How to Use the cfg! and cfg_attr Macros for Conditional Compilation

Use #[cfg(test)] to include test modules and #[cfg_attr] to conditionally apply attributes like Debug during testing.

When code needs to vanish

You are building a library that calculates physics for a game engine. During development, you add a function that dumps the entire state of the simulation to a file. It saves hours of debugging. When you ship the library to players, that function adds overhead, exposes internal structures, and bloats the binary. You comment it out? You risk forgetting to uncomment it next week. You delete it? You lose the tool you built.

Rust solves this with conditional compilation. You wrap the function in a #[cfg(...)] attribute. The compiler checks the build configuration. If the condition is false, the function is removed from the abstract syntax tree before type checking. It does not exist in the binary. It does not exist in the type system. It is as if you never typed it. This lets you keep development tools, platform-specific hacks, and test helpers in the same source files without paying a runtime or binary size cost.

The filter and the sensor

Conditional compilation in Rust comes in two forms that serve different purposes. The #[cfg(...)] attribute is a filter. It gates items. If the condition fails, the item is deleted. The cfg!(...) macro is a sensor. It evaluates to a boolean constant at compile time. It tells you the state of the configuration, but it does not delete code.

Think of #[cfg] as a bouncer at a club. The club is your compiled binary. The guests are your functions, structs, and modules. The #[cfg] attribute is the ID check. If the ID does not match the guest list, the guest never enters. They do not wait in the lobby. They do not consume space inside. They are gone.

Think of cfg! as a security camera feed. You can look at the feed and decide what to do. You might write if cfg!(test) { run_expensive_check() }. The camera tells you it is test mode. You decide to run the check. The code for run_expensive_check is still in the building. The compiler still has to parse it, type check it, and generate code for it. The optimizer might remove the branch later, but the code is present in the compilation unit.

This distinction matters. Attributes remove code from the compiler's view. Macros provide information to code that the compiler must still process.

Minimal example: attributes delete, macros evaluate

The #[cfg(test)] attribute is the most common entry point. It activates when you run cargo test. The test harness sets the test configuration flag. Any item guarded by #[cfg(test)] appears only in the test build.

// This function is deleted from the binary unless --test is passed.
// The compiler never sees it in a normal build.
#[cfg(test)]
fn debug_dump_state() {
    println!("State dump: this only exists in tests");
}

fn main() {
    // cfg! is a macro. It returns a bool constant.
    // The code inside the if block is still compiled,
    // even if the branch is never taken.
    if cfg!(test) {
        // ERROR: debug_dump_state is not available here in a normal build.
        // The #[cfg(test)] on the function removed it.
        // The cfg!() macro only controls the if condition, not the call.
        // debug_dump_state();
    }
}

The cfg! macro takes the same conditions as the attribute. You can use cfg!(test), cfg!(target_os = "windows"), or cfg!(feature = "serde"). It returns true or false. You can use it in if statements, assert! macros, or anywhere an expression is allowed.

fn check_environment() {
    // cfg! evaluates at compile time.
    // The compiler knows the value and can optimize the branch away.
    if cfg!(target_os = "linux") {
        println!("Running on Linux");
    } else {
        println!("Running on something else");
    }
}

Convention aside: use cfg!(debug_assertions) to guard checks that are expensive but useful during development. The debug_assertions flag is set automatically in debug builds and unset in release builds. This lets you write assert!-style checks that vanish in production without manually managing feature flags.

How the compiler processes cfg

The compiler evaluates cfg conditions against a set of flags. These flags come from three sources.

The toolchain sets target flags. When you compile for a specific architecture or operating system, the compiler knows the target triple. It sets flags like target_os, target_arch, target_pointer_width, and target_endian. You can query these to write platform-specific code.

The build command sets profile flags. cargo build sets flags based on the profile. debug_assertions is true in the dev profile. panic = "abort" might be set in a custom profile. The test flag is set when you run cargo test.

The crate configuration sets feature flags. You define features in Cargo.toml. When a user enables a feature, the compiler sets that flag. You can also set arbitrary flags via RUSTFLAGS or .cargo/config.toml, though this is less common for library authors.

The #[cfg_attr(...)] attribute combines these ideas. It stands for "conditional attribute". It applies an attribute only if a condition is true. If the condition is false, it does nothing. This is useful for adding attributes that are only needed in specific contexts.

// Apply derive(Debug) only when compiling tests.
// In production, the struct has no Debug implementation.
// This saves compile time and binary size if Debug is heavy.
#[cfg_attr(test, derive(Debug))]
struct PhysicsState {
    velocity: f64,
    position: f64,
}

fn main() {
    let state = PhysicsState { velocity: 1.0, position: 0.0 };
    
    // This compiles in tests because Debug is derived.
    // This fails in production because Debug is not derived.
    // println!("{:?}", state);
}

The expansion is straightforward. If test is true, #[cfg_attr(test, derive(Debug))] becomes #[derive(Debug)]. If test is false, it becomes nothing. You can chain cfg_attr or nest conditions, but keep it readable. Complex nesting often signals that the design needs simplification.

