Skip to content
rusthacks.dev

Cargo Features Explained and How They Unify in a Workspace

A feature enabled by any crate in your dependency graph is enabled for everyone using that crate. Learn how resolver 2 and 3 limit it, how to find who enabled what and rules for additive features.

6 min read
Summarize in AI
Flat illustration for the post “Cargo Features Explained and How They Unify in a Workspace”, composed around ownership.

A test passes when you run cargo test -p a and fails when you run cargo test --workspace, with no change to the code. That is feature unification, and it is the reason "works on my machine" is a real hazard in Cargo workspaces. Cargo compiles each crate once per build, with the union of every feature anyone in the build asked for, so a crate can quietly gain behaviour because a sibling wanted it. This post reproduces the effect in a four-crate workspace, shows how to see it, and lists the habits that keep it harmless.

Everything here ran on Cargo 1.99 with edition 2024 and resolver 3, using only path dependencies so you can rebuild it offline.

A workspace that proves it

One library, core-lib, has a feature called fast that changes what mode() returns. Crate a depends on it plainly. Crate b depends on it with fast enabled. A binary uses both.

core/src/lib.rsrust
pub fn mode() -> &'static str {
    if cfg!(feature = "fast") { "fast" } else { "plain" }
}
core/Cargo.tomlTOML
[package]
name = "core-lib"
version = "0.1.0"
edition = "2024"

[features]
default = []
fast = []
a/Cargo.tomlTOML
[package]
name = "a"
version = "0.1.0"
edition = "2024"

[dependencies]
core-lib = { path = "../core" }
b/Cargo.tomlTOML
[package]
name = "b"
version = "0.1.0"
edition = "2024"

[dependencies]
core-lib = { path = "../core", features = ["fast"] }

Both a and b expose pub fn run() -> &'static str { core_lib::mode() }, and the application prints each:

app/src/main.rsrust
fn main() {
    println!("a sees: {}", a::run());
    println!("b sees: {}", b::run());
}
Text
$ cargo run -q -p app
a sees: fast
b sees: fast

Crate a never asked for fast, and sees it anyway. There is one compiled copy of core-lib in the build, and it was built with the union of the requests: the empty set from a plus ["fast"] from b. Cargo cannot compile two variants of the same crate version side by side, because they would define the same types twice and values would not be interchangeable between them.

Why additive is the only safe design

The union rule is what makes features composable at all. If crate a wants a serde integration and crate b wants a different one, the build gets both and both are satisfied. That only works if turning a feature on can add code without taking anything away. A feature that swaps one behaviour for another, or removes an API, breaks whichever dependent expected the other side.

The mode() function above is exactly that anti-pattern: fast changes an existing return value. Crate a relied on the default and is now wrong through no fault of its own. The fix in the library is to make the new behaviour a new function, fast_mode(), so enabling the feature adds an item and changes none.

The failure you will meet in practice

Give crate a a test that assumes the plain mode:

a/src/lib.rsrust
pub fn run() -> &'static str {
    core_lib::mode()
}

#[cfg(test)]
mod tests {
    #[test]
    fn expects_plain_mode() {
        assert_eq!(super::run(), "plain");
    }
}
Text
$ cargo test -p a
test tests::expects_plain_mode ... ok

$ cargo test --workspace
test tests::expects_plain_mode ... FAILED

---- tests::expects_plain_mode stdout ----

thread 'tests::expects_plain_mode' panicked at a/src/lib.rs:9:9:
assertion `left == right` failed
  left: "fast"
 right: "plain"

error: test failed, to rerun pass `-p a --lib`

Selecting one package with -p means only that package's requests count, so core-lib is built without fast. Selecting the whole workspace brings b in, and the feature appears. This is also why a CI job that runs cargo test --workspace can be green while a developer running one crate's tests sees a different result. Neither is wrong; they are different builds.

Fixing the library so unification cannot hurt

The repair is to stop letting a feature change what an existing function returns. Keep mode() constant and add a second function that exists only when the feature is on:

core/src/lib.rsrust
pub fn mode() -> &'static str {
    "plain"
}

#[cfg(feature = "fast")]
pub fn fast_mode() -> &'static str {
    "fast"
}

Now crate a's test passes in both forms, because a only calls mode(), and crate b opts in by calling fast_mode(). When the union turns the feature on for everyone, nothing that already existed changes meaning; a new name appears and callers who do not use it never notice. This is what "features must be additive" means in practice: enabling a feature can only add items to the public API surface, never change one.

The same reasoning rules out three designs people reach for. A feature that switches a type alias between two backends (type Map = HashMap versus type Map = BTreeMap) changes the meaning of an existing name. A feature that adds a field to a public struct breaks every struct literal that does not mention it. And a feature that makes a trait method required breaks every existing implementor. If you need alternatives, expose both items under different names and let the caller choose.

The build-time cost of unification

Unification also decides how much code gets compiled. Say ten crates in a workspace depend on tokio, nine of them with features = ["rt"] and one with features = ["full"]. Every crate in the build links the full variant, because there is one tokio. Nothing is wrong, but the nine minimal crates are no longer minimal, and a binary you hoped would be small now carries the whole runtime surface. Use cargo tree -e features -i tokio to find the crate asking for full, and ask whether it really needs it. Large workspaces sometimes add a generated "workspace hack" crate (the cargo-hakari tool does this) that forces one consistent feature set for the whole tree, which trades a slightly bigger build for far fewer rebuilds when you switch between -p selections.

How to see who asked for what

cargo tree -e features prints the graph with feature edges, and the branch that enabled a feature is visible:

Text
$ cargo tree -p app -e features
app v0.1.0
├── a feature "default"
│   └── a v0.1.0
│       └── core-lib feature "default"
│           └── core-lib v0.1.0
└── b feature "default"
    └── b v0.1.0
        ├── core-lib feature "default" (*)
        └── core-lib feature "fast"
            └── core-lib v0.1.0

The line core-lib feature "fast" sits under b, so b is the dependent that turned it on. For a single crate, cargo tree -e features -i core-lib inverts the view and answers "who enabled features on this?" directly. Run it before spending an hour debugging a cfg that "should not be set".

What the resolver version changes

Edition 2021 and later default to resolver 2, and edition 2024 to resolver 3. Resolver 2 stopped unifying features across three boundaries that version 1 crossed: build dependencies versus normal ones, dev dependencies when not building tests, and target-specific dependencies for platforms you are not building. It does not stop unification between two normal dependents of the same crate, which is the case above. A virtual workspace has no root package to pick an edition from, so set it yourself, as the first manifest above does with resolver = "3". The resolver post has the details.

Habits that keep it harmless

  • Design features to be additive and test the claim: enable each in isolation and all together. Testing every combination covers cargo hack --feature-powerset.
  • Never gate a type's fields or a trait's required methods on a feature; gate whole items.
  • Do not use a negative feature such as no-std or disable-logging, because the union makes "disabled" impossible to guarantee. Use std as a default-on feature and let users opt out of it.
  • When a workspace's tests disagree with one crate's tests, run the workspace form in CI too, since that is what a real application build looks like.

Where unification does not bite

A binary that depends on a crate once, with no feature disagreement from anywhere else, sees exactly what it asked for, and so does a single-crate project. Unification also does not cross separate workspaces or separate cargo build invocations. If you genuinely need two incompatible variants of one library, the answer is two crates (core-lib and core-lib-fast) or a runtime switch, not a feature. For the manifest syntax behind everything above see Cargo.toml explained, and for the formal rules the features reference.

Taggedfeatures
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.