The compiler sees what you tell it to see
You're building a library that wraps a C API. On Linux, the header defines a struct with a pid_t field. On Windows, that field doesn't exist. You write the Rust binding with the field. You try to compile for Windows. The compiler rejects the code because the C type is missing. You don't want to delete the Linux code. You want the compiler to ignore the field when building for Windows, but keep it for Linux.
Rust solves this with conditional compilation. The cfg attribute lets you mark code as dependent on build configuration. The compiler evaluates the condition before it type-checks your code. If the condition is true, the code stays. If it's false, the code vanishes. It's as if you never wrote it.
How cfg works
cfg stands for configuration. It's an attribute that takes a predicate. Predicates are expressions that resolve to true or false based on the build environment. Common predicates include target_os, target_arch, debug_assertions, and feature.
Think of cfg as a stencil over your source code. The compiler sprays paint through the stencil. The holes in the stencil are the items where the predicate is true. Everything else is covered up. The compiler only processes what's visible. This happens at compile time. There is no runtime overhead. The excluded code does not exist in the binary.
This is different from a runtime if statement. A runtime check keeps both branches in the binary and chooses one when the program runs. cfg removes one branch entirely. The binary is smaller and faster because the dead code is gone.
Minimal example
Here's the simplest case. You want a function that behaves differently on Linux versus other operating systems.
/// Prints a message specific to the target OS.
pub fn greet() {
// Include this block only when compiling for Linux.
#[cfg(target_os = "linux")]
{
println!("Hello from Linux");
}
// Include this block for everything else.
#[cfg(not(target_os = "linux"))]
{
println!("Hello from elsewhere");
}
}
The #[cfg(...)] attribute sits directly above the item it controls. You can attach it to functions, structs, modules, enum variants, and even individual fields. The not(...) wrapper negates the predicate.
Convention aside: cfg predicates are case-sensitive. The value for Linux is "linux", not "Linux". The compiler checks these strings exactly. A typo here means the code is excluded when you expected it to be included, and the compiler won't warn you about the missing code. It will only complain later if you try to use something that doesn't exist.
Don't rely on the compiler to catch cfg typos. Double-check your strings.
What happens under the hood
When the compiler parses your source, it encounters #[cfg(target_os = "linux")]. It queries the build configuration. If the target OS is Linux, the attribute is a no-op. The function remains in the abstract syntax tree. If the target OS is Windows, the compiler deletes the function. It's removed before name resolution and type checking.
This has a powerful implication. You can use types and functions that don't exist on the current platform, as long as they're behind a cfg that excludes them. The compiler never sees the invalid code, so it never rejects it.
You can also use cfg on imports. This is common when you need platform-specific modules.
/// Imports the Unix-specific module only on Unix-like systems.
#[cfg(unix)]
use std::os::unix::fs as unix_fs;
/// Imports the Windows-specific module only on Windows.
#[cfg(windows)]
use std::os::windows::fs as windows_fs;
The unix and windows predicates are shorthand for target_family. The target_family predicate groups operating systems. unix matches Linux, macOS, FreeBSD, and others. windows matches all Windows targets. Use target_family when you want to group platforms. Use target_os when you need to distinguish between specific OSes like Linux and macOS.
Trust the predicates. The compiler knows the target. You don't need to guess.
Realistic example: Platform-specific structs
A common use case is defining a struct that interacts with C code. The C struct might have different fields on different platforms. You can use cfg on individual fields to keep the Rust struct aligned with the C definition.
/// Represents a process handle. Fields vary by platform.
pub struct ProcessHandle {
/// The process ID. Available on all platforms.
pub pid: u32,
/// The process group ID. Only valid on Unix systems.
#[cfg(unix)]
pub pgrp: u32,
/// The Windows handle. Only valid on Windows.
#[cfg(windows)]
pub handle: usize,
}
/// Creates a new ProcessHandle.
pub fn new_process(pid: u32) -> ProcessHandle {
#[cfg(unix)]
return ProcessHandle {
pid,
pgrp: 0,
handle: 0, // Unreachable on Unix, but keeps the struct definition uniform in logic.
};
#[cfg(windows)]
return ProcessHandle {
pid,
pgrp: 0, // Unreachable on Windows.
handle: 0,
};
}
This pattern works, but it gets messy quickly. You have to handle the initialization carefully. A better approach is to split the logic into platform-specific modules and re-export a common interface. The cfg attribute keeps the platform details isolated.
Convention aside: The community often uses the cfg_if crate for long chains of conditions. The cfg_if! macro flattens the indentation and makes the code readable. When you have three or more branches, reach for cfg_if. It's a standard dependency in many cross-platform crates.
Keep your cfg blocks focused. Isolate the platform differences. Don't scatter cfg attributes across a large function.
Custom flags and build scripts
The built-in predicates cover most cases. Sometimes you need your own flags. Maybe you want to enable a verbose logging mode only when a specific flag is set. Or you want to detect if a library is available on the system.
You can pass custom flags via RUSTFLAGS.
RUSTFLAGS="--cfg my_verbose_mode" cargo build
This adds my_verbose_mode to the configuration. You can then use #[cfg(my_verbose_mode)] in your code. This is useful for one-off builds or CI pipelines.
For permanent flags, use a build script. A build.rs file runs before compilation. It can detect the environment and emit flags.
// build.rs
fn main() {
// Check if the system has SSL support.
let has_ssl = /* detection logic */;
if has_ssl {
// Emit a cfg flag for the compiler.
println!("cargo:rustc-cfg=has_ssl");
}
}
After the build script runs, you can use #[cfg(has_ssl)] in your crate. The flag is available to all files in the crate.
Convention aside: Build scripts are the standard way to detect environment capabilities. Don't hardcode cfg flags in Cargo.toml unless the flag is static. Use build.rs when the flag depends on the build host or external dependencies. The pkg-config crate is a common helper for detecting C libraries in build.rs.
Treat build.rs as a bridge between the system and your crate. Let the build script do the heavy lifting.
Pitfalls and errors
Conditional compilation introduces subtle bugs. The most common issue is forgetting to apply cfg to the usage of conditional code.
#[cfg(target_os = "linux")]
fn linux_only() {}
fn main() {
// This call is not conditional.
linux_only();
}
If you compile this for Windows, the function linux_only is excluded. The call remains. The compiler rejects the code with E0425 (cannot find value linux_only in this scope). The error points to the call site, not the definition. You have to remember to add #[cfg(target_os = "linux")] to the call as well.
Another trap is mixing cfg with feature flags. Features are user-facing. Cfgs are compiler-facing. Features often map to cfgs.
/// Enables JSON support.
#[cfg(feature = "json")]
pub mod json_support {
// JSON implementation here.
}
Users enable the feature in Cargo.toml. The feature flag becomes a cfg predicate. This is the standard pattern for optional dependencies. If you use #[cfg(feature = "...")], make sure the feature is defined in Cargo.toml. Otherwise, the predicate is always false.
Error codes to watch for:
- E0425: You called a function or used a type that was excluded by
cfg. - E0432: You imported a module conditionally but used it unconditionally.
- E0599: You called a method on a type that was excluded or modified by
cfg.
The compiler doesn't guess. If the code is excluded, it doesn't exist. Write the conditions so the usage matches the definition.
Decision matrix
Use #[cfg(...)] when you need to include or exclude entire items like functions, modules, or struct fields based on the build target.
Use cfg! when you need a boolean value inside an expression, though prefer #[cfg] to remove dead code entirely.
Use cfg_attr when you want to apply an attribute conditionally, such as adding #[no_mangle] only for specific targets.
Use the cfg_if crate when you have a long chain of mutually exclusive conditions; the macro keeps indentation flat and readable.
Use feature flags in Cargo.toml when you want users to opt into optional functionality, rather than hard-coding platform checks.
Use build.rs when you need to detect environment capabilities or set flags based on the build host.
Reach for target_family when grouping platforms like Unix or Windows. Reach for target_os when you need to distinguish between specific OSes.