The day your test module shipped to production
Imagine you're building a small library. You've written a tests module right next to your real code, with helper structs and a few hundred lines of assertions. You publish v0.1 to crates.io. A user pulls your crate into their project, builds in release mode, and notices the binary is bigger than expected. They open an issue. It turns out your tests, your test fixtures, and the entire proptest dev dependency they pulled in transitively all got compiled into their final binary.
That's the problem #[cfg] exists to solve. It's the compiler attribute that lets you say: this chunk of code only exists when certain conditions are true. Otherwise, pretend I never wrote it. The most common use is #[cfg(test)], which strips a module out of every build except cargo test. But #[cfg] is much more general than that, and once you understand the shape of it, you'll see it in every serious Rust project.
What cfg actually does
#[cfg(...)] is a conditional compilation attribute. The compiler evaluates the condition at the start of compilation. If it's true, the item the attribute is attached to is compiled normally. If it's false, the compiler removes the item entirely, as if you'd commented it out. There's no runtime check, no dead-code branch, nothing in the binary. The code just doesn't exist.
Think of it like a paper script with sticky notes attached. Each sticky note says "only read this page if today is Tuesday." Before you start performing the play, you go through and tear out every page whose sticky note doesn't match. By the time you're acting, only the relevant pages remain.
The conditions you can check fall into a few buckets:
test: true when you runcargo test.target_os = "linux"(orwindows,macos, etc.): true when compiling for that OS.target_arch = "x86_64": same idea, for CPU architecture.feature = "serde": true when a Cargo feature flag is enabled.debug_assertions: true in debug builds, false in--release.not(...),any(...),all(...): combinators to build more complex conditions.
The classic: gating a test module
Here's the version you'll see in nearly every Rust project:
// The function under test. Compiled into every build.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// #[cfg(test)] tells the compiler: only include this module when running tests.
// In a normal `cargo build` or `cargo build --release`, this entire mod block
// disappears. The `tests` symbol won't even exist in the resulting binary.
#[cfg(test)]
mod tests {
// `use super::*` pulls in the parent module's items so we can call `add`.
use super::*;
// #[test] marks this function as a test. cargo test will discover and run it.
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
When you run cargo test, the compiler sets the test cfg flag, your tests module gets compiled in, and the #[test] attribute on it_works registers it with the test runner. When you run cargo build, the test cfg is not set, the entire module is dropped, and it_works simply does not exist in the output.
This matters more than it looks. You can have an entire test harness with mock types, helper builders, and dependencies pulled in only as dev-dependencies. None of it lands in production.
What happens when the condition is false
If you're skeptical the code really vanishes, try this:
// Imagine you add a function gated on a feature flag that isn't enabled.
// Without --features=fancy, this entire fn doesn't exist as far as
// the rest of the program is concerned.
#[cfg(feature = "fancy")]
pub fn fancy_print(s: &str) {
println!("~~ {} ~~", s);
}
fn main() {
// If `fancy` is disabled, the line below is a compile error:
// error[E0425]: cannot find function `fancy_print` in this module
fancy_print("hi");
}
The compiler will produce something like:
error[E0425]: cannot find function `fancy_print` in this module
--> src/main.rs:9:5
|
9 | fancy_print("hi");
| ^^^^^^^^^^^ not found in this module
That's the proof: the function isn't "defined but skipped," it doesn't exist at all. If you want code that calls fancy_print only when the feature is enabled, you have to gate the call site too.
A more realistic example: platform-specific code
Suppose your library needs to read a config path. On Linux that's ~/.config/myapp/config.toml. On macOS it's ~/Library/Application Support/myapp/config.toml. On Windows it's somewhere under %APPDATA%. You don't want three different code paths cluttering one function. Use #[cfg(target_os = ...)].
use std::path::PathBuf;
// On Linux, prefer XDG_CONFIG_HOME if set, otherwise ~/.config.
#[cfg(target_os = "linux")]
fn config_dir() -> PathBuf {
// dirs crate would normally do this; we'll do it by hand for the example.
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let mut p = PathBuf::from(std::env::var_os("HOME").unwrap());
p.push(".config");
p
})
}
// On macOS, the convention is ~/Library/Application Support.
#[cfg(target_os = "macos")]
fn config_dir() -> PathBuf {
let mut p = PathBuf::from(std::env::var_os("HOME").unwrap());
p.push("Library/Application Support");
p
}
// On Windows, %APPDATA% is the right environment variable.
#[cfg(target_os = "windows")]
fn config_dir() -> PathBuf {
PathBuf::from(std::env::var_os("APPDATA").unwrap())
}
fn main() {
// The compiler picks exactly one of the three definitions above based on
// the target you're compiling for. The other two are dropped entirely.
println!("config dir: {:?}", config_dir());
}
Notice there's no runtime branching, no if cfg!(target_os = "linux"). That kind of branching exists too, via the cfg! macro, but it produces a runtime boolean and keeps all the code in your binary. The attribute form is a compile-time switch and is what you want when the alternative branches genuinely shouldn't exist on the other platforms (for example, because they call OS-specific APIs that only link on one target).
Combinators: any, all, not
Real projects need more than single conditions. Suppose you want a function included on Linux or macOS but not Windows. Or only when both a feature is enabled and you're not compiling tests.
// Compile this on any Unix-like OS we care about.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
fn unix_only() { /* ... */ }
// Compile this only when the `metrics` feature is on AND we're in release mode.
#[cfg(all(feature = "metrics", not(debug_assertions)))]
fn record_release_metrics() { /* ... */ }
// Compile this on everything except Windows.
#[cfg(not(target_os = "windows"))]
fn fork_a_process() { /* ... */ }
The any, all, and not operators take other cfg expressions and combine them. They nest as deep as you need.
cfg vs cfg! vs cfg_attr
Three similar names, three different jobs:
#[cfg(...)]is the attribute. It decides whether an item (a fn, mod, struct, impl block, even a single field) is included. Compile-time only.cfg!(...)is a macro that expands to a booleantrueorfalseat compile time. Use it inside an expression when you want runtime-style branching that the compiler can constant-fold away. Both branches must still type-check, even the dead one.#[cfg_attr(condition, attribute)]applies another attribute conditionally. For example,#[cfg_attr(feature = "serde", derive(Serialize))]adds aSerializederive only when theserdefeature is on.
The cfg! macro is handy for things like:
if cfg!(debug_assertions) {
eprintln!("slow check enabled");
}
But don't reach for it when the bodies of the two branches genuinely cannot compile on the other platform. For that you need the attribute form, where the wrong branch is removed before type-checking happens.
Common pitfalls
You wrote #[cfg(test)] on a helper used by both real code and tests. Now cargo build fails because the helper doesn't exist. Fix: only put #[cfg(test)] on items that are exclusively for testing, like the tests module itself. Helpers used by the production code stay un-gated.
You wrote #[cfg(feature = "foo")] but forgot to declare foo in Cargo.toml under [features]. The compiler used to silently treat it as always-false. Newer rustc versions warn about "unexpected cfg condition," which is what you want. Add the feature to Cargo.toml even if no default code uses it.
You wrote #[cfg(target_os == "linux")] (with ==). That's not the syntax. It's target_os = "linux", a single equals sign, treated as a key=value pair, not a comparison.
You used cfg!(...) and got surprised that the inactive branch still has to compile. That's by design. If you genuinely need code that won't compile on the other platform, switch to the attribute form #[cfg(...)] on two separate function definitions.
You added a new platform branch and ran cargo build on Linux. It looks like the macOS branch "works" because you never tried compiling for it. Use cargo check --target x86_64-apple-darwin (after installing the target) or rely on CI to catch this.
When to reach for cfg
Reach for #[cfg(test)] whenever you have test helpers, mock types, or test-only modules. It's the single most common use.
Reach for #[cfg(target_os = ...)] when you genuinely have platform-specific implementations: file paths, system calls, FFI to OS libraries.
Reach for #[cfg(feature = ...)] when you want optional functionality that users opt into, especially when enabling it pulls in extra dependencies.
Reach for cfg!(...) (the macro) when both branches compile everywhere and you just want a tidy compile-time-known boolean.
If the variation is purely runtime data (a config value, a CLI flag), don't use cfg at all. Use a regular if or a match. cfg is for compile-time decisions.
Where to go next
How to Use cfg Attributes for Platform-Specific Code