The problem with one-size-fits-all binaries
You are building a networking library. Some users want TLS encryption. Others want raw UDP support. A few want both. If you compile everything into one binary, you force every user to link cryptographic libraries they never use. Your binary grows. Compile times stretch. Attack surfaces expand.
Rust solves this with feature flags. You declare optional capabilities in your manifest. Users opt into what they need. The compiler strips everything else before optimization begins. The final binary contains only the code paths that were actually requested.
How feature flags actually work
Think of a modular synthesizer. The base unit handles audio routing. You plug in oscillators, filters, or effects only when you need them. Unused modules stay in the box. They take up zero rack space. They draw zero power.
Rust handles optional functionality the same way. You mark code blocks with a compile-time attribute. The attribute checks a flag name. If the flag is active, the compiler includes the code. If the flag is inactive, the compiler deletes the code entirely. No runtime checks. No conditional branches. The dead code vanishes at compile time.
This mechanism lives in Cargo, Rust's package manager. You define features in Cargo.toml. You activate them with #[cfg(feature = "name")] in your source files. The two pieces talk to each other during the build process.
Minimal example
Start with a simple library that offers an optional logging helper.
[package]
name = "my-lib"
version = "0.1.0"
edition = "2021"
# Declare available features.
# Each key is a feature name.
# The value lists other features or dependencies it enables.
[features]
default = []
verbose_logging = []
/// Core functionality that always compiles.
pub fn do_work() -> String {
// This runs regardless of feature flags.
"work done".to_string()
}
/// Optional logging that only exists when the flag is active.
#[cfg(feature = "verbose_logging")]
pub fn log_status(message: &str) {
// The compiler includes this function only when verbose_logging is enabled.
println!("[VERBOSE] {}", message);
}
Build the library without the flag. The log_status function disappears from the compiled artifact. Build it with cargo build --features verbose_logging. The function appears. The rest of the crate remains unchanged.
Convention aside: always list default = [] explicitly when you have features. It signals to users that your crate ships lean by default. They must opt into extras intentionally.
Trust the attribute. If you forget the flag, the function simply does not exist. The compiler will not guess.
What happens under the hood
When you run cargo build, Cargo reads Cargo.toml. It builds a dependency graph. It notes which features are requested on the command line or by downstream crates. It passes those feature names to rustc as preprocessor definitions.
rustc scans every source file. It evaluates #[cfg(...)] attributes. Matching blocks stay in the token stream. Non-matching blocks get discarded. The remaining code goes through macro expansion, type checking, and optimization.
This means feature flags affect compilation time. Enabling more features means more code to parse and check. It also means your crate's public API changes based on the flag. A downstream crate that depends on you must enable the same feature to see the gated items. Cargo handles this transitively. If crate A enables feature = "x" for crate B, and crate B re-exports a function gated by x, crate A gets it automatically.
Convention aside: keep feature names short and descriptive. serde_support beats enable_serialization_via_serde_crate. Short names reduce typos in Cargo.toml and keep dependency graphs readable.
The compiler does not warn you about unused features. It silently ignores them. If you enable a flag that gates nothing, your binary stays the same size.
Realistic library setup
Real projects rarely gate single functions. They gate entire dependency trees. You want to offer serde serialization, but only for users who actually need it.
[package]
name = "data-processor"
version = "0.1.0"
edition = "2021"
[dependencies]
# Mark dependencies as optional.
# They only compile when a feature explicitly enables them.
serde = { version = "1.0", optional = true }
tokio = { version = "1", optional = true }
[features]
# Group related capabilities under one flag.
# Enabling json_serialization automatically pulls in serde.
json_serialization = ["serde"]
async_runtime = ["tokio"]
default = []
/// Process data synchronously.
pub fn process_sync(input: &[u8]) -> Vec<u8> {
// Always available. No feature gates needed.
input.to_vec()
}
/// Serialize output to JSON.
/// Only compiles when the json_serialization feature is active.
#[cfg(feature = "json_serialization")]
pub fn to_json(data: &[u8]) -> String {
// The serde crate is guaranteed to be linked here.
// The compiler would reject this code if serde were missing.
serde_json::to_string(data).unwrap()
}
/// Run processing asynchronously.
/// Only compiles when the async_runtime feature is active.
#[cfg(feature = "async_runtime")]
pub async fn process_async(input: &[u8]) -> Vec<u8> {
// Tokio is available because the feature enables it.
tokio::task::yield_now().await;
input.to_vec()
}
Downstream users add this to their Cargo.toml:
[dependencies]
data-processor = { version = "0.1.0", features = ["json_serialization"] }
Cargo resolves the graph. It pulls serde into the build. It compiles to_json. It leaves process_async and tokio out of the final binary. The user gets exactly what they asked for.
Convention aside: use --no-default-features in CI or strict environments. It forces explicit feature selection and prevents accidental bloat from upstream defaults.
Test your feature matrix. Run cargo test --features json_serialization, then cargo test --features async_runtime, then cargo test --all-features. If a test assumes a feature is active but you run it without the flag, the test file itself needs #[cfg(feature = "...")] or the test will not compile.
Common pitfalls and compiler errors
The most frequent mistake is confusing Cargo feature flags with nightly compiler features. Cargo uses #[cfg(feature = "...")] and [features] in Cargo.toml. Nightly Rust uses #![feature(...)] at the crate root to enable unstable language or standard library items. They share the word "feature" but operate in completely different layers.
If you try to use #![feature(some_cargo_flag)], the compiler rejects it. Nightly features require the nightly toolchain and explicit opt-in. Cargo features work on stable, beta, and nightly. They control your own code and your dependencies.
Another common trap is forgetting to enable a feature when importing a crate. You add reqwest = "0.11" to your dependencies. You try to use reqwest::Client::builder().use_rustls_tls(). The compiler throws E0599 (no function named use_rustls_tls found for struct ClientBuilder). The method exists, but it is gated behind the rustls-tls feature in reqwest's manifest. You must enable it in your own Cargo.toml:
reqwest = { version = "0.11", features = ["rustls-tls"] }
Missing features also cause E0432 (unresolved import) when you try to use a module that only exists under a specific flag. The fix is always the same: check the upstream crate's Cargo.toml or documentation, find the feature name, and add it to your dependency declaration.
Convention aside: document your features in your crate's README. List each flag, what it enables, and whether it pulls in heavy dependencies. Users will thank you when they are debugging a missing symbol at 2 AM.
Feature flags do not work at runtime. You cannot toggle them after compilation. If you need runtime configuration, use environment variables, config files, or a settings struct. Feature flags are strictly a compile-time contract.
When to use which flag
Use #[cfg(feature = "...")] when you want users to opt into optional functionality or heavy dependencies at compile time. Use #[cfg(target_os = "...")] when you need platform-specific code paths like Windows registry access or Linux epoll bindings. Use #[cfg(debug_assertions)] when you want expensive sanity checks that vanish in release builds. Use #![feature(...)] when you are on the nightly toolchain and need access to unstable compiler or standard library APIs that have not yet graduated to stable. Reach for plain cfg attributes for environment detection; reach for feature flags for capability selection.
Treat the feature matrix as a public API contract. If you change a feature name, you break downstream builds. Version your crate accordingly.