When one code path isn't enough
You are building a command-line tool that needs to read the current working directory. On Linux, you call getcwd. On Windows, you call GetCurrentDirectoryW. On macOS, you use a different libc wrapper. You do not want to ship three separate binaries. You want one crate that compiles the correct system call for whoever runs cargo build. The compiler needs a switch to flip before it translates your code.
That switch is the cfg attribute. It stands for configuration. It tells the compiler to include or exclude code based on the target platform, architecture, or build flags. The decision happens before optimization, before linking, and before your binary ever runs.
How conditional compilation works
Rust compiles in phases. It parses your source into an abstract syntax tree, checks types, and then generates machine code. The cfg attribute operates during the early parsing phase. The compiler evaluates the condition inside the parentheses. If the condition matches your build environment, the code stays in the tree. If it does not match, the compiler removes it entirely. The excluded code never sees type checking, never generates warnings, and never occupies space in your final binary.
Think of it like a movie editor cutting footage. The director shoots scenes for both a theatrical release and a director's cut. The editor checks the release format. If it is theatrical, the extended scenes get spliced out. The final reel contains only the approved footage. The audience never sees the cut scenes, and the projectionist never has to load them.
Minimal example
The syntax is straightforward. Place #[cfg(...)] directly above the item you want to gate. The condition uses a simple key-value or boolean check.
/// Returns the platform-specific home directory path.
#[cfg(target_os = "linux")]
fn get_home_dir() -> String {
// Linux stores the home path in the HOME environment variable.
// We read it directly from the OS environment.
std::env::var("HOME").unwrap_or_else(|_| "/home".to_string())
}
/// Returns the platform-specific home directory path.
#[cfg(target_os = "windows")]
fn get_home_dir() -> String {
// Windows uses the USERPROFILE variable instead.
// The fallback points to the default Windows user directory.
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users".to_string())
}
/// Fallback for unsupported platforms.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn get_home_dir() -> String {
// Unrecognized targets get a generic placeholder.
// This prevents compilation errors on niche architectures.
"unknown".to_string()
}
Each function shares the same signature. The compiler only keeps the one that matches your target_os. The others vanish from the compilation unit. Keep your gated items small. Large conditional blocks become impossible to review and test.
What happens under the hood
When you run cargo build, Cargo passes a set of configuration flags to rustc. These flags describe your target triple: x86_64-unknown-linux-gnu, aarch64-pc-windows-msvc, and so on. The compiler expands these triples into a list of active cfg keys. target_os, target_arch, target_family, and debug_assertions are always available. You can also define custom keys using the --cfg flag in your Cargo.toml or .cargo/config.toml.
The compiler walks your source files and evaluates every #[cfg(...)] attribute. It uses a simple boolean logic system. You can chain conditions with and, or, and not. You can also use any() for cleaner grouping. The evaluation is strict. If the condition resolves to false, the item is stripped. If you try to call a stripped function, the compiler rejects you with E0425 (cannot find value in this scope). The error happens at compile time, not at runtime.
This design gives you zero-cost abstractions. You pay nothing for code paths you never take. The binary contains only what you actually need. You also get early feedback. If you forget to implement a function for a specific platform, the build fails immediately. You do not have to wait for a user to report a panic.
Rust also provides target_family for broader grouping. target_family = "unix" matches Linux, macOS, FreeBSD, and other POSIX systems. Use it when your code relies on POSIX semantics rather than OS-specific quirks. The community convention is to prefer target_family for broad abstractions and target_os for exact system calls. It keeps your code portable without sacrificing precision.
Realistic example: cross-platform logging
Real projects rarely gate entire functions. They usually gate imports, struct fields, or attribute modifiers. Consider a logging crate that needs to attach process metadata to every log line. Linux provides /proc/self/stat. Windows requires a GetProcessTimes call. You want a single Logger struct that adapts to the environment.
/// Configuration for the cross-platform logger.
pub struct LoggerConfig {
/// Base log level threshold.
pub level: u8,
/// Linux-specific process start time from /proc.
#[cfg(target_os = "linux")]
pub proc_start_time: u64,
/// Windows-specific process handle for time queries.
#[cfg(target_os = "windows")]
pub process_handle: u64,
}
/// Initializes the logger with platform-specific metadata.
pub fn init_logger(config: LoggerConfig) {
// Attach the correct metadata based on the active OS.
// The compiler only compiles the matching branch.
#[cfg(target_os = "linux")]
println!("Linux logger initialized. Start time: {}", config.proc_start_time);
#[cfg(target_os = "windows")]
println!("Windows logger initialized. Handle: {}", config.process_handle);
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
println!("Generic logger initialized. No platform metadata available.");
}
The struct definition itself is conditional. On Linux, LoggerConfig contains proc_start_time. On Windows, it contains process_handle. The memory layout changes per platform, but the public API stays consistent. You avoid Option<T> wrappers and runtime type checks. The compiler bakes the correct layout into the binary.
You will often need to apply attributes conditionally. The #[cfg_attr(...)] attribute handles this cleanly. It takes a condition and an attribute. If the condition is true, it applies the attribute. If false, it does nothing.
/// Links to the Windows multimedia library only on Windows.
#[cfg_attr(target_os = "windows", link(name = "winmm"))]
extern "system" {
// Declares the platform-specific time function.
// The linker only resolves this on Windows targets.
fn timeGetTime() -> u32;
}
This pattern keeps your extern blocks clean. You do not need duplicate blocks or messy macro workarounds. The compiler handles the conditional linking automatically.
Pitfalls and compiler errors
Conditional compilation introduces a specific set of traps. The most common mistake is treating cfg like a runtime if statement. You cannot use #[cfg] to gate variables inside a function body. Attributes only work on items: modules, functions, structs, enums, impl blocks, and individual statements. If you try to attach it to a local variable, the compiler rejects you with a syntax error.
Another trap is forgetting to handle all branches. If you gate a function for Linux and Windows but leave macOS out, any code that calls that function will fail to compile on macOS. The compiler will emit E0425 or E0432 (unresolved import) depending on how you structured the module. Always provide a fallback branch using #[cfg(not(...))] to catch unsupported targets.
You will also run into dead code warnings. If you define a helper function inside a #[cfg(target_os = "linux")] block, the compiler will warn about it when building for Windows. The community convention is to use #[allow(dead_code)] on the gated item, or better yet, use #[cfg_attr(not(target_os = "linux"), allow(dead_code))]. This keeps the warning suppression tied to the condition itself. It signals to readers that the dead code is intentional and platform-specific.
Beware of mixing cfg with feature flags. Cargo features and target configuration are separate namespaces. A feature named linux does not automatically enable target_os = "linux". You must check both explicitly if your code depends on a feature that only makes sense on a specific OS. The compiler will not merge them for you.
You might also encounter the cfg!() macro. It looks similar but behaves completely differently. #[cfg(...)] is an attribute that runs at compile time. cfg!(...) is a macro that evaluates to a boolean at runtime. Use the macro only when you genuinely need a runtime check, such as inside a match arm or a conditional compilation that depends on dynamic input. In practice, runtime checks defeat the purpose of conditional compilation. Stick to the attribute whenever possible.
Treat every cfg branch as a separate compilation unit. Test them independently. If you skip testing a gated path, you are shipping unverified code. Cross-compile to your supported targets during CI. Let the compiler verify your branches before they reach production.
Decision: when to use cfg vs alternatives
Use #[cfg(target_os = "...")] when you need to call platform-specific system APIs or read OS-specific environment variables. Use #[cfg(target_arch = "...")] when you are writing architecture-specific optimizations, inline assembly, or SIMD intrinsics that only exist on certain CPUs. Use #[cfg(feature = "...")] when you are gating optional dependencies or heavy functionality that users can opt into via Cargo.toml. Use #[cfg_attr(...)] when you need to apply another attribute conditionally, such as #[link(name = "...")] on Windows or #[allow(unused)] on specific targets. Use the cfg!() macro when you need a runtime boolean check, though this is rare and usually indicates a design that should be resolved at compile time. Use the --cfg flag in Cargo.toml or .cargo/config.toml when you need custom build-time switches that are not tied to the target triple or Cargo features.
Keep your conditional blocks small. Isolate the platform-specific logic in a dedicated module, then re-export a unified interface. The rest of your crate should never know which OS is running. Trust the compiler to strip what you do not need.