Convention aside: use #[cfg_attr(test, derive(Debug))] sparingly. It is a common pattern in crates where Debug is only needed for test assertions. It keeps the production surface clean. Do not use it to hide API differences. If the API changes between test and prod, you are likely fighting the borrow checker or hiding a design flaw.

Realistic example: platform detection and feature gating

Libraries often need to adapt to the environment. A networking crate might use different system calls on Windows versus Unix. A serialization crate might only implement traits when the serde feature is enabled.

// Platform-specific implementation.
// The compiler only type-checks the block that matches the target.
#[cfg(target_os = "windows")]
fn get_env_var(name: &str) -> Option<String> {
    // Windows-specific logic.
    // Types and functions here can be Windows-only.
    None
}

#[cfg(unix)]
fn get_env_var(name: &str) -> Option<String> {
    // Unix-specific logic.
    // You can use std::os::unix types here without errors on Windows.
    None
}

// Feature-gated trait implementation.
// This impl only exists if the user enables the "serde" feature.
#[cfg(feature = "serde")]
impl serde::Serialize for PhysicsState {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_f64(self.velocity)
    }
}

The #[cfg(unix)] shorthand is a convenience. It expands to #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd", ...))]. Rust provides several shorthands: unix, windows, target_pointer_width = "64", and target_endian = "little". Use the shorthands when they express your intent clearly. Use explicit flags when you need precise control.

You can combine conditions with any, all, and not.

// Apply this attribute if targeting 64-bit little-endian systems.
#[cfg(all(target_pointer_width = "64", target_endian = "little"))]
fn optimize_for_x86_64() {}

// Apply this if NOT compiling for tests.
#[cfg(not(test))]
fn production_only_init() {}

// Apply this if either feature is enabled.
#[cfg(any(feature = "json", feature = "yaml"))]
fn load_config() {}

The logic is boolean. all requires every condition to be true. any requires at least one. not inverts the result. You can nest these arbitrarily. The compiler evaluates the expression and keeps or discards the item.

Trust the build flags. They are the contract between your code and the environment. If the flag is set, the code exists. If not, it does not.

Pitfalls: the dead code trap

The most common mistake is confusing cfg! with #[cfg]. Developers write if cfg!(test) { test_helper() } and expect test_helper to be allowed to exist only in tests. It does not work that way.

The cfg! macro controls the if condition. It does not control the body. The compiler still parses and type-checks the body. If test_helper is guarded by #[cfg(test)], it is removed in the release build. The call site remains. The compiler rejects the code with cannot find function 'test_helper' in this scope.

#[cfg(test)]
fn test_helper() {}

fn main() {
    // ERROR: test_helper is not found in release builds.
    // The #[cfg(test)] removed the function.
    // The cfg!() macro only evaluates the condition.
    // The call is still present in the AST.
    if cfg!(test) {
        test_helper();
    }
}

The fix is to guard the call site with #[cfg] as well, or restructure the code. If you need conditional logic, use #[cfg] on the branches.

#[cfg(test)]
fn test_helper() {}

fn main() {
    #[cfg(test)]
    {
        test_helper();
    }
}

Another pitfall is relying on cfg! to hide missing dependencies. If you write if cfg!(feature = "serde") { use serde::Serialize; }, the use statement is still compiled. The compiler checks that serde::Serialize exists. If the serde crate is not in Cargo.toml as a normal dependency, the build fails.

Feature-gated dependencies must be declared in Cargo.toml with optional = true. The cfg flag only gates the code usage. It does not gate the dependency resolution. Cargo resolves all optional dependencies that are enabled. If a feature is not enabled, the dependency is not resolved, and the crate is not available.

Convention aside: use cargo expand to see the result of macro expansion and attribute processing. It shows you exactly what the compiler sees after cfg filtering. If you are unsure why code is missing or present, run cargo expand and inspect the output. It removes guesswork.

Decision: choosing the right tool

Use #[cfg(...)] when you need to remove code entirely from the compilation unit. Use #[cfg(...)] for test modules, platform-specific implementations, feature-gated exports, and development helpers that should not ship.

Use cfg!(...) when you need a boolean value at compile time inside an expression. Use cfg!(...) for conditional logic in if statements, assert! macros, or when you need to pass a constant to a generic parameter.

Use #[cfg_attr(...)] when you want to apply an attribute only under certain conditions. Use #[cfg_attr(...)] for adding #[derive(Debug)] in tests, toggling #[allow(dead_code)] based on features, or applying #[doc(cfg(...))] to control documentation visibility.

Reach for #[cfg(any(...))] when multiple flags should trigger the same code. Reach for #[cfg(all(...))] when every flag must be present. Reach for #[cfg(not(...))] when you need to invert a condition.

Attributes delete. Macros evaluate. Know the difference. If the code shouldn't exist in the binary, use an attribute. If you need to make a decision based on the build, use a macro.

Where to go next