The Rust Engineering Handbook / Chapter 41
Features, Conditional Compilation, Targets, and Portability
Design optional Rust capabilities that compose under Cargo feature unification and remain testable, portable, and SemVer-safe.
Two crates ask for different behavior from the same dependency. feature-left enables audit; feature-right enables metrics. The application calls both and receives the same answer:
capabilities=["unix-fd", "audit", "metrics"]
Neither path got a private configuration. Cargo resolved one capability-core package ID and compiled it with the union of requested features. This is the result a feature design must survive—not merely the result its author sees after running cargo test with defaults.
A Cargo feature is an additive compile-time capability request. It is not a product mode, a private preference of one dependent, or a reliable switch for disabling behavior another edge can enable. Portability begins with the same rule: express capabilities that can coexist, then select implementations from target facts without letting the public contract fracture by accident.
Keep two review questions separate as the chapter widens: which capabilities may accumulate on one package, and which implementation is valid for the target being built? Features answer the first; target facts answer the second. The public API must remain truthful at their intersection.
Model configurations as a capability lattice
For a package with audit and metrics, the configurations form a small lattice: neither enabled, either enabled alone, or both enabled. Enabling a feature moves upward by adding capability. The top must remain valid because dependency edges can request both.
[features]
default = []
audit = []
metrics = []
The fixture makes that model observable. Two wrapper crates request different features:
# feature-left
capability-core = { path = "../capability-core", features = ["audit"] }
# feature-right
capability-core = { path = "../capability-core", features = ["metrics"] }
The final binary depends on both wrappers. cargo tree -e features -p portable-agent exposes both paths; one compiled package contains both capabilities. The application asserts that both callers observe the same list.
This union rule changes API design. #[cfg(feature = "audit")] may add audit_label; enabling metrics must not remove it, change its signature, or reinterpret existing values. Features work well for optional integrations, protocol extensions, expensive subsystems, std support, or additional implementations. They work badly for exclusive choices such as “use format A instead of format B” when enabling both creates a contradiction.
Resolver version 2 and later avoid some unwanted unification across target-specific, build, proc-macro, and inactive dev-dependency contexts. That does not create edge-private features within an ordinary resolved unit. Inspect the graph relevant to the actual command rather than using resolver version as permission to design conflicting switches.

