Rust Deep Dives #8: Cargo Features, #[cfg], and Conditional Compilation in Plain English
Post 8 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.
Previous: #7: Declarative Macros
Most real-world Rust crates don't compile exactly the same code every time. Some code only runs on Linux. Some code only exists in debug builds. Some code disappears entirely unless the user opts into a feature. Rust handles all of this at compile time, not at runtime, and the mechanisms are surprisingly straightforward once you see how the pieces fit together.
This post covers the full toolkit: #[cfg()] attributes, the cfg!
macro, Cargo features, optional dependencies, #[cfg_attr()], platform-specific
dependencies, and the common patterns and mistakes you'll encounter in practice.
#[cfg()] — Conditional compilation basics
The #[cfg()] attribute tells the compiler to include or exclude a piece
of code based on a condition. When the condition is false, the code doesn't just
get skipped at runtime — it's removed entirely during compilation. It won't appear
in the binary, and it won't even be type-checked.
Rust ships with a set of built-in configuration options. The most useful ones come up constantly.
test
The test cfg is set when you run cargo test. This is how
test modules work:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_parses_valid_input() {
let result = parse("hello");
assert!(result.is_ok());
}
}
The entire tests module vanishes in release builds. It's not just
not-executed — it's not compiled. Any test-only dependencies you import inside that
module don't need to exist in your production binary.
debug_assertions
This one is set in debug builds and unset in release builds. It's useful for expensive sanity checks you want during development but not in production:
fn process_batch(items: &[Item]) {
#[cfg(debug_assertions)]
{
for item in items {
assert!(item.is_valid(), "invalid item: {:?}", item);
}
}
// actual processing...
}
In debug mode, every item gets validated before processing. In release mode, that entire block is gone. Zero cost.
target_os and target_arch
These let you write platform-specific code. The values come from the target triple you're compiling for:
#[cfg(target_os = "linux")]
fn get_memory_usage() -> u64 {
// read from /proc/self/statm
let contents = std::fs::read_to_string("/proc/self/statm").unwrap();
let pages: u64 = contents.split_whitespace()
.next().unwrap()
.parse().unwrap();
pages * 4096
}
#[cfg(target_os = "macos")]
fn get_memory_usage() -> u64 {
// use mach kernel APIs
todo!("macOS implementation")
}
#[cfg(target_arch = "aarch64")]
fn use_neon_simd(data: &[f32]) {
// ARM NEON intrinsics
}
You can also use the shorthand unix and windows cfgs,
which cover broader families:
#[cfg(unix)]
fn set_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let perms = Permissions::from_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
#[cfg(windows)]
fn set_permissions(path: &Path) {
// Windows ACLs are a different beast entirely
}
The unix cfg is true on Linux, macOS, FreeBSD, and other Unix-like
systems. It's more practical than checking for each OS individually when you just
need POSIX-style APIs.
Combining conditions
You can combine cfgs with all(), any(), and not():
// Only on 64-bit Linux
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn use_io_uring() { /* ... */ }
// On any desktop OS
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
fn open_browser(url: &str) { /* ... */ }
// Everything except Windows
#[cfg(not(target_os = "windows"))]
fn use_fork() { /* ... */ }
cfg! macro vs #[cfg] attribute
There are two ways to check configuration in Rust, and they behave differently.
The #[cfg] attribute removes code at compile time. The cfg!()
macro evaluates to true or false at compile time, but
both branches must still type-check.
// #[cfg] — code is removed entirely
#[cfg(target_os = "linux")]
fn linux_only() {
// This function doesn't exist on macOS or Windows.
// Calling it from non-Linux code is a compile error.
}
// cfg!() — both branches are compiled, result is a bool
fn describe_platform() -> &'static str {
if cfg!(target_os = "linux") {
"running on linux"
} else if cfg!(target_os = "macos") {
"running on macos"
} else {
"running on something else"
}
}
The key difference: with #[cfg], the excluded code is invisible to the
compiler. It can reference types that don't exist on the current platform, call
functions that aren't defined, or use syntax from a future edition. None of that
matters because the compiler never sees it.
With cfg!(), both branches are type-checked. The dead branch gets
optimized away by LLVM (so there's no runtime cost), but the code must still be
valid Rust. This means you can't call platform-specific APIs inside a
cfg!() branch — they'll fail to compile on other platforms.
When should you use which? Use #[cfg] when the code literally can't
compile on the other platform — different function signatures, platform-specific
types, OS-level APIs. Use cfg!() when both branches are valid Rust and
you just want to pick a value or toggle a behavior:
fn default_config_path() -> PathBuf {
if cfg!(target_os = "windows") {
PathBuf::from(r"C:\ProgramData\myapp\config.toml")
} else {
PathBuf::from("/etc/myapp/config.toml")
}
}
Both branches construct a PathBuf from a string literal. Perfectly
valid Rust on every platform. The optimizer will collapse the dead branch, so the
runtime behavior is identical to using #[cfg].
Cargo features
This is the main event. Cargo features let crate authors and users control which
parts of a crate get compiled. They're defined in Cargo.toml, passed
on the command line or through dependency declarations, and checked in code with
#[cfg(feature = "...")].
Defining features
Features live in the [features] table in your Cargo.toml:
[features]
# The default set — enabled unless the user opts out
default = ["json", "logging"]
# Individual features
json = []
yaml = []
toml-support = []
logging = []
tracing = []
# A "kitchen sink" feature that turns on everything
full = ["json", "yaml", "toml-support", "logging", "tracing"]
Each feature is a name that maps to a list of other features it implies. An empty
list [] means the feature stands alone. The default key
is special — those features are enabled when a user adds your crate as a dependency
without specifying anything.
Using features in code
In your Rust source, check for features with #[cfg(feature = "...")]:
pub fn load_config(path: &Path) -> Result<Config, Error> {
let contents = std::fs::read_to_string(path)?;
match path.extension().and_then(|e| e.to_str()) {
#[cfg(feature = "json")]
Some("json") => serde_json::from_str(&contents).map_err(Error::from),
#[cfg(feature = "yaml")]
Some("yaml") | Some("yml") => serde_yaml::from_str(&contents).map_err(Error::from),
#[cfg(feature = "toml-support")]
Some("toml") => toml::from_str(&contents).map_err(Error::from),
_ => Err(Error::UnsupportedFormat),
}
}
Each match arm only exists if the corresponding feature is enabled. If a user
enables only json, the YAML and TOML arms are stripped from the binary
entirely, along with their dependencies.
Optional dependencies
Features really shine when combined with optional dependencies. When you mark a
dependency as optional = true, Cargo automatically creates a feature
with the same name:
[dependencies]
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1", optional = true }
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
The dep: prefix is important. Without it, enabling the serde
feature would also create an implicit feature named serde. The
dep: syntax (stabilized in Rust 1.60) says "I'm referring to the
dependency, not a feature." This gives you clean control over your public feature
API — users enable json, which pulls in serde and
serde_json as implementation details.
Enabling features
From the command line:
# Enable a specific feature
cargo build --features json
# Enable multiple features
cargo build --features "json yaml tracing"
# Disable defaults and enable only what you want
cargo build --no-default-features --features yaml
# Enable all features
cargo build --all-features
From a dependent's Cargo.toml:
[dependencies]
mylib = { version = "1.0", default-features = false, features = ["yaml"] }
Setting default-features = false disables the defaults. You then
explicitly list the features you want. This is important for keeping binary size
and compile times down in applications where you know exactly what you need.
Designing good features
The most important rule: features should be additive. Enabling a feature should only add functionality, never remove it. If enabling feature A breaks code that worked without it, something is wrong with your feature design.
This matters because of how Cargo resolves features. If crate X depends on your library with feature A, and crate Y depends on it with feature B, Cargo enables both A and B. This is called feature unification. If A and B conflict, the build breaks — and neither X nor Y can fix it.
// BAD: mutually exclusive features
[features]
async-runtime-tokio = []
async-runtime-async-std = []
# If two crates enable different runtimes, the build breaks
// BETTER: one default, override via code or build config
[features]
default = ["tokio-runtime"]
tokio-runtime = ["dep:tokio"]
async-std-runtime = ["dep:async-std"]
# Still not perfect, but at least both can compile together
Put common functionality in the default features. Most users should get a useful crate by just adding it to their dependencies without extra configuration. Reserve non-default features for niche use cases: alternative backends, heavyweight dependencies, unstable APIs.
Don't feature-gate core functionality. If your crate is a JSON parser, the JSON parsing shouldn't be behind a feature flag. Features are for optional extras — serde integration, async support, alternative allocators, format converters.
Name features after what they add, not what they affect. serde is
better than serialization. tokio is better than
async. Users read feature names and immediately understand what
dependency or capability they're opting into.
#[cfg_attr()] — Conditional attributes
Sometimes you don't want to conditionally include code — you want to conditionally
apply an attribute to code that's always present. That's #[cfg_attr()].
The syntax is #[cfg_attr(condition, attribute)]: if the condition is
true, apply the attribute; otherwise, ignore it.
The classic use case is optional serde support. You want your types to always exist,
but only derive Serialize and Deserialize when the user
enables a feature:
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config {
pub host: String,
pub port: u16,
pub max_retries: u32,
}
Without the serde feature, this is just a plain struct with
Debug, Clone, and PartialEq. With the
feature enabled, it also derives serde's traits. The struct itself always exists
— only the attribute changes.
You can stack multiple conditional attributes:
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct ApiResponse {
pub status_code: u16,
pub response_body: String,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub error_message: Option<String>,
}
This pattern is everywhere in the Rust ecosystem. Crates like url,
chrono, uuid, and bytes all use
cfg_attr to offer optional serde support without making serde a
required dependency.
Another common use: conditional linting.
// Only deny missing docs in CI (where we set the `strict` cfg)
#![cfg_attr(feature = "strict", deny(missing_docs))]
#![cfg_attr(feature = "strict", deny(warnings))]
Platform-specific dependencies
Cargo lets you declare dependencies that only apply on certain platforms. The
syntax uses [target.'cfg(...)'.dependencies] in your
Cargo.toml:
[target.'cfg(target_os = "linux")'.dependencies]
inotify = "0.10"
[target.'cfg(target_os = "macos")'.dependencies]
fsevent-sys = "4"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.52", features = ["Win32_Storage_FileSystem"] }
This is different from optional dependencies. Platform-specific dependencies are always included on the matching platform — you don't need to enable a feature. They simply don't exist when compiling for a different target.
In your code, pair these with #[cfg] to use the platform-specific API:
#[cfg(target_os = "linux")]
mod watcher {
use inotify::{Inotify, WatchMask};
pub fn watch_directory(path: &Path) -> Result<(), Error> {
let mut inotify = Inotify::init()?;
inotify.watches().add(path, WatchMask::MODIFY | WatchMask::CREATE)?;
// poll for events...
Ok(())
}
}
#[cfg(target_os = "macos")]
mod watcher {
pub fn watch_directory(path: &Path) -> Result<(), Error> {
// use FSEvents via fsevent-sys
todo!()
}
}
#[cfg(windows)]
mod watcher {
pub fn watch_directory(path: &Path) -> Result<(), Error> {
// use ReadDirectoryChangesW
todo!()
}
}
Each platform gets its own implementation of the watcher module. From
the outside, callers just use watcher::watch_directory() without caring
which backend is active. This is the same pattern used by crates like
notify and mio.
You can also use platform-specific dev-dependencies for testing:
[target.'cfg(unix)'.dev-dependencies]
nix = "0.27" # for testing with Unix signals, pipes, etc.
The test module pattern
You've seen #[cfg(test)] at the top of every test module. Let's look
at the conventions and subtleties.
The standard pattern is an inline test module at the bottom of each source file:
// src/parser.rs
pub fn parse(input: &str) -> Result<Ast, ParseError> {
// ...
}
fn skip_whitespace(input: &str) -> &str {
input.trim_start()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_input() {
assert!(parse("").is_err());
}
#[test]
fn skip_whitespace_trims_leading() {
assert_eq!(skip_whitespace(" hello"), "hello");
}
}
The use super::* import pulls in everything from the parent module,
including private functions like skip_whitespace. This is intentional
— test modules are children of the module under test, so they can access private
items. This is one of the reasons Rust doesn't need a separate "friend" or
"internal" visibility modifier.
Test-only helper functions and fixtures should also live behind
#[cfg(test)]:
#[cfg(test)]
mod tests {
use super::*;
/// Build a Config with test defaults. Only exists during testing.
fn test_config() -> Config {
Config {
host: "localhost".to_string(),
port: 0, // let the OS pick a port
max_retries: 1,
timeout: Duration::from_millis(100),
}
}
#[test]
fn connects_with_defaults() {
let config = test_config();
let client = Client::new(config);
assert!(client.connect().is_ok());
}
}
If you need test helpers shared across multiple modules, put them in a dedicated test utility module:
// src/test_helpers.rs
#![cfg(test)]
use crate::Config;
pub fn test_config() -> Config {
// shared test configuration
Config::default()
}
pub fn setup_temp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("failed to create temp dir")
}
// src/lib.rs
#[cfg(test)]
mod test_helpers;
mod parser;
mod compiler;
The #![cfg(test)] at the top of the file (note the ! for
inner attribute) means the entire file is test-only. It won't be compiled in
release builds.
Common patterns and gotchas
Testing feature permutations with cargo hack
Your crate compiles with default features. Great. Does it compile with
--no-default-features? With every individual feature on its own?
With every combination? You need to check, and checking manually is tedious.
The cargo-hack tool automates this:
# Install it
cargo install cargo-hack
# Check that each feature compiles independently
cargo hack check --each-feature
# Check all combinations (exponential — use with few features)
cargo hack check --feature-powerset
# Check each feature with and without defaults
cargo hack check --each-feature --no-dev-deps
The --feature-powerset flag tests every possible combination of
features. For a crate with 5 features, that's 32 combinations. For 10 features,
it's 1,024. Use it on small feature sets, or combine it with
--exclude-features to skip features that are known to work.
Add this to your CI pipeline. Feature-related breakage is one of the most common sources of "works on my machine" bugs in the Rust ecosystem.
Breaking --no-default-features builds
This is the most common feature-related bug. You write code that implicitly depends on a default feature, and it breaks when someone disables defaults:
// Cargo.toml
[features]
default = ["logging"]
logging = ["dep:log"]
// src/lib.rs
pub fn process(data: &[u8]) {
log::info!("processing {} bytes", data.len()); // BUG!
// This fails with --no-default-features because `log` isn't available
}
The fix: wrap the logging call behind the feature gate:
pub fn process(data: &[u8]) {
#[cfg(feature = "logging")]
log::info!("processing {} bytes", data.len());
// actual processing...
}
Or create a macro that silently no-ops when logging is disabled. Many crates take
this approach to avoid scattering #[cfg] annotations everywhere:
#[cfg(feature = "logging")]
macro_rules! trace {
($($arg:tt)*) => { log::trace!($($arg)*) }
}
#[cfg(not(feature = "logging"))]
macro_rules! trace {
($($arg:tt)*) => { } // expands to nothing
}
Feature-gated trait implementations
A common pattern in library crates: implement a trait only when a feature is enabled. This is how most crates add serde support:
#[derive(Debug, Clone)]
pub struct Timestamp {
secs: u64,
nanos: u32,
}
#[cfg(feature = "serde")]
impl serde::Serialize for Timestamp {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let total = self.secs as f64 + self.nanos as f64 / 1_000_000_000.0;
serializer.serialize_f64(total)
}
}
This is more flexible than cfg_attr with derive because you get full
control over the serialization format. The struct always exists. The
Serialize impl only shows up when serde is enabled.
Compile-time feature detection with compile_error!
Sometimes you need to give users a clear error message when they've misconfigured
features. The compile_error! macro is your friend:
#[cfg(all(feature = "backend-opengl", feature = "backend-vulkan"))]
compile_error!("features `backend-opengl` and `backend-vulkan` are mutually exclusive");
#[cfg(not(any(feature = "backend-opengl", feature = "backend-vulkan")))]
compile_error!("at least one backend feature must be enabled: `backend-opengl` or `backend-vulkan`");
This turns a confusing "function not found" error into a clear message explaining what went wrong. It's a small thing, but it makes a big difference when someone hits the problem at 2 AM.
Feature-gated public API
When you feature-gate public items, document it. Users reading your docs need to know which feature to enable:
/// Compress data using zstd.
///
/// Requires the `zstd` feature.
#[cfg(feature = "zstd")]
pub fn compress(data: &[u8]) -> Vec<u8> {
zstd::encode_all(data, 3).unwrap()
}
On docs.rs, you can enable all features for documentation by adding this to your
Cargo.toml:
[package.metadata.docs.rs]
all-features = true
This ensures that feature-gated items show up in the generated documentation. You
can also annotate items with #[doc(cfg(...))] on nightly to show a
badge indicating which feature is required, though this is still unstable at the
time of writing.
Feature flags and conditional compilation are one of those areas where the initial
learning curve is shallow — #[cfg(test)] just works — but the deeper
patterns take time to internalize. The key insight is that all of this happens at
compile time. There's no runtime overhead, no feature-check functions called in hot
loops, no if-else branches that the CPU has to predict. The binary you ship contains
exactly the code that was selected, nothing more. That's the Rust way: zero-cost
abstractions that let you pay only for what you use.
Telex