How to create conditional compilation in Rust

Use #[cfg(...)] attributes to include or exclude Rust code based on target OS, debug mode, or custom build flags.

When the platform dictates the code

You are building a command-line tool that needs to read the system clipboard. On macOS, the command is pbpaste. On Linux, it is usually xclip or wl-copy. On Windows, it is clip. You write the macOS implementation. You try to compile for Linux. The compiler rejects the build because the macOS-specific function calls do not exist in the Linux environment, and the dependencies you pulled in are irrelevant there.

You need a way to tell the compiler, "Include this code only if we are building for macOS. Otherwise, pretend it was never written."

Rust solves this with conditional compilation. The #[cfg(...)] attribute lets you gate code behind predicates that the compiler evaluates at build time. If the condition is false, the code is removed before type checking. It does not just skip execution; it vanishes from the compilation unit entirely.

The blueprint analogy

Think of your source code as a set of architectural blueprints. Conditional compilation is like a master builder who only hands the construction crew the pages relevant to the current site.

If the site is in a hurricane zone, the builder removes the "Glass Atrium" pages before the crew arrives. The crew never sees those pages. They do not try to build the atrium and fail because the materials are missing. They simply build the reinforced structure defined in the remaining pages.

In Rust, the compiler is the builder. The #[cfg] attribute marks pages of the blueprint. When the condition is false, those pages are shredded before the type checker even looks at them. This means you can reference types and functions that only exist on specific platforms without causing errors on others. The compiler never tries to verify the missing code because it is not there.

Minimal example

The most common predicate is target_os. It checks the operating system of the compilation target.

#[cfg(target_os = "linux")]
fn get_pid() -> u32 {
    // Linux provides getpid in libc.
    // This block is included only for Linux targets.
    unsafe { libc::getpid() as u32 }
}

#[cfg(target_os = "windows")]
fn get_pid() -> u32 {
    // Windows uses a different API.
    // This block is included only for Windows targets.
    // Simplified stub for demonstration.
    1234
}

fn main() {
    // The compiler includes exactly one implementation of get_pid.
    // The other is removed before type checking.
    println!("PID: {}", get_pid());
}

The #[cfg(...)] attribute sits directly above the item it controls. It can wrap functions, structs, modules, or even individual statements inside a function. The predicate target_os = "linux" evaluates to true only when the target triple ends with -linux.

What happens under the hood

When the compiler encounters #[cfg(predicate)], it evaluates the predicate immediately.

If the predicate is true, the attribute is stripped and the code is processed normally. If the predicate is false, the code is discarded. This happens during the early parsing phase, before name resolution and type checking.

This has two consequences. First, you can use types that are only available on the active platform. If you import a Linux-only crate inside a #[cfg(target_os = "linux")] block, the compiler does not complain when building for Windows because the import is gone. Second, there is zero runtime cost. Conditional compilation is not an if statement. An if statement compiles both branches and inserts a branch instruction in the binary. #[cfg] removes the code entirely. The binary contains only the active path.

If you call a function that was gated behind a cfg and the condition is false, the compiler rejects the call site. You get E0425 (cannot find function in this scope). The compiler treats the missing function as if it never existed. You must also gate the call site, or provide a fallback implementation.

Real-world patterns

Feature flags

Libraries use cfg extensively with feature flags. Features allow users to opt into optional functionality. You define features in Cargo.toml and gate code with #[cfg(feature = "name")].

[features]
default = []
json = ["serde"]
/// A configuration struct that can optionally serialize to JSON.
pub struct Config {
    pub verbose: bool,
}

// Only derive Serialize if the json feature is enabled.
// This avoids pulling in serde when the user does not need it.
#[cfg(feature = "json")]
impl serde::Serialize for Config {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_bool(self.verbose)
    }
}

fn main() {
    let config = Config { verbose: true };

    // This call only compiles if the json feature is enabled.
    #[cfg(feature = "json")]
    let json = serde_json::to_string(&config).unwrap();

    #[cfg(not(feature = "json"))]
    println!("JSON support is disabled.");
}

