Skip to content

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(...)] 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!
▶ Try it Yourself

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.

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 program
The biggest usize is 18446744073709551615
Paths look like /home/ana
▶ Try it Yourself

target_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.

You can build bigger conditions from small ones:

  • not(a): true when a is false
  • any(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: sh
A 64-bit Unix system
▶ Try it Yourself

unix 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.

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 points
▶ Try it Yourself

In 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.

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 the serde and serde_json crates, so they are only downloaded and compiled when someone asks for json.
[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 = true means “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: when json is 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":

src/main.rs
#[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.)

Terminal window
cargo run -q # default features only
cargo run -q --features json # defaults + json
cargo run -q --no-default-features # nothing extra, not even the defaults
cargo run -q --all-features # every feature

This is the real output:

$ cargo run -q
Order: large pizza with tomato, basil, extra cheese
$ cargo run -q --features json
Order: large pizza with tomato, basil, extra cheese
As JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-features
Order: large pizza with tomato, basil
$ cargo run -q --all-features
Order: large pizza with tomato, basil, extra cheese
As 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.

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’s derive feature gives you #[derive(Serialize, Deserialize)].
  • tokio has no default features. On its own it gives you almost nothing. full turns 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 example features = ["rt-multi-thread", "macros"].
  • serde_json’s preserve_order keeps 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:

Terminal window
cargo tree -e features --features json --depth 1

For 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?”.

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:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo test --all-features

For bigger crates, the cargo-hack tool can check each feature on its own (cargo hack check --each-feature).

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 default

Don’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.

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/notes
▶ Try it Yourself

Exactly 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.

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"]
src/main.rs
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 -q
Ana: 120 points
Bo: 95 points
$ cargo run -q --features export
Ana: 120 points
Bo: 95 points
Export: [{"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 a bool. Both branches must still compile on every platform.
  • Combine conditions with not(..), any(..) and all(..). Common ones are target_os, unix/windows, debug_assertions and test.
  • #[cfg_attr(condition, attr)] adds an attribute only when the condition is true.
  • Cargo features live in [features]. Use optional = true plus dep:name for optional crates, and default = [...] for what’s on by default.
  • Features should only add things. Run cargo check --all-features in CI, and watch for unexpected_cfgs warnings.

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