When one binary isn't enough
You are building a data serialization library. Half your users run on desktops and want full JSON and YAML support. The other half deploy to microcontrollers where every kilobyte of flash memory matters. You cannot force the microcontroller users to compile a heavy YAML parser they will never call. You also cannot maintain two separate repositories just to split the codebase. You need one crate that ships different payloads depending on what the consumer actually requests.
The concept: conditional compilation
Feature flags solve this by turning your crate into a modular toolkit. The compiler acts as a selective assembler. Before it translates a single line of Rust into machine code, it scans your Cargo.toml and your source files. It checks which flags the downstream project enabled. It then physically strips out any code wrapped in a disabled flag. The stripped code never reaches the linker. It never occupies RAM. It never adds to your binary size.
Think of it like a restaurant menu with dietary restrictions. The kitchen prepares the base dish. If a customer orders the vegan option, the chef swaps the beef for mushrooms before plating. If they order gluten-free, the chef leaves out the bread. The final plate matches the order exactly. Rust does the same thing at compile time. The mechanism is called conditional compilation. It relies on the #[cfg(...)] attribute and the [features] table in your manifest file.
There is zero performance penalty for disabled features. The code does not exist in the final binary. You are not writing if statements that run at runtime. You are editing the source tree dynamically during the build phase.
Defining features in Cargo.toml
Start by declaring your flags in Cargo.toml. The [features] section maps flag names to lists of dependencies or other flags. Every published crate should define this table, even if it only contains a single default flag.
[package]
name = "my_toolkit"
version = "0.1.0"
edition = "2021"
[features]
# Default features are enabled automatically when a user adds your crate.
# Leaving it empty forces users to opt into every extra capability.
default = []
# This flag enables the heavy analytics module.
analytics = []
The default key controls what happens when a consumer writes my_toolkit = "0.1.0" in their Cargo.toml. If default is empty, they get only the base crate. If you list analytics inside default, everyone gets the analytics code unless they explicitly disable it. The ecosystem strongly prefers empty defaults for utility crates. It keeps downstream build times fast and binary sizes predictable.
Wiring it into your code
Next, wrap the conditional code in your source files. The #[cfg(feature = "analytics")] attribute tells the compiler to include the following item only if the flag is active.
// src/lib.rs
/// Core functionality that always compiles.
pub fn base_operation() -> String {
"Running base logic".to_string()
}
// This function only exists when the consumer enables the analytics feature.
#[cfg(feature = "analytics")]
pub fn track_usage() {
println!("Sending metrics to dashboard");
}
When a downstream project runs cargo build, Cargo reads the dependency declaration. If they wrote my_toolkit = "0.1.0", the analytics flag stays off. The compiler sees the #[cfg] attribute, recognizes the flag is disabled, and deletes track_usage from the compilation unit. The resulting binary contains only base_operation.
If they run cargo build --features analytics, Cargo flips the flag. The compiler keeps track_usage. It compiles the function, links it, and ships it. The entire process happens before optimization or code generation.
Treat the [features] table as your crate's public API contract. If it is not in the table, it does not exist.
How Cargo resolves the dependency graph
Real crates rarely use empty feature lists. Features usually unlock optional dependencies or enable other internal flags. The ecosystem follows a strict naming convention to keep dependency graphs readable.
[features]
default = ["std"]
# The std feature enables the standard library dependency.
std = ["dep:std_compat"]
# The async feature pulls in a runtime and enables std automatically.
async = ["std", "dep:tokio"]
[dependencies]
# Mark dependencies as optional so they only compile when a feature requests them.
std_compat = { version = "1.0", optional = true }
tokio = { version = "1", features = ["full"], optional = true }
Notice the dep: prefix in the feature list. This is a modern Cargo convention. When you write std = ["dep:std_compat"], you are explicitly telling Cargo that the std feature enables the std_compat dependency. Older crates used std = ["std_compat"], which worked but created ambiguity when a dependency name matched a feature name. The dep: prefix removes that guesswork. Use it in every new crate.
Here is how the source code wires these dependencies together:
// src/lib.rs
/// Provides standard library compatibility helpers.
#[cfg(feature = "std")]
pub mod std_helpers {
// Only compile this module when the std feature is active.
#[cfg(feature = "std")]
use std_compat::format_path;
pub fn get_current_dir() -> String {
// The dependency is guaranteed to exist because of the cfg guard.
format_path("/current")
}
}
/// Async runtime integration.
#[cfg(feature = "async")]
pub mod async_runtime {
// Tokio is only available when the async feature is enabled.
#[cfg(feature = "async")]
use tokio::task;
pub async fn spawn_worker() {
task::spawn(async {
println!("Worker started");
});
}
}
The compiler enforces the dependency graph. If you try to call std_helpers::get_current_dir() without enabling the std feature, the module itself disappears. The compiler rejects the call with an unresolved import error. This is intentional. Feature flags are a compile-time contract, not a runtime toggle.
Follow the community standard: keep your default feature minimal. Ship only the core functionality by default. Force users to opt into heavy dependencies like serde, tokio, or openssl. This keeps downstream binary sizes predictable and build times fast.
Testing your feature combinations
Feature flags multiply your test surface. If you have three independent flags, you potentially have eight different compilation configurations. Running cargo test only tests the default configuration. You must explicitly test the others.
Use cargo test --features analytics to verify the analytics path. Use cargo test --all-features to compile every combination at once. Add this to your CI pipeline. If a feature breaks compilation, the build fails immediately. You do not want to discover a missing import after a user reports it.
You can also use cfg_attr to conditionally apply attributes like #[derive(Serialize)] or #[doc(hidden)]. This keeps your public API clean when a feature is disabled.
// src/lib.rs
/// A configuration struct that gains serialization when the feature is active.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppConfig {
pub timeout: u64,
pub retries: u32,
}
The cfg_attr macro evaluates the feature flag and injects the derive macro only when the flag is true. The struct compiles either way, but the serialization methods appear only when requested. This pattern is standard in crates like serde and tokio. Adopt it whenever a trait implementation depends on an optional dependency.
Pitfalls and compiler friction
Feature flags introduce friction when the source code and the manifest file fall out of sync. The most common mistake is wrapping code in #[cfg(feature = "new_flag")] without declaring new_flag in Cargo.toml. Cargo will warn you about an unused feature, but the compiler will silently strip the code. Your tests might pass, but the functionality vanishes in production.
Another trap is conditional exports. If you conditionally compile a function but forget to conditionally compile the pub use statement that re-exports it, downstream code breaks. The compiler throws E0432 (unresolved import) when a consumer tries to use a symbol that was stripped during compilation. The fix is to apply the exact same #[cfg] attribute to the export line.
// src/lib.rs
#[cfg(feature = "analytics")]
mod analytics_impl {
pub fn track() {}
}
// The re-export must carry the same feature guard.
#[cfg(feature = "analytics")]
pub use analytics_impl::track;
You will also run into feature resolution conflicts when two dependencies request incompatible flags for the same third-party crate. Cargo merges feature requests by default. If crate A enables feature-x and crate B enables feature-y, both compile. If they both try to enable mutually exclusive flags, Cargo fails with a resolution error. The solution is to audit your dependency tree and isolate conflicting features behind separate flags in your own crate.
Run cargo tree --features your_flag to visualize exactly what gets pulled in. Trust the output. If the tree looks wrong, your Cargo.toml is lying.
Choosing your feature strategy
Use a feature flag when you want to ship optional functionality inside a single crate and keep the dependency graph flat. Use a feature flag when you need to toggle heavy dependencies like serialization libraries or async runtimes without forcing them on every consumer. Use a separate crate when the optional codebase exceeds fifty percent of your total lines and has a completely different maintenance lifecycle. Use a build script when you need to detect host system capabilities like CPU instruction sets or OS-specific headers at compile time. Use runtime configuration when the choice depends on user input, environment variables, or network conditions that only exist after the binary starts.