Convention aside: The community expects feature flags to be additive. Enabling a feature should never break existing code or change behavior in a way that causes errors. Features should only add capabilities. If you need to toggle behavior, use a runtime flag or a different crate.

Conditional attributes

Sometimes you need to apply an attribute only under certain conditions. The cfg_attr attribute handles this. It takes a predicate and an attribute. If the predicate is true, the attribute is applied. If false, nothing happens.

// Apply #[derive(Debug)] only in debug builds.
// This keeps the release binary smaller by omitting debug formatting logic.
#[cfg_attr(debug_assertions, derive(Debug))]
struct InternalState {
    buffer: Vec<u8>,
}

cfg_attr is cleaner than wrapping the entire item in a cfg block when you only need to toggle a single attribute. It keeps the definition visible and avoids duplication.

The cfg-if crate

Long chains of cfg blocks can become hard to read. The cfg-if crate provides a macro that flattens the structure. It is a community standard for complex platform detection.

use cfg_if::cfg_if;

cfg_if! {
    if #[cfg(target_os = "linux")] {
        fn platform_name() -> &'static str {
            "Linux"
        }
    } else if #[cfg(target_os = "windows")] {
        fn platform_name() -> &'static str {
            "Windows"
        }
    } else {
        fn platform_name() -> &'static str {
            "Unknown"
        }
    }
}

The macro expands to nested cfg blocks, but the syntax reads like a normal if/else chain. Use this when you have three or more branches. It reduces indentation and makes the fallback case explicit.

Pitfalls and errors

The debug trap

A common mistake is using #[cfg(debug)] to gate debug code. There is no standard debug flag. The correct predicate is debug_assertions.

// WRONG: This flag does not exist by default.
// The code is always excluded unless you manually pass --cfg debug.
#[cfg(debug)]
fn check_invariants() { ... }

// CORRECT: This is true in debug builds, false in release.
#[cfg(debug_assertions)]
fn check_invariants() { ... }

The debug_assertions flag is set automatically by Cargo when building in debug mode. It is cleared in release mode. Using debug breaks the convention and confuses other developers who expect debug_assertions. Always use debug_assertions for development-only checks.

Missing call site gates

If you gate a function definition but forget to gate the call, the compiler errors.

#[cfg(target_os = "linux")]
fn linux_only() {
    println!("Linux!");
}

fn main() {
    // ERROR: E0425 cannot find function `linux_only` in this scope.
    // The function was removed for non-Linux targets, but the call remains.
    linux_only();
}

You must gate the call site with the same predicate, or provide a fallback.

fn main() {
    #[cfg(target_os = "linux")]
    linux_only();

    #[cfg(not(target_os = "linux"))]
    println!("Not on Linux.");
}

Custom flags

You can define custom flags using RUSTFLAGS or --cfg. This is useful for CI scripts or testing configurations.

RUSTFLAGS="--cfg my_custom_flag" cargo build
#[cfg(my_custom_flag)]
fn custom_behavior() {
    println!("Flag is set!");
}

Convention aside: Keep custom flags documented in README.md or CONTRIBUTING.md. If a flag is required to build the project, it should be part of the standard build process, not a hidden environment variable.

Decision matrix

Use #[cfg(...)] when you need to exclude code entirely based on the build environment, such as target OS, architecture, or feature flags. The code disappears before type checking, so you avoid dependencies and errors for unsupported platforms.

Use if statements when the condition changes at runtime, like user input, network state, or configuration files. Conditional compilation cannot handle runtime values.

Use cfg_attr when you want to apply an attribute conditionally, such as adding #[derive(Serialize)] only when a feature is enabled. This keeps your derive macros clean and avoids unused attribute warnings.

Use RUSTFLAGS or --cfg when you need to pass custom flags from the command line for testing or CI scripts. This lets you toggle behavior without changing source code.

Use debug_assertions when you want checks that run in development but vanish in release builds. This is the standard way to gate expensive validation logic.

Use cfg_if! from the cfg-if crate when you have a long chain of platform checks. It flattens the syntax and makes the fallback case explicit.

Treat cfg as a contract with the build system. If the code does not compile on the target, the cfg is wrong. Trust the predicates. They are the only thing standing between your code and the platform reality.

Where to go next