Feature flags are blueprints, not switches
You are building a CLI tool. You want to offer a "pro" mode that unlocks advanced analytics. You add a feature flag, write some code guarded by that flag, and run the binary. The pro features are missing. You flip the flag, rebuild, and run again. Now they are there. You realize the flag did not toggle behavior inside the running program. It changed the program itself.
Rust's feature flags are compile-time instructions. They tell the compiler which parts of your code to include in the final binary. They are not runtime toggles. You cannot flip a feature flag while the application is running. You must rebuild the binary to change features. This distinction trips up developers coming from languages where feature flags often mean runtime configuration checks. In Rust, a feature flag is a decision made at the factory, not a button on the dashboard.
The factory analogy
Imagine a car factory. The assembly line builds cars based on a blueprint. If the blueprint says "include sunroof", the robot welds a sunroof into the chassis. If the blueprint says "no sunroof", the robot skips that step. The car rolls off the line with or without the sunroof. You cannot drive the car to a service station and ask them to install the sunroof by flipping a switch in the glove box. The sunroof is either in the metal or it isn't.
Rust feature flags work the same way. When you compile with cargo build --features pro, the compiler reads the blueprint, sees the pro feature is active, and includes the code marked for pro. When you compile without it, that code is stripped out. It does not exist in the binary. There is no hidden if statement checking a flag at runtime. There is no overhead. The binary is smaller and faster because the unused code is gone.
This approach gives you zero-cost abstractions. You only pay for what you use. If your library supports both JSON and XML parsing, but the user only needs JSON, the XML parser never gets compiled. The user's binary stays small, and compile times stay fast.
Minimal example: toggling code
Feature flags live in two places. You define them in Cargo.toml, and you use them in your source code with #[cfg] attributes.
Start by defining the feature in your manifest. The [features] section lists available flags. You can also define a default list that activates automatically when users build your crate without specifying features.
# Cargo.toml
[features]
# Default features activate when the user runs `cargo build` without flags.
default = []
# 'pro' is an optional feature. Users must enable it explicitly.
pro = []
In your code, guard blocks with #[cfg(feature = "name")]. The compiler checks if the feature is active. If it is, the block compiles. If not, the block disappears.
// src/main.rs
fn main() {
// This line only exists in the binary if 'pro' is enabled.
#[cfg(feature = "pro")]
println!("Pro mode: Analytics enabled.");
// This line only exists if 'pro' is NOT enabled.
#[cfg(not(feature = "pro"))]
println!("Free mode: Analytics disabled.");
}
Run the binary with cargo run. You see the free mode message. Run it with cargo run --features pro. You see the pro mode message. The compiler generated two different binaries. The first binary has no trace of the pro message. The second binary has no trace of the free message.
Feature flags change the binary. They do not change the running program.
Realistic example: optional dependencies
The most common use case for feature flags is optional dependencies. You are writing a library that can serialize data. You want to support JSON and YAML, but you do not want to force users to compile both parsers. You define features that enable specific dependencies.
# Cargo.toml
[package]
name = "my-serializer"
version = "0.1.0"
[features]
# Users get nothing by default. They must opt-in.
default = []
# Enabling 'json' pulls in serde_json.
json = ["dep:serde_json"]
# Enabling 'yaml' pulls in serde_yaml.
yaml = ["dep:serde_yaml"]
[dependencies]
# Mark dependencies as optional.
serde_json = { version = "1.0", optional = true }
serde_yaml = { version = "0.9", optional = true }
In your library code, expose functions only when the relevant feature is active. This keeps the public API clean. Users who enable json see the JSON functions. Users who enable yaml see the YAML functions. Users who enable both see everything.
// src/lib.rs
/// Serialize a value to a JSON string.
/// Only available when the 'json' feature is enabled.
#[cfg(feature = "json")]
pub fn to_json(value: &str) -> String {
// serde_json is guaranteed to be available here.
serde_json::to_string(value).unwrap()
}
/// Serialize a value to a YAML string.
/// Only available when the 'yaml' feature is enabled.
#[cfg(feature = "yaml")]
pub fn to_yaml(value: &str) -> String {
// serde_yaml is guaranteed to be available here.
serde_yaml::to_string(value).unwrap()
}
Users of your crate enable features in their Cargo.toml. They can pick exactly what they need.
# user/Cargo.toml
[dependencies]
my-serializer = { version = "0.1", features = ["json"] }
When the user builds, serde_yaml is never downloaded or compiled. The dependency tree stays minimal. This is the power of feature flags. They let you build flexible libraries without penalizing users for unused functionality.
Convention aside: most libraries set default = [] or default = ["std"]. This forces users to make conscious choices about what they enable. Applications often set default = ["all"] so developers get the full experience out of the box. Follow the pattern of the crates you depend on.
Compile-time checks with cfg!
Sometimes you need to branch logic at runtime based on a compile-time feature. You cannot use #[cfg] for this because it removes code entirely. Instead, use the cfg! macro. It evaluates to a boolean constant. The compiler knows the value at compile time, so it can optimize away the dead branch, but the code structure remains.
// src/lib.rs
/// Returns the current mode as a string.
pub fn get_mode() -> &'static str {
// cfg! evaluates to true or false at compile time.
// The optimizer usually removes the dead branch.
if cfg!(feature = "pro") {
"pro"
} else {
"free"
}
}
Use cfg! when you want to keep a function signature stable or share logic across variants. Use #[cfg] when you want to exclude code entirely. The cfg! macro is useful for logging or debug assertions that depend on features.
// src/lib.rs
fn process_data(data: &[u8]) {
// This log line exists in the binary, but the condition is constant.
// The optimizer may remove the call entirely if the feature is off.
if cfg!(feature = "verbose") {
println!("Processing {} bytes", data.len());
}
// Actual processing logic...
}
The cfg! macro gives you a runtime check with compile-time guarantees. The branch is cheap because the condition never changes.
Nightly feature gates
Rust has a second kind of feature flag for unstable language features. These are not defined in Cargo.toml. They are enabled with #![feature(...)] attributes at the crate root. These flags unlock experimental functionality that is not yet stable.
// src/main.rs
// Enables the trait_alias feature, which is unstable.
#![feature(trait_alias)]
// Define a trait alias.
trait ReadWrite = std::io::Read + std::io::Write;
fn main() {
println!("Using unstable feature.");
}
Nightly feature gates require the nightly toolchain. You cannot compile this code with stable Rust. You must switch toolchains using rustup.
rustup run nightly cargo build
Convention aside: never use nightly features in a library published to crates.io unless you have a compelling reason and a way to gate them. Nightly features can change or be removed at any time. They break stable users. If you must use them, wrap them behind a feature flag and document the requirement clearly.
Nightly features are a contract with the compiler that you accept breakage. Treat them as experimental tools, not production dependencies.
Pitfalls and compiler errors
Feature flags introduce specific failure modes. Understanding these saves debugging time.
Missing imports. If you import a type inside a #[cfg] block but use it outside, the compiler rejects the code when the feature is disabled. You get E0432 (use of undeclared type or module). The fix is to guard the usage, not just the import.
// BAD: Import is guarded, usage is not.
#[cfg(feature = "json")]
use serde_json;
fn parse() {
// E0432: cannot find type 'Value' in module 'serde_json'
// when 'json' is disabled.
let _ = serde_json::Value::Null;
}
// GOOD: Guard the usage.
#[cfg(feature = "json")]
fn parse() {
use serde_json;
let _ = serde_json::Value::Null;
}
Transitive features. If your crate depends on serde, and serde has a derive feature, you need to enable it in your Cargo.toml. If your users need derive, you must re-export the feature. Otherwise, they enable derive in their code, but your crate does not pass it to serde.
# Cargo.toml
[features]
default = []
derive = ["serde/derive"]
[dependencies]
serde = "1.0"
This tells Cargo: when the user enables derive on your crate, also enable derive on serde. Without this, the user's #[derive(Serialize)] fails because serde was compiled without the derive macro.
Runtime confusion. The biggest pitfall is expecting features to toggle at runtime. You cannot check a feature flag in a running binary and expect it to change based on a config file. The flag is baked into the binary. If you need runtime toggles, use environment variables or a configuration file.
If you need a runtime toggle, reach for a config file. The compiler cannot help you there.
Decision: when to use what
Rust offers multiple ways to control behavior. Choose the right tool for the job.
Use #[cfg(feature = "...")] when you need to include or exclude code at compile time based on user choice. This is the standard way to build flexible libraries and applications with optional components.
Use cfg!(feature = "...") when you need to branch logic at runtime based on a compile-time feature. This keeps code structure stable while allowing the optimizer to remove dead branches.
Use environment variables or configuration files when you need to toggle behavior at runtime without rebuilding. This is the correct approach for runtime feature toggles, A/B testing, or user preferences.
Use #![feature(...)] when you are experimenting with nightly-only language features and accept the risk of breakage. This is for language developers and early adopters, not for production libraries.
Use cfg_attr when you need to apply attributes conditionally. This is useful for adding derives or lint attributes only when a feature is active.
Feature flags are blueprints. Use them to shape the binary, not to steer the running program.