Cargo Features
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in BCP 14 RFC 2119 RFC 8174 when, and only when, they appear in all capitals, as shown here.
This guide covers Cargo features as additive conditional compilation: declaring them, testing every combination, feature unification, and what they are not (runtime A/B switches). The toolchain, lint, formatting, and pre-commit rules in the Rust Style Guide apply and are not repeated here, and so do the dependency and CI rules; every version quoted is listed in the Tested Version Matrix and was current on 8 September 2026.
Cargo features are additive compile-time configuration. They MUST NOT be used for A/B testing, staged rollout, or any experiment whose cohort is decided per request. Runtime experiments MUST use runtime configuration.
Why
A Cargo feature is resolved when the binary is built and is the same for every
user of that binary, so an “experiment” behind a feature requires a rebuild and
a redeploy to change cohort, and cannot be turned off without one. Features are
also unioned, not selected: if any crate in the graph, any
--all-features invocation, or feature unification across a workspace enables
variant-b, it is enabled for everyone. That is the opposite of what an
experiment needs.1 A feature that turns something off is a further
mistake, because a dependency enabling the feature can never be countermanded;
express the choice as an additive no-foo-free positive feature instead.
| Requirement | Mechanism |
|---|---|
| Optional dependency or platform backend | Cargo feature |
| API surface that some consumers do not compile | Cargo feature |
| Per-request or per-user cohort | Runtime configuration |
| Kill switch that must work without redeploy | Runtime configuration |
| Gradual rollout by percentage | Runtime configuration |
// GOOD: a runtime experiment. Cohort assignment is data, not a build flag.
pub struct Experiments {
rollout_percent: u8,
}
impl Experiments {
#[must_use]
pub fn use_new_ranker(&self, user_id: u64) -> bool {
u8::try_from(user_id % 100).is_ok_and(|bucket| bucket < self.rollout_percent)
}
}
# BAD: an experiment as a Cargo feature. Changing cohort means a rebuild, and
# any crate in the graph can switch it on for everybody.
[features]
new-ranker = []
Feature Flags via Cargo Features
# Cargo.toml — additive, positive names, one dialect selected explicitly
[features]
default = ["postgres"]
postgres = []
sqlite = []
Overlapping features MUST have an explicit, documented precedence, and
every cfg arm MUST be reachable in exactly one configuration.
Why: Cargo features are additive. A dependency, --all-features, or
feature unification across a workspace can enable postgres and sqlite
together. Writing #[cfg(feature = "postgres")] against
#[cfg(not(feature = "postgres"))] in the implementation while the test
asserts on postgres and sqlite independently makes both assertions
compile at once, and cargo test --all-features fails with
left: "$1", right: "?".
// GOOD: postgres takes precedence over sqlite; neither is a build error.
#[cfg(feature = "postgres")]
#[must_use]
pub fn placeholder(index: usize) -> String {
format!("${index}")
}
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
#[must_use]
pub fn placeholder(_index: usize) -> String {
"?".to_owned()
}
#[cfg(not(any(feature = "postgres", feature = "sqlite")))]
compile_error!("enable the `postgres` or `sqlite` feature to select a dialect");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholder_matches_the_selected_dialect() {
// Exactly one arm is compiled in, mirroring the precedence above.
#[cfg(feature = "postgres")]
assert_eq!(placeholder(1), "$1");
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
assert_eq!(placeholder(1), "?");
}
}
// BAD: with both features enabled, both assertions compile and contradict.
#[test]
fn placeholder_matches_the_selected_dialect() {
let result = placeholder(1); // "$1"
#[cfg(feature = "postgres")]
assert_eq!(result, "$1");
#[cfg(feature = "sqlite")]
assert_eq!(result, "?"); // fails under --all-features
}
Feature Unification
A feature enabled anywhere in the graph is enabled for the whole build of that crate. Libraries MUST NOT rely on a feature being off, and MUST document which combinations are supported.
Why: if crate A depends on my_crate with sqlite and crate B depends on
my_crate with postgres, Cargo builds one my_crate with both. Code written
on the assumption that the two are alternatives is compiled with both enabled
and takes whichever arm is written first. Testing only the default feature set
never exercises that build.
Feature Matrix in CI
Projects with optional features MUST test the combinations they support, not only the default set:
jobs:
features:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
features:
- "" # default
- "--all-features" # unified: both dialects
- "--no-default-features --features sqlite"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
- run: cargo test ${{ matrix.features }}
--no-default-features on its own is deliberately absent: this crate has no
supported no-dialect build and says so with compile_error!. A matrix
MUST list only combinations the crate claims to support.
Conditional Compilation with cfg Attributes
Every cfg-gated item that unconditional code calls MUST be defined for
every target the CI matrix builds, or the build MUST fail with an explicit
message.
Why: target_os arms for Linux and Windows alone leave the item undefined
on the macos-latest runner, and the unconditional test call fails with
error[E0425]: cannot find function 'platform_specific' in this scope. A
compile_error! in the fallback arm turns a confusing name-resolution error
into a statement of what is missing.
#[cfg(target_os = "linux")]
fn platform_specific() -> &'static str {
"Linux"
}
#[cfg(target_os = "macos")]
fn platform_specific() -> &'static str {
"macOS"
}
#[cfg(target_os = "windows")]
fn platform_specific() -> &'static str {
"Windows"
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
)))]
compile_error!("platform_specific() has no implementation for this target_os");
#[test]
fn test_platform_behavior() {
// Assert the value, not merely that one exists. `!is_empty()` passes for
// every arm, including a macOS build that wrongly returns "Windows".
#[cfg(target_os = "linux")]
assert_eq!(platform_specific(), "Linux");
#[cfg(target_os = "macos")]
assert_eq!(platform_specific(), "macOS");
#[cfg(target_os = "windows")]
assert_eq!(platform_specific(), "Windows");
}
// Testing multiple feature combinations
#[test]
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
fn placeholder_is_postgres_only() {
// Only runs when postgres is enabled but sqlite is not
assert_eq!(placeholder(1), "$1");
}
A cfg-gated test MUST assert the value the selected arm is supposed to
produce, not merely that some value exists.
Why: assert!(!result.is_empty()) holds for every arm, so it cannot
distinguish a correct implementation from one whose arms are swapped. Copying
the "Windows" body into the target_os = "macos" arm keeps that test green
on a macOS runner; the value assertion above fails it with
left: "Windows", right: "macOS". The same applies to feature-gated tests:
mirror the precedence in the assertions so exactly one is compiled and it names
the expected result.
References
See Also
- Rust Style Guide - Toolchain, lints, formatting, dependencies, and CI
- Rust topic guides - The other Rust topic guides
Footnotes
-
Cargo features - Feature unification and additive semantics ↩