How to Create and Use Feature Flags in Cargo

Feature flags in Cargo allow you to conditionally compile code based on specific configurations defined in your `Cargo.toml`, enabling you to include optional dependencies, enable debug tools, or target different environments without cluttering your main codebase.

The switch that deletes code

You are building a library. It handles data serialization. Some users want JSON support. Some want MessagePack. Some want both. The JSON crate adds 200ms to compile time. The MessagePack crate adds another 100ms. If you include both by default, you punish every user with longer builds and a larger binary, even if they only use one format.

You need a way to say: "Compile this code only if the user explicitly asks for it."

That is what feature flags do. They are compile-time switches. When a feature is off, the compiler treats the guarded code as if it never existed. The code vanishes. No binary bloat. No unused dependency warnings. No compile-time tax.

Feature flags are not runtime toggles. You cannot flip a feature flag while your program is running. They shape the binary before it ever executes. Think of them like modules on a spacecraft. You weld the solar panels onto the hull during assembly. If the mission doesn't need solar power, the panels stay in the warehouse. The ship launches lighter. You cannot decide to add solar panels once you are in orbit.

How Cargo resolves features

Cargo manages features through the dependency graph. Every crate can define features. Every crate can enable features on its dependencies. When you run cargo build, Cargo resolves the entire graph and unifies feature states.

Feature unification is the key concept. If crate A enables feature X on a dependency, and crate B also enables feature X on that same dependency, feature X is on. There is no way to have conflicting feature states for the same crate version in a single binary. Cargo merges all requests. If anyone asks for a feature, it gets turned on.

This unification prevents duplicate symbols and ensures consistency. It also means features can propagate transitively. You might enable a feature on your direct dependency, and that dependency silently turns on a feature on one of its dependencies. You rarely see this directly, but it affects what code gets compiled.

# Cargo.toml
[package]
name = "my-lib"
version = "0.1.0"

[dependencies]
serde = { version = "1.0", optional = true }

[features]
# The 'dep:' prefix explicitly ties the feature to the optional dependency.
# This is the modern convention. It makes the intent clear.
serde_support = ["dep:serde"]

# Group multiple features under one name.
# Users can enable 'full' to get everything.
full = ["serde_support"]

# Default features are enabled automatically when users depend on this crate.
# Keep defaults lean. Let users opt-in to bloat.
default = []

The dep: prefix is a Cargo convention introduced to clarify the relationship between features and optional dependencies. Before dep:, you had to name the feature the same as the dependency to auto-enable it. That worked, but it was implicit. The dep: syntax makes the dependency activation explicit. Use dep: in new code. It reads better and avoids confusion when a feature name differs from the dependency name.

Minimal example: Guarding code

Feature flags live in Cargo.toml. You activate them in Rust code using the #[cfg] attribute. The attribute checks the feature state at compile time.

// src/lib.rs

/// Logs a message if the logging feature is enabled.
/// Returns an error if logging is disabled, allowing the caller to handle the absence.
#[cfg(feature = "logging")]
pub fn log_message(msg: &str) {
    println!("[LOG] {}", msg);
}

/// Returns a status string based on feature configuration.
pub fn get_status() -> &'static str {
    #[cfg(feature = "logging")]
    {
        return "Logging active";
    }

    #[cfg(not(feature = "logging"))]
    {
        return "Logging disabled";
    }
}

The #[cfg(feature = "logging")] attribute tells the compiler to include the item only when the logging feature is active. If the feature is off, the compiler skips the item entirely. The #[cfg(not(feature = "logging"))] attribute does the opposite. It includes the item only when the feature is absent.

You can wrap functions, structs, modules, and even individual statements. The granularity is fine. You can guard a single line inside a function if needed, though guarding entire modules is cleaner for large blocks.

The code vanishes. No binary bloat. No unused warnings.

Realistic example: A multi-backend library

Real libraries often use features to support multiple backends. A database client might support PostgreSQL, MySQL, and SQLite. Users typically need only one. The library exposes a common trait, and each backend implements that trait behind a feature flag.

# Cargo.toml
[dependencies]
postgres = { version = "0.19", optional = true }
mysql = { version = "24", optional = true }
sqlite = { version = "0.30", optional = true }

[features]
postgres = ["dep:postgres"]
mysql = ["dep:mysql"]
sqlite = ["dep:sqlite"]
default = []
// src/lib.rs

/// Represents a database connection.
/// The concrete type depends on which backend feature is enabled.
pub trait Database {
    fn query(&self, sql: &str) -> Result<Vec<String>, Box<dyn std::error::Error>>;
}

