Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)
Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)
What will I learn
- You will learn how the
#[cfg(...)]attribute compiles code in or out based on a condition; - how feature flags let users opt into optional functionality and dependencies;
- how to write platform-specific code with
target_os,unix, andtarget_pointer_width; - the difference between the
#[cfg]attribute (drops code) and thecfg!macro (a compile-time bool); - how
cfg_attrapplies an attribute conditionally, and how to combine predicates withall,any, andnot.
Requirements
- A working modern computer running macOS, Windows or Ubuntu, with Cargo;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous fifty-six episodes, especially crates, modules, and workspaces;
- The ambition to learn systems programming from the ground up.
Difficulty
- Intermediate
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
- Learn Rust Series (#47) - Option Combinators & Null-Free Programming
- Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
- Learn Rust Series (#49) - thiserror: Ergonomic Library Errors
- Learn Rust Series (#50) - anyhow: Flexible Application-Level Errors & Context
- Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind
- Learn Rust Series (#52) - Testing: Unit Tests, Integration Tests, and Doctests
- Learn Rust Series (#53) - Property-Based Testing with proptest
- Learn Rust Series (#54) - Fuzzing with cargo-fuzz and libFuzzer
- Learn Rust Series (#55) - Benchmarking with Criterion and Reading the Numbers
- Learn Rust Series (#56) - Cargo Workspaces and Multi-Crate Projects
- Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg) (this post)
Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)
Last episode we split a single crate into a whole workspace -- a library holding the logic, a thin binary wrapping it, a shared common crate that both a client and a server agree on. That gave us structure across files and crates. But real programs need a second kind of variation, one that lives inside a single crate: the same source needs to compile differently depending on the situation. A system call that only exists on Linux. An optional JSON integration a user might not want to pay for. An expensive consistency check you want in debug builds but not in the shipped binary. All three are the same question -- "should this code be part of the build or not?" -- and Rust answers all three at compile time with one mechanism: #[cfg(...)].
The word to hold onto is compile time. Unlike a runtime if that lives in your binary and is decided while the program runs, a #[cfg] decision is made by the compiler before a single instruction is emitted. Code behind a false predicate is not merely skipped at runtime -- it is never compiled at all, never type-checked into the final artifact, and its dependencies never even have to be present. That is what lets one codebase serve a bare-bones embedded target and a batteries-included server from the exact same src/, paying only for the parts you actually switch on ;-)
Solutions to Episode 56 Exercises
Episode 56 was Cargo workspaces, and all three exercises were about wiring crates together and controlling what leaks out of them. Here they are in full.
Exercise 1 asked for a two-crate workspace -- a core library with an area function, called from a cli binary -- written with a module stand-in so it compiles in one file, plus a comment showing the real path dependency:
// cli/src/main.rs -- in reality `use core::area;` where core is a path dependency
// and cli/Cargo.toml carries: core = { path = "../core" }
mod core {
pub fn area(w: f64, h: f64) -> f64 {
w * h
}
}
fn main() {
let (w, h) = (3.0, 4.0);
println!("area = {}", core::area(w, h)); // area = 12
}
In a genuine workspace the mod core { ... } block would instead be a separate crate at core/src/lib.rs, and cli would reach it through use core::area; after listing core = { path = "../core" } under its [dependencies]. The single-file module here is just a faithful stand-in so the whole thing runs as one program.
Exercise 2 wanted two functions living in separate internal modules, re-exported through a facade with pub use, then called from the crate root as if those modules did not exist -- and a rename to prove the public path is stable:
mod geometry { // renamed from `shapes`; the public call below does not change
pub fn circle_area(r: f64) -> f64 { std::f64::consts::PI * r * r }
}
mod bodies { // renamed from `solids`
pub fn sphere_volume(r: f64) -> f64 { 4.0 / 3.0 * std::f64::consts::PI * r * r * r }
}
pub use geometry::circle_area; // facade: re-exported at the crate root
pub use bodies::sphere_volume;
fn main() {
println!("{:.3}", circle_area(1.0)); // 3.142
println!("{:.3}", sphere_volume(1.0)); // 4.189
}
The point is that circle_area and sphere_volume are called with no module qualifier at all. I renamed shapes to geometry and solids to bodies, and the call sites at the root did not budge -- because the facade re-export absorbs the internal layout. That is exactly the promise pub use buys you: the module names behind it are yours to change forever.
Exercise 3 asked for a common-style struct deriving Debug, Clone, and PartialEq, an internal helper marked pub(crate), and a pub wrapper calling that helper -- confirming the wrapper compiles while a direct outside call to the helper would be the crate's private business:
#[derive(Debug, Clone, PartialEq)]
pub struct Point {
pub x: i32,
pub y: i32,
}
pub(crate) fn manhattan(a: &Point, b: &Point) -> i32 { // shared inside the crate only
(a.x - b.x).abs() + (a.y - b.y).abs()
}
pub fn distance_report(a: &Point, b: &Point) -> String { // public wrapper over the helper
format!("distance = {}", manhattan(a, b))
}
fn main() {
let p = Point { x: 0, y: 0 };
let q = p.clone();
assert_eq!(p, q); // PartialEq + Clone in action
println!("{}", distance_report(&p, &Point { x: 3, y: 4 })); // distance = 7
println!("{p:?}"); // Point { x: 0, y: 0 }
}
Inside this crate, distance_report can freely call manhattan. From a dependent crate, manhattan would be invisible -- pub(crate) draws exactly that line. The derives give us value equality (assert_eq!) and cheap copying (clone) for free, which is why common-style shared types lean on them so heavily. Right, homework cleared. Now, conditional compilation.
The cfg attribute drops code
#[cfg(predicate)] placed on an item means "only compile this item if the predicate is true; otherwise behave as though the code were never written". The most common predicate you have already met -- as far back as episode 52 -- is #[cfg(test)], which is precisely how test modules stay out of your shipped binary:
pub fn double(n: i32) -> i32 {
n * 2
}
#[cfg(test)] // this whole module exists only during `cargo test`
mod tests {
use super::*;
#[test]
fn doubles() {
assert_eq!(double(21), 42);
}
}
Because the compiler literally removes the item, code behind a false cfg costs nothing -- not a byte in the binary, not a nanosecond at runtime -- and it does not even need its dependencies to be present. That last point matters more than it looks: a test module gated on #[cfg(test)] can pull in a heavy testing-only crate, and that crate never touches your release build. The predicate is not a suggestion the optimizer might act on; it is a hard gate the compiler applies before the code exists at all.
You can attach #[cfg] to almost anything an attribute can sit on -- a function, a struct, an impl block, a use statement, a whole mod, even a single field of a struct. Gating an entire module is the workhorse pattern, because it lets you quarantine a big chunk of situational code behind one line and forget about it everywhere else.
Platform-specific code
target_os selects by operating system. The idiomatic pattern is one function name with several cfg-gated definitions, exactly one of which survives on any given target:
#[cfg(target_os = "macos")]
fn platform_name() -> &'static str { "macOS" }
#[cfg(target_os = "windows")]
fn platform_name() -> &'static str { "Windows" }
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn platform_name() -> &'static str { "some other OS" }
fn main() {
println!("running on {}", platform_name());
}
There are two things worth staring at here. First, from the compiler's point of view there is only ever one platform_name in the build -- the other two definitions simply do not exist on your target, so there is no duplicate-definition error. Second, that not(any(...)) arm is the fallback, and it is doing real work: without it, the code would fail to compile on Linux, FreeBSD, or anything else not explicitly named, because platform_name would be undefined there. Forgetting a fallback arm is the classic "compiles on my machine, breaks in CI on a different OS" bug. Whenever you gate by platform, ask yourself "what happens on a target I did not name?" -- and answer it with a fallback.
Beyond target_os there is a small vocabulary of built-in predicates the compiler sets for you: unix and windows (broad families), target_arch ("x86_64", "aarch64", ...), target_pointer_width ("32" or "64"), target_endian, and debug_assertions (true in debug builds, false under --release). You compose these to describe precisely the situation you care about, which is where the combining operators come in later.
Feature flags: opt-in functionality
Platform predicates are set by the compiler; features are set by you, the crate author, and toggled by whoever depends on you. A feature is a named switch declared in Cargo.toml, and code gated on #[cfg(feature = "name")] compiles only when someone builds with that feature turned on:
#[cfg(feature = "extra")]
fn describe() -> &'static str { "extra features enabled" }
#[cfg(not(feature = "extra"))]
fn describe() -> &'static str { "baseline build" }
fn main() {
// exactly one `describe` exists, depending on whether `--features extra` was passed
println!("{}", describe()); // "baseline build" by default
}
The declarations that make this work live in a [features] table. A feature can be empty (a pure switch) or it can pull in an optional dependency, so turning it on both enables code paths and adds a crate to the build:
# Cargo.toml
[dependencies]
serde_json = { version = "1", optional = true } # only compiled when a feature needs it
[features]
default = ["extra"] # what you get with a plain `cargo build`
extra = [] # a bare switch, no extra deps
json = ["dep:serde_json"] # turning on `json` also pulls in serde_json
A user then builds with cargo build --features json, or opts out of everything with cargo build --no-default-features. This is how a crate offers optional integrations -- a JSON codec, an async backend, extra numeric types -- without forcing that weight onto everyone who does not want it. Somebody targeting a tiny embedded device takes the minimal build; somebody writing a server flips on the extras.
There is one rule you must respect, and Cargo will punish you quietly if you break it: features must be additive. Turning a feature on should only ever add capability, never remove or change existing behaviour. The reason is feature unification -- if crate A and crate B both depend on your crate, and A wants json while B does not, Cargo compiles your crate once with the union of the requested features (so, with json on). If enabling json had removed a function that B relied on, B would now fail to build through no fault of its own. Additive features are what make it safe for Cargo to merge everyone's requests into a single compilation.
The cfg! macro: a compile-time boolean
Everything so far has removed code. Sometimes you do not want to drop a whole item -- you just want a runtime if whose condition is known at compile time. That is the cfg! macro. It evaluates a predicate to a plain bool, but crucially keeps both branches compiled and type-checked:
fn main() {
let debug_build = cfg!(debug_assertions);
println!("debug assertions on? {debug_build}");
if cfg!(target_pointer_width = "64") {
println!("this is a 64-bit target");
} else {
println!("this is a 32-bit (or other) target");
}
}
The distinction from the attribute is the whole point of this section, so let me make it sharp. #[cfg(...)] decides whether an item exists; cfg!(...) produces a bool from a predicate while both arms of the surrounding if must still compile on every target. That second requirement is a real constraint: because both branches are type-checked, you cannot call a function inside a cfg! branch that only exists under one configuration -- the branch for the other configuration would reference a function that is not there, and the build breaks. Use cfg! for small in-line decisions where both paths are always valid code; reach for the #[cfg] attribute whenever one path mentions something that only exists in that configuration.
In practice the compiler folds a cfg! down to a constant true or false, so the "dead" branch is optimised away and you pay nothing at runtime -- you get the readability of an ordinary if with the cost of a compile-time constant.
cfg_attr: a conditional attribute
cfg_attr(predicate, attribute) applies attribute only when predicate holds. The textbook use is deriving a trait only when a feature is enabled -- so an optional serde derive never appears in a minimal build that does not want the dependency:
// derive Debug always; under a `serde` feature we would also derive its traits
#[cfg_attr(feature = "serde", derive(Clone))]
#[derive(Debug)]
struct Config {
verbose: bool,
}
fn main() {
let c = Config { verbose: true };
println!("{c:?}"); // Config { verbose: true }
}
Read #[cfg_attr(feature = "serde", derive(Clone))] as "if the serde feature is on, act as if #[derive(Clone)] were written here; otherwise, act as if this line were not written". In real crates this is how you offer #[derive(Serialize, Deserialize)] on your public types only when a downstream user opts into serialization, so the base build never compiles serde at all. You can pass several attributes at once -- #[cfg_attr(feature = "serde", derive(Serialize), derive(Deserialize))] -- and the same trick works for any attribute, not just derives.
Combining predicates
all, any, and not compose predicates into precise conditions -- the boolean AND, OR, and NOT of the cfg world. This lets you target something like "unix, but specifically not macOS", which comes up whenever Linux and the BSDs share an implementation that macOS does not:
#[cfg(all(unix, not(target_os = "macos")))]
fn family() -> &'static str { "unix but not macOS (Linux, BSD, etc.)" }
#[cfg(target_os = "macos")]
fn family() -> &'static str { "macOS (which is also unix)" }
#[cfg(not(unix))]
fn family() -> &'static str { "not a unix (Windows, etc.)" }
fn main() {
println!("{}", family());
}
Note how the three arms are mutually exclusive and exhaustive: macOS matches the second, other unixes match the first, and everything else matches the third, so exactly one family exists on any target and the code compiles everywhere. Designing your cfg arms so they tile the whole space of targets -- no overlaps, no gaps -- is the same discipline as writing an exhaustive match, and it prevents both the "two definitions" error and the "no definition" error in one stroke.
Having said all that, resist the temptation to sprinkle cfg through every function. Conditional compilation scattered widely turns into code that is genuinely hard to read, because you can no longer tell at a glance which lines are live on your machine. The cleanest pattern is to isolate platform or feature differences behind a small internal module with one common interface -- a mod platform that exposes, say, fn line_ending() -> &'static str, with the cfg gymnastics hidden inside it -- so the rest of your codebase calls one stable function and stays blissfully configuration-free.
How C, Go and Python handle it
A look sideways sharpens the picture, as always. C does conditional compilation with the preprocessor: #ifdef, #if, #else, #endif. It is text substitution that runs before the compiler proper even sees the code, and it is famously error-prone -- unbalanced #endifs, macros that expand in surprising ways, and code inside a false #ifdef that is never checked by anything, so it can rot into syntactic garbage without anyone noticing until the flag flips. Rust's #[cfg] is a real language attribute, parsed and understood by the compiler; even code behind a false predicate must still be syntactically valid Rust, which catches a whole class of bugs the C preprocessor happily lets through:
/* C: the preprocessor, pure text substitution before compilation */
#ifdef _WIN32
const char *platform_name(void) { return "Windows"; }
#else
const char *platform_name(void) { return "some other OS"; }
#endif
Go takes a different route with build tags -- a special comment at the top of a file, plus a filename convention. A file named foo_linux.go compiles only on Linux, foo_windows.go only on Windows, and a //go:build line can express boolean combinations. Go's model is coarser: it gates whole files, not individual items, which keeps things simple but pushes you toward one-file-per-platform organisation:
//go:build linux && !android
package main
func platformName() string { return "Linux (but not Android)" }
Python, being interpreted, has no compile-time gating at all -- the nearest thing is a runtime check, import sys; if sys.platform == "win32": ..., which decides while the program runs and always ships every branch. There is no way to make a block of Python literally not exist on a given platform, because there is no compile step to remove it. Seen against those three, Rust's approach is the sweet spot: finer-grained than Go (any item, not just a file), safer than C (real parsing, not text), and genuinely compile-time unlike Python (the dropped code truly is not there).
Wrapping up
So here is the shape of it. #[cfg(predicate)] is a compile-time gate on an item: if the predicate is false, the item is not compiled, not type-checked into your artifact, and its dependencies need not be present -- #[cfg(test)] is the example you already knew. Features are named switches you declare in a [features] table and gate on with #[cfg(feature = "...")]; they let users opt into optional code and optional dependencies, and they must be additive so Cargo can safely unify everyone's requests into one build. The cfg! macro is the runtime-if cousin -- it yields a compile-time bool but keeps both branches compiled, so use it only where both paths are always valid. cfg_attr applies an attribute conditionally, most often a feature-gated derive. And all, any, and not compose predicates into exhaustive, non-overlapping conditions the way an honest match covers every case. Above all, keep the cfg noise contained -- push it behind a small module with a stable interface, and let the rest of your code stay readable.
There is one more source of build-time variation we have not touched. Everything today was decided by predicates the compiler or the user already knows. But sometimes a crate needs to run code during the build itself -- to detect what is available on the host, to compile a bit of C alongside your Rust, or to generate Rust source from some other input before compilation proper begins. Cargo has a dedicated hook for exactly that, and next time we look at how a crate can do real work at build time and feed the results back into the compilation ;-)
Exercises
- Write two
target_os-gated versions of aline_ending()function returning"\r\n"on Windows and"\n"otherwise, with anot(...)fallback arm so it compiles on every platform, and print the result frommain. - Declare a
prettyfeature in a[features]table (sketch theCargo.tomlsnippet in a comment), then gate ashow(v: i32)helper behind#[cfg(feature = "pretty")]with a#[cfg(not(feature = "pretty"))]fallback that prints plainly. - Use
cfg!(debug_assertions)inside an ordinaryifto print whether the current build has debug assertions on, and then usecfg_attrto add a second derive (Clone) to a small struct only under aserdefeature, confirming the baseline build still compiles.
Tot de volgende keer, en veel plezier met compileren! ;-)