Cargo Features and Conditional Compilation

Modules and Project Organization covered splitting code across modules and crates. Cargo features are the other axis of organization: compiling different subsets of the same crate depending on what a consumer asks for. They power optional dependencies, platform-specific code, and the --no-default-features slimming that embedded and WASM builds rely on. They also have one genuinely surprising rule, feature unification, that causes confusing bugs in larger dependency trees. This tutorial covers the mechanics and that pitfall.


Declaring and Gating a Feature

Features are named flags declared in the [features] table of Cargo.toml. Each maps to a list of other features or optional dependencies it enables. In code, #[cfg(feature = "...")] conditionally compiles an item only when that feature is active.

# Cargo.toml
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
compression = ["dep:flate2"]

[dependencies]
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
flate2 = { version = "1", optional = true }
pub struct Response {
    body: Vec<u8>,
}

impl Response {
    #[cfg(feature = "json")]
    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
        serde_json::from_slice(&self.body)
    }
}

The json method simply doesn't exist when the json feature is off, the code isn't compiled, and the serde/serde_json dependencies aren't pulled in or built at all. A consumer who only needs raw bytes pays nothing for the JSON machinery.


Optional Dependencies and dep:

Marking a dependency optional = true means it isn't compiled unless some feature turns it on. The modern syntax for that is dep:serde inside a feature's list, which references the optional dependency without implicitly creating a feature of the same name.

[features]
json = ["dep:serde", "dep:serde_json"]

Before dep: existed, every optional dependency automatically became an implicitly-named feature, so serde = { optional = true } silently created a serde feature you didn't ask for, leaking an implementation detail (which library you happen to use) into your crate's public feature surface. Using dep: keeps the dependency private to the feature that needs it: consumers enable json, not serde, and you're free to swap the underlying library later without it being a breaking change.


Default Features and Opting Out

The special default feature is the set enabled automatically when someone depends on your crate without specifying otherwise. Consumers can opt out with default-features = false and then re-enable only what they need, the standard pattern for slimming a build:

# in a consumer's Cargo.toml
[dependencies]
my_crate = { version = "1", default-features = false, features = ["compression"] }

This is how crates support no_std, WASM, or embedded targets: the heavyweight defaults (often including std itself) are bundled into default, and a constrained target turns them off. Put only genuinely common, broadly-safe functionality in default, anything a meaningful subset of users would want to exclude (a runtime, std, a large optional dep) belongs in a named non-default feature instead, so opting out is possible without losing everything.


Conditional Compilation Beyond Features

#[cfg(...)] gates on more than features, it also handles platform and build differences, which is how one codebase targets multiple operating systems:

#[cfg(target_os = "windows")]
fn config_path() -> PathBuf { /* %APPDATA%\... */ }

#[cfg(not(target_os = "windows"))]
fn config_path() -> PathBuf { /* ~/.config/... */ }

#[cfg(test)]
mod tests { /* only compiled under `cargo test` */ }

cfg predicates combine with all(...), any(...), and not(...): #[cfg(all(unix, feature = "async"))] compiles only on Unix and with the async feature on. For an expression-level check rather than gating a whole item, cfg!(...) evaluates to a bool at compile time, usable inside normal control flow without splitting code into two #[cfg]-annotated copies. The #[cfg(test)] you've seen throughout this series (e.g. in Testing in Practice) is the same mechanism.


The Pitfall: Feature Unification

This is the rule that surprises people. Within a single build, Cargo enables the union of every feature requested for a given crate, anywhere in the entire dependency graph. If your crate depends on foo with no features, but another dependency also pulls in foo with foo/heavy-feature on, your build gets foo with heavy-feature enabled too, whether you wanted it or not.

your_app
├── depends on  foo            (you wanted: no features)
└── depends on  bar
    └── depends on  foo  with  feature "heavy"

Result: foo is compiled ONCE, with "heavy" enabled, for the whole build.

Cargo compiles each crate only once per build, so it can't give you a featureless foo and bar a heavy foo simultaneously, it unifies them. The practical consequences:

  • Features must be purely additive. A feature should only ever add capability, never change or remove existing behavior, because any consumer in the graph can silently turn it on for everyone. A feature that, say, changes a function's return type or disables an API will break unrelated crates that didn't ask for it.
  • You can't rely on a feature being off. Code must remain correct whether or not any given feature is enabled, since you don't control what the rest of the dependency tree requests.

This is why "mutually exclusive features" are an anti-pattern in Rust: unification means both can end up on at once, and there's no mechanism to prevent it. Design features as independent, additive switches, and the unification rule stops being a hazard.


Key Takeaways

  • Features are named flags in [features]; #[cfg(feature = "x")] compiles an item only when x is active, so disabled functionality (and its dependencies) costs nothing.
  • Mark optional dependencies optional = true and reference them with dep:name in a feature list, this keeps the dependency private instead of leaking it as an implicitly-named public feature.
  • default is the auto-enabled feature set; consumers slim a build with default-features = false. Keep default to broadly-safe essentials so opting out stays practical (the basis for no_std/WASM support).
  • #[cfg(...)] also gates on platform (target_os), test builds (cfg(test)), and combinations via all/any/not; cfg!(...) gives a compile-time bool for inline checks.
  • Feature unification: Cargo enables the union of all features requested for a crate across the whole dependency graph, and compiles it once. Make every feature purely additive, and never rely on one being off, because you don't control what other crates turn on.