Figure 41-1. Feature requests join at the package; target facts choose an implementation beneath the stable capability contract.
Optional dependencies are authority behind a named gate
Marking a dependency optional = true prevents it from being included unless enabled. Cargo normally creates an implicit feature with the dependency’s name. Prefer an explicit public capability name and the dep: prefix when the dependency name is an implementation detail:
[dependencies]
wire-json = { version = "1", optional = true }
[features]
json-export = ["dep:wire-json"]
Now consumers choose json-export, not the vendor or crate name. That leaves room to replace the implementation in a compatible release if no dependency types leak into the public API.
Feature forwarding is an API decision:
[features]
json-export = ["dep:wire-json", "wire-json/std"]
The package/feature form can enable the optional package as well. The package?/feature form forwards a feature only if another capability already enabled that dependency. Use the distinction deliberately; a small punctuation change can add an entire dependency subtree.
For every optional dependency, record whether its code runs at runtime, build time, or both; which targets support it; whether its types escape; and which license, MSRV, security, and maintenance policies enter when enabled. “Optional” means absent from some graphs, not exempt from ownership.
Defaults are a consumer promise
Default features are enabled unless the consuming command or dependency edge disables them. They should represent a conservative common configuration, not every capability the maintainer can demonstrate.
default-features = false applies to an edge. If another active edge enables defaults, unification can still include them. Verify with cargo tree -e features; do not infer the resolved set from one manifest line.
Removing a feature from the default set can break consumers that relied on behavior without naming it. Moving existing public items behind a feature, deleting a feature, or changing a feature from additive to exclusive can also be SemVer-incompatible. Adding a feature is usually less disruptive, but its enabled behavior must still preserve existing contracts. Treat default-set changes and public feature gates as release design, not build cleanup.
Libraries often benefit from a small or empty default set when their consumers span embedded, server, browser, and command-line products. Applications can select a product configuration explicitly. A zero-default policy is not automatically better: if nearly every consumer needs one safe baseline, requiring repeated flags creates configuration drift. Choose defaults by consumer contract and commit to maintaining them.
Redesign mutually exclusive features
Suppose a codec defines fast and precise; each changes the same decode algorithm and compile_error! rejects both. This may pass isolated CI, yet a downstream graph can legitimately enable both through unrelated dependencies.
There are three stronger designs:
- Runtime policy:
Decoder::new(Policy::Fast)makes exclusivity a value chosen at the product boundary. One artifact can serve both modes; code size may increase. - Type policy:
Decoder<Fast>andDecoder<Precise>make the choice explicit in types. This supports static specialization but can expose generics and increase monomorphized code. - Separate packages: when implementations have incompatible native dependencies, licenses, targets, or release cycles, place them behind separate adapter crates. The shared abstraction remains neutral.
Keep features for additive availability: fast-backend may make Fast available without silently selecting it. Then enabling every feature yields a larger valid library rather than a contradiction.
cfg removes code; cfg! only computes a Boolean
#[cfg(predicate)] conditionally includes an item, statement in supported positions, or module. cfg!(predicate) expands to true or false, but both branches of an ordinary if still must type-check. Use #[cfg] when unsupported code must not be compiled.
The fixture chooses a module per target family:
#[cfg(unix)]
#[path = "platform_unix.rs"]
mod platform;
#[cfg(windows)]
#[path = "platform_windows.rs"]
mod platform;
#[cfg(not(any(unix, windows)))]
#[path = "platform_portable.rs"]
mod platform;
All modules supply EVENT_SOURCE, so callers see one contract. Unix uses file descriptors, Windows uses handles, and the fallback uses polling. The fallback is an explicit portability decision; it may be slower, but it avoids pretending every supported target has an OS-specific primitive.
Prefer the broadest fact that matches the requirement. Use target_has_atomic = "64" when the algorithm needs 64-bit atomics, not target_arch = "x86_64". Use target_family, target_os, pointer width, endianness, environment, or target_feature only when that property is truly the boundary. Obtain actual target facts with rustc --print cfg --target <triple>.
cfg_attr(predicate, attribute) applies an attribute conditionally. It is useful for platform-specific representation, lints, documentation annotations, and generated bindings. Keep it close to the affected item and test both sides. Dense nested predicates should be named through modules or build-validated custom cfg values rather than copied throughout the codebase.
Cargo target-specific dependency tables use cfg expressions to choose dependency edges. They do not support using Cargo features inside the target table predicate. Separate the axes: target facts decide which platform dependency can exist; features decide which additive package capabilities are requested.
Public APIs need one portability story
Conditional compilation can produce four kinds of API contract:
- an item is universally present and its implementation varies;
- an item exists only with a documented feature;
- an item exists only on documented targets;
- an item exists for a feature-target intersection.
The first is easiest to consume. Use the others when the capability genuinely does not exist or a dependency would impose unacceptable cost. Do not expose a platform type from a supposedly portable signature unless platform specificity is the point. Prefer local wrapper types, traits, or capability queries when they preserve truthful semantics.
Documentation should list every public feature, whether it is default, what it enables, dependencies and targets it adds, interactions, MSRV effects, and whether it changes the public API. Build documentation with important configurations. doc(cfg(...)) support has version/channel constraints, so verify the chosen documentation pipeline rather than making it the only record.
Custom cfg names deserve checking. Have build tooling emit cargo::rustc-check-cfg for expected values and let the compiler’s unexpected_cfgs lint catch misspellings or forgotten cases. A misspelled predicate that silently removes code is a portability defect.
Test risk-weighted configurations, not only defaults
With n independent features there are up to 2ⁿ combinations before targets, profiles, MSRVs, and dependency versions multiply the space. Exhaustive testing is practical for small safety-critical sets and impossible for many mature packages. Choose a documented coverage strategy:
- no defaults and defaults;
- each feature alone;
- all features together, because the union must work;
- known interacting pairs or triples;
- each supported target family and capability boundary;
- the MSRV and current stable compiler;
- public documentation and examples under their required features;
- downstream integration configurations for features that cross package edges.
Pairwise coverage can reduce cost but does not prove higher-order interactions absent. Use graph evidence to prioritize combinations that real products select. Compile unsupported targets where possible; run on representative hardware or emulation when behavior depends on OS or CPU semantics. A cross-compile proves type and link compatibility, not runtime correctness.
The fixture’s default workspace build reaches the feature union through real dependency paths. Direct package commands cover the empty, individual, and all-feature states:
cargo test -p capability-core --no-default-features --offline
cargo test -p capability-core --no-default-features --features audit --offline
cargo test -p capability-core --no-default-features --features metrics --offline
cargo test -p capability-core --all-features --offline
cargo tree -e features -p portable-agent --offline
Pin the toolchain and record target triples. “CI tested all features” without the command context, targets, and resolved graph is weak evidence.
Failure patterns worth rejecting
Exclusive feature modes. Two features change one behavior and reject their union. Move selection to a value, type, or adapter package.
The local disable illusion. One edge disables defaults and assumes they are absent. Inspect all incoming edges and the resolved feature tree.
OS-name capability guesses. Code checks linux when it needs an atomic width or environment property. State and test the actual capability.
A split public identity. Feature-gated aliases or re-exports cause the same function name to mean incompatible types across builds. Add capabilities without rewriting established meaning.
Default-only confidence. Tests pass while no-default, all-feature, target, or MSRV configurations rot. Make the matrix a maintained product artifact.
A silent fallback. Unsupported targets receive a slow or insecure implementation without documentation. Name its semantics and cost, or fail clearly at a deliberate boundary.
Exercise: replace modes with choices
Level: Integrate. A reusable parser has fast and precise features. Each selects a different algorithm, both enabled causes a compile error, fast is default, one backend uses a native library, and public errors expose backend-specific types. The parser supports Linux, Windows, and WebAssembly.
Produce a redesign that:
- traces which dependency paths enable each feature and demonstrates the union;
- chooses runtime policy, type policy, adapter packages, or a justified combination;
- keeps additive features only for making capabilities available;
- defines a target-capability matrix, including the native backend and fallback semantics;
- removes backend-specific types from shared public errors or explicitly scopes them;
- documents defaults, optional dependencies, MSRV, targets, API gates, and SemVer migration;
- specifies no-default, individual, union, interacting, target, documentation, and downstream tests;
- identifies compile-time, artifact-size, runtime, security, and maintenance costs;
- gives a compatibility path for existing consumers;
- states evidence that would reverse the design.
Reject a solution that merely renames the exclusive features. Evaluate whether every reachable union is valid and whether target differences sit beneath a truthful public contract.
Review record
Before approving a feature or target change, record the capability added, every enabling edge, default-set impact, optional authority, public API effect, supported target facts, fallback behavior, MSRV, tested configurations, documentation location, and removal policy. Run cargo tree -e features from product roots; review compile time and artifact impact with measurements rather than feature counts.
The durable rule is compact: features accumulate, targets describe facts, and public contracts must survive both. Once configuration is explicit, the next risk is the code that Cargo executes before the target crate is compiled.
Sources and verification notes
- Cargo Reference: Features, Feature resolver, Platform-specific dependencies, and SemVer compatibility.
- Rust Reference: Conditional compilation; rustc book: Checking conditional configurations; rustdoc book:
doc(cfg). - Executable source:
examples/rust-engineering-handbook/part-07/portability-build-lab/. - The recorded feature-tree, target-cfg, stable, and MSRV observations use Cargo/Rust 1.97.0 and Rust 1.85.0.
Continue reading
Full table of contents