#[cfg(feature = "postgres")]
mod postgres_backend {
    use super::Database;

    /// PostgreSQL implementation of the Database trait.
    pub struct PostgresConnection {
        // Connection details would go here.
    }

    impl Database for PostgresConnection {
        fn query(&self, sql: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
            // Implementation using the postgres crate.
            Ok(vec![])
        }
    }
}

#[cfg(feature = "mysql")]
mod mysql_backend {
    use super::Database;

    /// MySQL implementation of the Database trait.
    pub struct MySqlConnection;

    impl Database for MySqlConnection {
        fn query(&self, sql: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
            // Implementation using the mysql crate.
            Ok(vec![])
        }
    }
}

/// Creates a database connection based on the enabled feature.
/// This function only compiles if exactly one backend feature is enabled.
/// In a real library, you might provide separate constructors per backend.
pub fn connect() -> Box<dyn Database> {
    #[cfg(feature = "postgres")]
    {
        return Box::new(postgres_backend::PostgresConnection);
    }

    #[cfg(feature = "mysql")]
    {
        return Box::new(mysql_backend::MySqlConnection);
    }

    #[cfg(not(any(feature = "postgres", feature = "mysql")))]
    {
        compile_error!("No database backend enabled. Enable 'postgres' or 'mysql'.");
    }
}

The compile_error! macro is a safety net. It ensures the library cannot compile in a broken state where no backend is available. Users must enable at least one feature. This is a common pattern for libraries that require a choice.

Your library becomes a toolkit. Users pick the tools they need.

Pitfalls and compiler errors

Feature flags introduce subtle traps. The most common is assuming a feature is off when a dependency turned it on. Feature unification means you cannot always control the state directly.

If you write code that assumes a feature is off, but a dependency enables it, your code might break. For example, you might have a fallback implementation guarded by #[cfg(not(feature = "serde"))]. If a dependency enables serde, your fallback disappears, and the main code might fail to link if you didn't implement the required traits.

The compiler will catch missing symbols. You'll see E0432 (use of undeclared type) or E0433 (failed to resolve) if you try to use something guarded by a feature that is off. These errors are straightforward. The fix is usually to enable the feature or guard the usage.

// This fails to compile if 'logging' is off.
// Error[E0432]: unresolved import `log`
use log::info;

// Fix: Guard the import.
#[cfg(feature = "logging")]
use log::info;

Another pitfall is testing. Code guarded by a feature does not run in default tests. If you only run cargo test, you might miss bugs in feature-gated code. You must test features explicitly.

# Test with specific features
cargo test --features postgres

# Test all features to ensure nothing breaks
cargo test --all-features

# Test with no default features to check minimal builds
cargo test --no-default-features

Add these commands to your CI pipeline. Run cargo test --all-features and cargo test --no-default-features. This ensures your library works across all configurations. If you don't test a feature, it's just dead code waiting to rot.

Feature unification can also cause surprise when you try to disable a feature that a dependency requires. You cannot disable a feature if another crate in the graph enables it. Cargo will warn you. The resolution is global. You can't have two versions of the same crate with different features in the same binary. This is a strength, not a bug. It keeps the dependency graph consistent.

Run cargo tree before you assume. The graph knows the truth.

Decision matrix: When to use features

Feature flags are powerful, but they are not the right tool for every variable. Choose the mechanism that matches the nature of the variability.

Use feature flags when you have optional dependencies that add compile time or binary size. If a crate is only needed by some users, make it optional and gate it with a feature. This keeps the default build lean.

Use feature flags when you want to expose unstable or nightly-only APIs behind a gate. Mark the feature as unstable or nightly. Users must opt-in explicitly. This protects stable users from breaking changes.

Use feature flags when you need to support multiple backends and users only need one. Database drivers, serialization formats, and logging backends are classic examples. Features let users pick their stack.

Reach for environment variables when the value changes per deployment or per run. Configuration like database URLs, API keys, and log levels should be runtime values. Features cannot change at runtime.

Reach for #[cfg(target_os = "...")] when the code depends on the operating system, not user choice. Platform-specific code should use target configuration attributes. Features are for user choice; target attributes are for compiler detection.

Reach for #[cfg_attr] when you need to conditionally apply attributes, not items. If you want to add #[derive(Serialize)] only when a feature is on, use #[cfg_attr(feature = "serde", derive(Serialize))].

Choose the tool that matches the variability. Compile-time choice gets a feature flag. Runtime choice gets a config value. Platform choice gets a target attribute.

Where to go next