When one binary isn't enough
You're building a data parser. Most users just want the basic text parsing. A power user wants regex support. An embedded engineer needs the code to run without the standard library. You can't force everyone to download regex. You can't break the embedded build by importing std. You need a way to toggle functionality on and off without forking the codebase.
Cargo features solve this. They let you define optional chunks of functionality that users can activate. The compiler includes or excludes code based on these switches.
How features work
Cargo features are compile-time switches. They change what code gets compiled and what dependencies get downloaded. When a user enables a feature, Cargo includes that code and any associated dependencies. When the feature is off, the code vanishes from the binary, and the dependencies stay out of the build.
Think of it like a car configurator. The base model comes with four wheels and an engine. You can add "leather seats" or "sunroof". The factory builds the car differently based on the options selected. In Rust, the "factory" is the compiler, and the options are your features.
Features are not runtime flags. You cannot check a feature value inside a running program. The decision happens when the binary is built.
Features are compile-time, not runtime. The switch flips before the code runs.
Minimal example
Define features in Cargo.toml under the [features] section. Each key is a feature name. The value is a list of other features or dependencies that get enabled when this feature is active.
[package]
name = "my-lib"
version = "0.1.0"
[features]
# default features enabled unless user passes --no-default-features
default = ["std"]
# std feature enables standard library support
std = []
# regex feature pulls in the regex crate using dep: syntax
regex = ["dep:regex"]
[dependencies]
# optional = true means this crate only downloads if a feature enables it
regex = { version = "1.10", optional = true }
Use #[cfg(feature = "...")] in your code to guard blocks that depend on a feature.
/// Basic parsing function available in all builds.
pub fn parse_basic(input: &str) -> &str {
input.trim()
}
/// Regex parsing only compiles when the "regex" feature is active.
#[cfg(feature = "regex")]
pub fn parse_with_regex(input: &str) -> bool {
// regex::is_match compiles the pattern and checks it in one step.
// This function disappears from the binary if the feature is off.
regex::is_match(r"\d+", input).unwrap_or(false)
}
Convention: The dep: prefix in regex = ["dep:regex"] is the modern standard. It explicitly tells Cargo that regex refers to a dependency, not another feature. Older code might use regex = ["regex"], which works only if the feature name matches the dependency name. Use dep: to avoid ambiguity.
The compiler respects the flag. If the feature is off, the code is gone.
What happens at compile time
When you run cargo build, Cargo reads Cargo.toml. It sees default = ["std"]. It enables the std feature. It looks at std = []. No dependencies there. It compiles lib.rs. The compiler sees #[cfg(feature = "regex")]. The regex feature is not active. The compiler skips that function entirely. It is as if the code never existed.
Now run cargo build --features regex. Cargo enables regex. It sees regex = ["dep:regex"]. It downloads the regex crate. It compiles lib.rs. The #[cfg] check passes. The function gets compiled. The binary grows. The dependency tree grows.
Features are transitive. If you depend on my-lib, you can enable its features. Cargo propagates feature activations down the dependency graph.
Transitive features mean one switch can ripple through your entire dependency tree.
Realistic example: no_std support
The most common use for features is supporting no_std environments. Embedded systems, kernels, and freestanding programs often lack the standard library. You can write a library that works in both worlds by making std optional.
[features]
default = ["std"]
std = []
// no_std means no standard library by default.
// This is common for embedded systems or kernels.
#![no_std]
/// A simple counter that works without the standard library.
pub struct Counter {
value: u32,
}
impl Counter {
pub fn new() -> Self {
Counter { value: 0 }
}
pub fn increment(&mut self) {
self.value += 1;
}
pub fn get(&self) -> u32 {
self.value
}
}
// Only include std-specific code when the "std" feature is on.
#[cfg(feature = "std")]
mod std_support {
use super::Counter;
// Implement Display only when std is available.
impl std::fmt::Display for Counter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Counter: {}", self.value)
}
}
}
Convention: Libraries that support no_std almost always use default = ["std"]. This gives desktop and server users the full experience out of the box. Embedded users opt out by adding default-features = false to their dependency declaration.
Support no_std by defaulting to std, and let embedded users opt out.
Pitfalls and errors
Feature unification can surprise you. You depend on crates-a and crates-b. Both depend on crates-c. crates-a enables feat-x on c. crates-b enables feat-y on c. Cargo builds c with both x and y. You cannot have two versions of c with different features in the same binary. This is called feature unification. It can cause bloat or unexpected behavior if features conflict. If feat-x and feat-y toggle incompatible code, your build might fail or behave strangely.
If you use a type inside a #[cfg] block but forget the feature, you get E0432 (use of undeclared type). The compiler sees the code but the dependency isn't there. The error points to the missing item, not the missing feature. Check your #[cfg] guards first.
Forgetting optional = true on a dependency breaks the feature flag. If you list a dependency in [dependencies] without optional = true, it downloads and compiles even if no feature enables it. The feature flag becomes useless. The dependency is always present.
Feature unification is silent. Check your dependency tree if your binary grows unexpectedly.
When to use features
Use features when a dependency is heavy and not all users need it. Reach for optional = true combined with a feature flag to keep the default build small and fast.
Use features when supporting no_std environments. Define a std feature that enables standard library traits and types, and make it the default so most users get the full experience without extra flags.
Use features when offering multiple backends or algorithms. Let users choose between a fast SIMD implementation and a portable fallback, or between a blocking and async interface.
Reach for environment variables or config files when you need runtime toggles. Features are compile-time only. You cannot flip a feature on or off while the program is running.
Avoid features for minor code paths. If the code is small and the dependency is negligible, just include it. Features add cognitive overhead for users and maintenance overhead for you.
Keep features focused. One feature, one capability.