What is the cfg attribute

The #[cfg(test)] attribute includes code only during test execution, excluding it from production builds.

The compiler's on-off switch

You are building a library that calculates cryptographic hashes. During development, you want to print every intermediate step to the terminal. In production, that logging would leak sensitive data and tank performance. You also need to call different system APIs depending on whether the user runs Linux or macOS. Writing a runtime if statement to check the operating system wastes CPU cycles on every function call. Rust solves this with the cfg attribute, a compile-time switch that removes entire blocks of code before the binary is ever built.

How conditional compilation works

The cfg attribute stands for configuration. It tells the compiler to evaluate a condition at build time. If the condition is true, the compiler keeps the code. If it is false, the compiler erases it as if you never typed it. There is no runtime overhead. No dead branches. No hidden performance tax.

Think of it like a film editor cutting scenes before the final print. The script contains multiple endings, but only one gets rendered for the audience. The other endings stay in the binder. Rust does the same thing with your source files. The compiler reads the attributes, checks the current build environment, and strips out anything that does not match. What remains is a lean binary tailored to exactly what you asked for.

The compiler evaluates cfg conditions before type checking and before name resolution. This means gated code never touches the rest of your program. It does not pollute the namespace. It does not trigger trait resolution. It does not increase link times. The attribute system is a first-class citizen in the compiler pipeline, not a text replacement hack like the C preprocessor.

The minimal example

The most common entry point is #[cfg(test)]. It isolates test modules so they never ship to users.

/// Adds two integers and returns the sum.
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Only compiled when `cargo test` runs.
// Keeps test helpers and assertions out of the release binary.
#[cfg(test)]
mod tests {
    // Brings the parent module's public items into scope.
    use super::*;

    #[test]
    fn addition_works() {
        let result = add(2, 2);
        // Fails the test if the values do not match.
        assert_eq!(result, 4);
    }
}

When you run cargo build, the compiler sees #[cfg(test)], evaluates the condition as false, and skips the entire mod tests block. The resulting binary contains only add. When you run cargo test, the compiler flips the condition to true, compiles the module, and links it into the test harness. The same pattern applies to #[cfg(debug_assertions)], which is true during cargo build but false during cargo build --release.

Trust the attribute. If it is not in the build profile, it is not in the binary.

Real-world patterns

Real projects rarely rely on just test flags. Feature flags are where cfg becomes essential. They let you ship optional functionality without forcing every user to compile code they will never use.

First, define the features in Cargo.toml:

[features]
default = []
serde_support = ["dep:serde"]

Then, gate the code in lib.rs:

// Only pull in serde when the feature is explicitly enabled.
// Prevents unnecessary compilation time for users who do not need serialization.
#[cfg(feature = "serde_support")]
use serde::{Serialize, Deserialize};

/// A simple configuration struct.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct AppConfig {
    pub timeout: u64,
}

The cfg_attr attribute applies another attribute conditionally. Here, it adds derive(Serialize, Deserialize) only when the feature is active. This keeps the serde dependency completely out of the dependency graph unless someone runs cargo build --features serde_support.

Convention note: always use dep: in Cargo.toml when gating optional dependencies. It tells Cargo to only download and compile the crate when the feature is enabled. Without it, the crate compiles anyway, and you defeat the purpose of the feature flag.

Feature flags turn a monolithic library into a modular toolkit. Ship only what the user actually needs.

The cfg! macro for compile-time constants

Attributes work on items. Sometimes you need a compile-time check inside a function body. That is what the cfg! macro exists for. It evaluates to a boolean at compile time, which the compiler then uses to optimize away dead branches.

/// Returns the default timeout based on the build profile.
pub fn default_timeout() -> u64 {
    // Evaluates at compile time. The compiler removes the unused branch.
    if cfg!(debug_assertions) {
        500
    } else {
        5000
    }
}

The compiler treats cfg! like a constant. It inlines the boolean, runs constant propagation, and drops the dead branch during optimization. You get the same zero-cost abstraction as #[cfg], but with standard control flow syntax. Use it when you need to branch on build flags without duplicating function bodies.

Pitfalls and compiler feedback

Conditional compilation hides code. That means mistakes also hide until you try to compile a different profile. The most common trap is forgetting to gate a dependency or a type definition.

If you reference a type that only exists behind a feature flag, the compiler rejects you with E0425 (cannot find value in scope). The error message points to the line where you used the missing type, not the line where you forgot the #[cfg] block. This happens because the compiler processes attributes top-down and removes gated items before name resolution finishes.

Another trap is assuming cfg works like a runtime if. It does not. You cannot use variables inside #[cfg(...)]. The conditions must be known at compile time. If you try to check a runtime value, the compiler throws a hard error. You need a standard if statement for runtime decisions.

Debugging configuration flags used to require guessing. Now you can print the exact flags active in your current build:

cargo rustc -- -Z unstable-options --print cfg

This command outputs a list of every cfg flag the compiler currently recognizes for your target. You will see debug_assertions, target_os, target_arch, and any custom flags you passed via RUSTFLAGS. Use it to verify why a block is being included or excluded.

Convention note: CI pipelines often set RUSTFLAGS="--cfg ci_build" to enable integration tests or skip flaky checks. Keep those flags documented in your CONTRIBUTING.md. New contributors will spend hours guessing why their local build passes but the pipeline fails.

Treat the configuration list as your source of truth. Guessing flags leads to broken builds.

When to use what

Use #[cfg(test)] when you need to isolate test modules and keep testing utilities out of production binaries. Use #[cfg(feature = "...")] when you want to offer optional functionality or dependencies without bloating the base crate. Use #[cfg(target_os = "...")] or #[cfg(target_arch = "...")] when you must call platform-specific system APIs or optimize for a specific CPU instruction set. Use #[cfg_attr(...)] when you need to conditionally apply derives, doc links, or lint attributes based on another flag. Use cfg! when you need a compile-time boolean inside a function body. Use a runtime if statement when the decision depends on user input, file contents, or network responses.

Pick the compile-time switch for build-time decisions. Leave runtime checks for runtime data.

Where to go next