Cargo Features and Conditional Compilation (#[cfg]) in Rust
Think about ordering a pizza. Everyone gets the base and the tomato sauce. Then you choose extras: mushrooms, extra cheese, olives. The kitchen only makes what you asked for, so you don’t pay (or wait) for toppings you don’t want.
Rust code works the same way. With conditional compilation (#[cfg]), you tell the compiler “only build this part on Windows” or “only in tests”. With Cargo features, the people who use your crate pick the optional extras they need, like JSON support. Code that isn’t picked is not compiled at all. It doesn’t slow down the build, and it isn’t in the final program.
This page builds on Cargo & Crates (Cargo.toml, cargo add) and Rust Tools & Setup. If those words are new, have a quick look there first.
#[cfg]: keep or remove code
Section titled “#[cfg]: keep or remove code”#[cfg(...)] is an attribute you put on a function, a struct, a use line, a module or even a single statement. If the condition is true, the code stays. If it is false, the compiler removes it completely, as if you had never written it.
#[cfg(target_os = "linux")]fn os_name() -> &'static str { "Linux"}
#[cfg(target_os = "windows")]fn os_name() -> &'static str { "Windows"}
#[cfg(target_os = "macos")]fn os_name() -> &'static str { "macOS"}
fn main() { println!("Hello from {}!", os_name());}Output:
Hello from Linux!The Rust Playground runs on Linux, so it prints Hello from Linux!. On your own Windows laptop, the same code prints Hello from Windows!. Three functions have the same name, but that’s fine: on any one computer, only one of them survives.
There’s a small trap here. On FreeBSD, none of the three conditions is true, so os_name doesn’t exist and the build stops with E0425: “cannot find function os_name in this scope”. The next sections show how to add a fallback.
cfg!(): a simple true or false
Section titled “cfg!(): a simple true or false”Sometimes you don’t want to remove code. You just want to ask a question. The cfg!() macro takes the same conditions, but it gives you a plain bool:
fn main() { let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 }; println!("This is a {bits}-bit program"); println!("The biggest usize is {}", usize::MAX);
if cfg!(target_os = "windows") { println!("Paths look like C:\\Users\\ana"); } else { println!("Paths look like /home/ana"); }}Output:
This is a 64-bit programThe biggest usize is 18446744073709551615Paths look like /home/anatarget_pointer_width is the size of a memory address: "64" on almost every modern computer, "32" on some small devices. It’s also the size of usize, which is why usize::MAX is so big here.
So when do you use which?
#[cfg(...)] |
cfg!(...) |
|
|---|---|---|
| What it does | Removes the code when false | Becomes true or false |
| Does the “false” code still compile? | No, it’s gone | Yes, both branches must compile everywhere |
| Use it for | Code that can’t compile elsewhere (OS-only APIs, optional crates) | Small differences in behavior |
That middle row matters. Unix-only things like std::os::unix::fs::PermissionsExt don’t exist on Windows, so code that uses them must sit behind #[cfg(unix)]. An if cfg!(unix) is not enough, because the Windows compiler would still try to compile that branch.
Combining conditions: not, any, all
Section titled “Combining conditions: not, any, all”You can build bigger conditions from small ones:
not(a): true whenais falseany(a, b, ...): true when at least one is true (like “or”)all(a, b, ...): true when every one is true (like “and”)
Here is the OS example again, this time with a fallback so it compiles everywhere:
#[cfg(any(target_os = "linux", target_os = "macos"))]fn shell() -> &'static str { "sh"}
#[cfg(target_os = "windows")]fn shell() -> &'static str { "cmd"}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]fn shell() -> &'static str { "unknown"}
fn main() { println!("Default shell: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) { println!("A 64-bit Unix system"); }}Output:
Default shell: shA 64-bit Unix systemunix and windows are handy short names. unix is true on Linux, macOS, the BSDs and similar systems. These are the conditions you’ll meet most often:
| Condition | True when… |
|---|---|
target_os = "linux" / "windows" / "macos" |
You build for that operating system |
unix / windows |
You build for any Unix-like system / for Windows |
target_arch = "x86_64" / "aarch64" / "wasm32" |
You build for that kind of processor (WebAssembly is wasm32) |
target_pointer_width = "64" |
Addresses (and usize) are 64 bits |
debug_assertions |
It’s a debug build (cargo build), not --release |
test |
You are running cargo test |
feature = "json" |
The Cargo feature json is turned on (more below) |
Want to see the full list for your machine? Run rustc --print cfg. On a 64-bit Linux PC it prints lines like target_os="linux", target_family="unix" and debug_assertions. You’ll see #[cfg(windows)] and #[cfg(unix)] a lot in FFI code, because C libraries often differ between systems.
#[cfg(test)] and #[cfg_attr]
Section titled “#[cfg(test)] and #[cfg_attr]”You have already used one condition without thinking about it. Every test module on the Testing page starts with #[cfg(test)]:
pub fn add_points(total: u32, points: u32) -> u32 { total + points}
#[cfg(test)]mod tests { use super::*;
#[test] fn adds_points() { assert_eq!(add_points(10, 5), 15); }}cargo test turns test on, so the module is built and its tests run. A normal cargo build leaves the whole module out, so your real program carries no test code. #[cfg] works on any module like this (see Modules), for example #[cfg(windows)] mod windows_paths;.
#[cfg_attr(condition, attribute)] means: “if the condition is true, add this attribute”. A common use is a derive that you only want sometimes:
#[cfg_attr(debug_assertions, derive(Debug))]struct Player { name: String, score: u32,}
fn main() { let player = Player { name: "Ana".to_string(), score: 120 };
// only in debug builds; goes to stderr, not stdout #[cfg(debug_assertions)] eprintln!("debug: {:?}", player);
println!("{} has {} points", player.name, player.score);}Output:
Ana has 120 pointsIn a debug build (the Playground’s default), you also see debug: Player { name: "Ana", score: 120 } in the “Standard Error” part of the output. Switch the Playground to “Release” and that line disappears, together with the Debug code for Player. Notice that the eprintln! needs its own #[cfg(debug_assertions)]. Without it, a release build fails, because Player doesn’t implement Debug there. More about debug and release builds is on Cargo & Crates.
Cargo features in your own crate
Section titled “Cargo features in your own crate”Now the pizza menu. A feature is a named switch that you define in Cargo.toml, in a [features] table. Let’s build a small program, pizza_order, with two features:
extra_cheese: a free extra, turned on by default.json: prints the order as JSON. It needs theserdeandserde_jsoncrates, so they are only downloaded and compiled when someone asks forjson.
[package]name = "pizza_order"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1", features = ["derive"], optional = true }serde_json = { version = "1", optional = true }
[features]default = ["extra_cheese"]extra_cheese = []json = ["dep:serde", "dep:serde_json"]Line by line:
optional = truemeans “don’t use this crate unless a feature asks for it”.extra_cheese = []creates a feature that turns on nothing else. It’s just a switch your code can check.json = ["dep:serde", "dep:serde_json"]says: whenjsonis on, also turn on these two optional dependencies.dep:means “this is a dependency, not a feature name”.default = [...]lists the features that are on when nobody says otherwise.
In the code, you check a feature with feature = "name":
#[cfg_attr(feature = "json", derive(serde::Serialize))]struct Order { size: String, toppings: Vec<String>,}
fn main() { let mut toppings = vec!["tomato".to_string(), "basil".to_string()];
if cfg!(feature = "extra_cheese") { toppings.push("extra cheese".to_string()); }
let order = Order { size: "large".to_string(), toppings }; println!("Order: {} pizza with {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")] println!("As JSON: {}", serde_json::to_string(&order).unwrap());}The JSON line uses #[cfg], not cfg!. Without the json feature, serde_json isn’t a dependency at all, so that line must disappear completely. The cheese check compiles fine either way, so cfg! is enough.
Now order your pizza from the command line. (-q just hides Cargo’s “Compiling…” lines.)
cargo run -q # default features onlycargo run -q --features json # defaults + jsoncargo run -q --no-default-features # nothing extra, not even the defaultscargo run -q --all-features # every featureThis is the real output:
$ cargo run -qOrder: large pizza with tomato, basil, extra cheese
$ cargo run -q --features jsonOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-featuresOrder: large pizza with tomato, basil
$ cargo run -q --all-featuresOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}You can combine the flags. --no-default-features --features json gives JSON without the cheese. For several features, separate them with commas: --features json,extra_cheese. The same flags work for cargo build, cargo check and cargo test.
Turning on features of your dependencies
Section titled “Turning on features of your dependencies”Most of the time, you are on the other side of the counter: you use crates that have features. You have already seen this on Cargo & Crates:
[dependencies]serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }serde_json = { version = "1", features = ["preserve_order"] }serde’sderivefeature gives you#[derive(Serialize, Deserialize)].tokiohas no default features. On its own it gives you almost nothing.fullturns on everything: the runtime,#[tokio::main], timers, networking, files and more. That’s easy while you learn. In a real project you can ask for less, for examplefeatures = ["rt-multi-thread", "macros"].serde_json’spreserve_orderkeeps object keys in the order you inserted them.
cargo add writes these lines for you: cargo add tokio --features full.
Why do crates split things up like this? Because every part you don’t use is code you don’t have to download, compile or ship. A big crate like tokio would make every small project slow to build if it always compiled everything. Features keep compile times and binary sizes down.
You can also switch a crate’s defaults off and pick only what you need, with default-features = false:
[dependencies]serde_json = { version = "1", default-features = false, features = ["alloc"] }To see which features are on in your project, ask Cargo:
cargo tree -e features --features json --depth 1For pizza_order, this prints:
pizza_order v0.1.0 (/home/ana/pizza_order)├── serde feature "default"├── serde feature "derive"└── serde_json feature "default"Without --depth 1 you see the whole tree, including features that your dependencies turn on in their dependencies. It’s long, but it’s the best tool when you wonder “why is this crate being compiled?”.
Rules of thumb
Section titled “Rules of thumb”Features should only add things. Turning a feature on should never remove a function or change what an existing function does. Here’s why. If two crates in your project both use serde_json, and one asks for preserve_order while the other doesn’t, Cargo compiles serde_json once, with the features of both turned on. So every crate must still work when someone else turns on extra features.
Avoid features that can’t be on together. A design like “feature postgres OR feature sqlite, but never both” breaks as soon as two crates pick different ones, and --all-features stops working. Prefer features that can all be on at once.
Document your features. Write a short list in your README or your crate docs: the feature name, what it adds, and whether it’s on by default. On docs.rs, every crate has a “Feature flags” page (for example tokio’s), and many crates mark items with “Available on crate feature … only”.
Check every combination in CI. Code behind a feature is only compiled when that feature is on. Add these to your CI so nothing hides there:
cargo check --all-featurescargo check --no-default-featurescargo test --all-featuresFor bigger crates, the cargo-hack tool can check each feature on its own (cargo hack check --each-feature).
Common mistakes
Section titled “Common mistakes”A typo in a feature name. If you write #[cfg(feature = "jsno")], the code still compiles. The JSON line just never prints, even with --features json. Luckily, current Rust warns you:
warning: unexpected `cfg` condition value: `jsno` --> src/main.rs:17:11 |17 | #[cfg(feature = "jsno")] | ^^^^^^^^^^------ | | | help: there is a expected value with a similar name: `"json"` | = note: expected values for `feature` are: `default`, `extra_cheese`, and `json` = help: consider adding `jsno` as a feature in `Cargo.toml` = note: `#[warn(unexpected_cfgs)]` on by defaultDon’t ignore this warning. On the command line, Cargo is stricter: cargo run --features jsno stops with error: the package 'pizza_order' does not contain this feature: jsno and suggests json.
Checking the dependency name instead of the feature name. Because of dep:serde, there is no feature called serde in pizza_order. If you write #[cfg_attr(feature = "serde", ...)], you get the same “unexpected cfg condition value” warning, this time for serde. Then cargo run --features json fails with E0277: “the trait bound Order: serde::Serialize is not satisfied”, because the derive was never added. Check the name from [features]: feature = "json".
Forgetting dep:. Once any feature says dep:serde, you must use dep: for serde everywhere. Add a new feature yaml = ["serde"] and Cargo refuses to read the file:
error: failed to parse manifest at `/home/ana/pizza_order/Cargo.toml`
Caused by: feature `yaml` includes `serde`, but `serde` is an optional dependency without an implicit feature Use `dep:serde` to enable the dependency.The fix is yaml = ["dep:serde"]. And if you leave out dep: everywhere, Cargo quietly creates a public feature for each optional dependency (here --features serde would work). That’s an old style. Using dep: keeps your feature list clean and intentional.
Code that is never compiled. Say you mistype serde_json::to_strng inside the #[cfg(feature = "json")] line. cargo check says everything is fine, because that line isn’t compiled by default. Only cargo check --all-features finds it:
error[E0425]: cannot find function `to_strng` in crate `serde_json` --> src/main.rs:18:41 |18 | println!("As JSON: {}", serde_json::to_strng(&order).unwrap()); | ^^^^^^^^ |help: a function with a similar name exists |18 | println!("As JSON: {}", serde_json::to_string(&order).unwrap()); | +This is exactly why --all-features belongs in CI.
Practice
Section titled “Practice”1. A config folder for every OS
Section titled “1. A config folder for every OS”Write a function config_dir() that returns where a notes app keeps its settings: "~/.config/notes" on Linux, "~/Library/Application Support/notes" on macOS, r"%APPDATA%\notes" on Windows, and "./notes" on every other system. Print the result in main. Hint: one function per case with #[cfg(target_os = "...")], and not(any(...)) for the fallback.
Show solution
#[cfg(target_os = "linux")]fn config_dir() -> &'static str { "~/.config/notes"}
#[cfg(target_os = "macos")]fn config_dir() -> &'static str { "~/Library/Application Support/notes"}
#[cfg(target_os = "windows")]fn config_dir() -> &'static str { r"%APPDATA%\notes"}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]fn config_dir() -> &'static str { "./notes"}
fn main() { println!("Settings are saved in {}", config_dir());}Output:
Settings are saved in ~/.config/notesExactly one of the four functions survives on any computer, so main always finds a config_dir. The Playground runs Linux, so you see the Linux path. A real app would use a crate like dirs to find these folders properly.
2. An optional JSON export
Section titled “2. An optional JSON export”You have a small leaderboard program that prints scores. Add a feature export (off by default) that pulls in serde_json and adds an export_json function. With --features export, the program should also print the scores as JSON. Hint: make serde_json optional, use dep:serde_json in the feature, and put #[cfg(feature = "export")] on both the function and the line that calls it. The Playground can’t set Cargo features, so try this one with cargo new leaderboard on your machine.
Show solution
[package]name = "leaderboard"version = "0.1.0"edition = "2024"
[dependencies]serde_json = { version = "1", optional = true }
[features]export = ["dep:serde_json"]struct Score { name: &'static str, points: u32,}
#[cfg(feature = "export")]fn export_json(scores: &[Score]) -> String { let list: Vec<serde_json::Value> = scores .iter() .map(|s| serde_json::json!({ "name": s.name, "points": s.points })) .collect(); serde_json::Value::Array(list).to_string()}
fn main() { let scores = [ Score { name: "Ana", points: 120 }, Score { name: "Bo", points: 95 }, ];
for s in &scores { println!("{}: {} points", s.name, s.points); }
#[cfg(feature = "export")] println!("Export: {}", export_json(&scores));}$ cargo run -qAna: 120 pointsBo: 95 points
$ cargo run -q --features exportAna: 120 pointsBo: 95 pointsExport: [{"name":"Ana","points":120},{"name":"Bo","points":95}]There is no default = [...] line, so export is off unless you ask for it. Without it, serde_json isn’t even downloaded. Both the function and the call need #[cfg], because each of them mentions serde_json.
#[cfg(condition)]keeps code only when the condition is true. Otherwise it is removed before compiling.cfg!(condition)is just abool. Both branches must still compile on every platform.- Combine conditions with
not(..),any(..)andall(..). Common ones aretarget_os,unix/windows,debug_assertionsandtest. #[cfg_attr(condition, attr)]adds an attribute only when the condition is true.- Cargo features live in
[features]. Useoptional = trueplusdep:namefor optional crates, anddefault = [...]for what’s on by default. - Features should only add things. Run
cargo check --all-featuresin CI, and watch forunexpected_cfgswarnings.
Quick check
Section titled “Quick check”Quick check
1. What happens to a function marked #[cfg(target_os = "windows")] when you build on Linux?
2. Why is if cfg!(unix) { ... } not enough for code that uses a Unix-only API?
3. In Cargo.toml, what does json = ["dep:serde_json"] do?
4. You wrote #[cfg(feature = "jsno")] by mistake. What happens?
Score: 0 / 4
Read more
Section titled “Read more”- Features — The Cargo Book: the full guide to
[features], optional dependencies,dep:, and how features are unified. - Conditional compilation — The Rust Reference: every
cfgoption, plus the exact rules for#[cfg],#[cfg_attr]andcfg!. cfg!macro — Rust standard library: whatcfg!returns and how it differs from the#[cfg]attribute.- Specifying Dependencies — The Cargo Book: how to write
features,default-features = falseandoptionalfor each dependency. - cfg — Rust by Example: short examples of
#[cfg]andcfg!side by side.