Cargo features are compile-time switches. In a workspace they interact: Cargo compiles each dependency once per build with the union of every feature any selected package asked for. This file covers how to design features that survive that, and how to find the bugs unification hides.
Contents
- How features resolve
- What resolver 2 and 3 change
- Design rules
- Optional dependencies, dep: and weak features
- default-features across a workspace
- Unification pitfalls
- Inspecting features
- Testing feature combinations
- Workspace-hack crates
- Mutually exclusive choices
- Checklist
How features resolve
[features]
default = ["std"]
std = []
json = ["dep:serde_json", "serde"]
serde = ["dep:serde", "shop-domain/serde"]- A feature is a name mapped to a list of other features,
dep:name(enable an optional dependency) orpkg/feature(enable a feature of a dependency). defaultis enabled unless the dependent writesdefault-features = false(or passes--no-default-featuresfor the package being built).- For one build, Cargo computes the set of packages to compile (the selected members and their dependency
graph), then for each dependency the union of features requested anywhere in that graph. One
tokiois compiled withrt-multi-threadbecause one crate asked for it, and every other crate in that build seesrt-multi-threadtoo. - Which members are selected matters:
cargo build --workspace,cargo build -p shop-app, and building insidecrates/shop-app/(which selects only that package) can produce three different feature sets for the same dependency.
What resolver 2 and 3 change
With resolver 1, features are unified across every use of a dependency. Resolver 2 (default for edition
2021, explicit resolver = "2" in virtual manifests) splits the union in three places:
| Situation | Resolver 1 | Resolver 2 / 3 |
|---|---|---|
| Same dep used as build-dependency (or by a proc-macro) and as a normal dependency | one feature set | host (build scripts, proc-macros) and target resolved separately; may compile twice |
| Dev-dependency enables extra features | always unified into the normal build | only when building targets that need dev-deps (tests, examples, benches) |
[target.'cfg(windows)'.dependencies] features | unified even on Linux | only for the platform being built |
--features / --no-default-features in a workspace | applied in odd ways to the root package | applied to the selected packages; -F crate/feat syntax for others |
Resolver 3 keeps resolver 2's feature behaviour and adds MSRV-aware version selection; it does not change feature unification.
Consequence of the dev-dependency rule: cargo build and cargo test can compile a dependency with different
features (e.g. tokio gets test-util only for tests), so switching between them may rebuild that dependency.
That is the price of not leaking test-only features into release builds.
Design rules
- Features are additive. Enabling a feature may add items or capabilities; it must never remove an API, change a type's layout used by other crates, or change behaviour another crate relies on. Because of unification, some other crate can always turn your feature on.
- No negative features.
no_std,disable-tls,without-xbreak when two crates disagree. Invert them:std(default),tls,x. - Small defaults for libraries. Defaults are hard to turn off downstream (every dependent must write
default-features = false, and inheritance cannot turn them off later). Put the common, cheap path indefault; make heavy integrations opt-in. - Name features after capabilities, not dependencies, when the dependency is an implementation detail:
compressionrather thanflate2. Usedep:so the dependency name does not become a public feature. - Every feature combination compiles. At least: no features, default, each feature alone, all features.
cargo hackautomates it. - Forward features explicitly through layers. If
shop-infrahasserde, it enablesshop-domain/serde; it does not rely on someone else having enabled it. - Document features in the crate docs and gate docs with
#[cfg_attr(docsrs, doc(cfg(feature = "x")))](nightly docs.rs feature) or simply list them in the README. - Test-only behaviour does not go in a feature that ships. Use dev-dependencies,
#[cfg(test)], or atest-supportcrate.
Optional dependencies, dep: and weak features
Since 1.60:
[dependencies]
serde = { workspace = true, optional = true, features = ["derive"] }
serde_json = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
[features]
default = ["std"]
std = ["serde?/std"] # weak: only if serde is already enabled; does not enable serde
serde = ["dep:serde"] # explicit feature name for the optional dep
json = ["serde", "dep:serde_json"]
runtime = ["dep:tokio", "tokio?/rt-multi-thread"]- Without
dep:, every optional dependencyfooimplicitly creates a featurefoo. As soon as any feature usesdep:foo, that implicit feature is not created, so the dependency name stops being part of your public feature API. pkg?/feat(weak dependency feature) enablesfeatonpkgonly ifpkgis enabled by something else. Without the?,std = ["serde/std"]would turn serde on wheneverstdis on.- Manifests using
dep:or?/need Cargo 1.60+; declarerust-versionaccordingly (CW020 checks it). #[cfg(feature = "serde")]in code refers to feature names, not dependency names. Since 1.80 rustc checks cfg names and values (unexpected_cfgs), so a misspelled#[cfg(feature = "serd")]warns instead of silently compiling the item out.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrderId(pub u64);default-features across a workspace
Workspace inheritance is additive, so the root decides the floor:
# root Cargo.toml
[workspace.dependencies]
tokio = { version = "1.38", default-features = false }
serde = { version = "1", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }# crates/shop-infra/Cargo.toml
[dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "net", "fs"] }
serde = { workspace = true, features = ["derive", "std"] }
reqwest = { workspace = true, features = ["json"] }Pitfalls:
{ workspace = true, default-features = false }with a root entry that keeps defaults on does nothing; Cargo warns, edition 2024 rejects it (CW016).- Turning defaults off at the root but forgetting
stdon a crate that needs it:serdewithoutstdcompiles in--workspacebuilds (some other crate enabledstd) but fails with-palone. Test withcargo hack. - One crate anywhere in the build that depends on
reqwestwith defaults re-enablesnative-tlsfor everyone;cargo tree -e features -i native-tlsfinds who.
Unification pitfalls
| Symptom | Cause | Fix |
|---|---|---|
cargo build --workspace passes, cargo build -p shop-app fails with "unresolved import" / "no method" | shop-app uses a dependency feature it never requested; another member enabled it | add the feature in shop-app's manifest; cargo hack check --each-feature per package |
cargo test passes, cargo build --release fails | feature only enabled by a dev-dependency | add it to the normal dependency |
| Dependency rebuilds every time you alternate between two commands | different feature sets (or RUSTFLAGS) per command | build with consistent selections, workspace-hack crate, separate IDE target dir |
| Binary larger / slower to compile than expected | some crate enabled heavy defaults (e.g. full) | cargo tree -e features -i <dep>; disable defaults at root |
Two copies of a crate in cargo tree -d | different major versions requested | align versions via [workspace.dependencies]; cargo update -p dep@old |
links conflict ("only one package in the dependency graph may specify the same links value") | two versions of a -sys crate | unify the version of the wrapper crates that pull them in |
| Proc-macro dependency compiled twice | host vs target feature split (resolver 2) | expected; keep proc-macro deps' features minimal |
| Feature-gated code in a library is never compiled in CI | CI only builds --workspace with default features | --all-features job and cargo hack --each-feature job |
Inspecting features
cargo tree -e features -i tokio # who enables which tokio features (inverted)
cargo tree -p shop-app -e features # features active when building shop-app alone
cargo tree --workspace -d # duplicate versions
cargo tree -e normal --target all -i openssl-sys # who pulls a -sys crate on any platform
cargo metadata --format-version 1 | jq '.resolve.nodes[] | select(.id | contains("tokio")) | .features'
cargo build -p shop-app -v 2>&1 | grep -- '--cfg .feature' # the cfgs rustc actually gotTesting feature combinations
cargo-hack (taiki-e) runs a cargo command over feature combinations, one package at a time, which is exactly what catches unification bugs:
cargo install cargo-hack --locked
cargo hack check --workspace --each-feature --no-dev-deps # every feature alone (+ none, + all)
cargo hack check --workspace --feature-powerset --depth 2 --no-dev-deps # pairs of features
cargo hack check --workspace --feature-powerset --exclude-features nightly --group-features a,b
cargo hack test --workspace --each-feature # run tests per feature (slower)
cargo hack check --rust-version --workspace # each crate on its declared rust-version--no-dev-depsdrops dev-dependencies so their features cannot mask a missing one (and is faster); it edits manifests temporarily and restores them.--feature-powersetgrows exponentially; bound it with--depth,--group-featuresand--exclude-features.- Feature combinations that are intentionally unsupported can be skipped with
--exclude-featuresor documented withcompile_error!.
Workspace-hack crates
Large workspaces sometimes add a crate (conventionally workspace-hack) that depends on every third-party
crate with the union of features any member uses, and make every member depend on it. Then every build
selection sees the same feature set: no rebuilds when switching -p, and better cache hits. cargo-hakari
generates and maintains such a crate (cargo hakari generate, cargo hakari manage-deps, cargo hakari verify in CI).
Trade-offs: members compile features they do not need, the crate must be publish = false and is stripped or
replaced when publishing, and it is one more moving part. Worth it at tens of crates and heavy dependencies;
overkill for five crates.
Mutually exclusive choices
Sometimes a choice really is exclusive (TLS backend, async runtime, allocator). Options in order of preference:
- Avoid it at the library level. Take the choice as a type parameter, a trait object or a runtime value; let the binary pick.
- Make both work at once. Enable
rustlsandnative-tlssimultaneously and pick at runtime. - Decide in the binary only. Libraries expose features; only
apps/*enable exactly one. - Fail loudly if both are enabled:
#[cfg(all(feature = "runtime-tokio", feature = "runtime-async-std"))]
compile_error!("features `runtime-tokio` and `runtime-async-std` are mutually exclusive");Remember that unification can enable both behind your back; compile_error! turns a silent wrong choice into
a clear build error.
Checklist
- Root
[workspace.dependencies]disables default features of heavy crates; members add what they use - No negative features;
stdis a default, notno_stda feature - Optional deps use
dep:; cross-crate feature forwarding uses?/where appropriate - Every member builds alone:
cargo hack check --workspace --each-feature --no-dev-deps - CI has a
--all-featuresjob and a no-default-features job for libraries -
cargo tree -dreviewed; duplicates are intentional or tracked - Exclusive choices live in binaries, or are guarded with
compile_error!
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.