Cargo features let you ship one crate that adapts to different use cases — optional dependencies, platform-specific code, and compile-time API variants — all with zero runtime cost. This guide covers practical patterns for library and application authors.
Features are declared in Cargo.toml:
[features]
default = ["std"]
std = []
serde = ["dep:serde"]
plotting = ["dep:plotters"]
Key rules:
default features are enabled unless the consumer opts out with default-features = falsedep: syntax (Rust 1.60+) prevents optional dependencies from implicitly creating feature namesThe #[cfg(feature = "...")] attribute controls compilation:
/// Always available
pub fn db_to_linear(db: f64) -> f64 {
10_f64.powf(db / 10.0)
}
/// Only compiled when the "serde" feature is enabled
#[cfg(feature = "serde")]
impl serde::Serialize for LinkBudget {
// ...
}
For entire modules:
#[cfg(feature = "plotting")]
pub mod plot;
#[cfg(feature = "std")]
pub mod io;
The most common feature flag pattern in the Rust ecosystem:
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
[features]
default = []
serde = ["dep:serde"]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NoiseTemperature {
pub kelvin: f64,
}
This pattern lets users who don't need serialization avoid pulling in serde entirely — a meaningful compile-time savings.
no_std SupportFor embedded or WASM targets, support no_std while keeping full functionality available:
[features]
default = ["std"]
std = []
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
#[cfg(feature = "std")]
use std::{string::String, vec::Vec};
Consumers opt in:
# Embedded project — no std
rfconversions = { version = "0.7", default-features = false }
# Normal project — std included by default
rfconversions = "0.7"
Use cfg attributes (not features) for platform differences:
#[cfg(target_os = "linux")]
fn get_cpu_temp() -> Option<f64> {
std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.map(|millideg| millideg / 1000.0)
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_temp() -> Option<f64> {
None
}
Combine platform and feature checks:
#[cfg(all(feature = "gpu", target_os = "linux"))]
mod cuda_backend;
Features must be additive. This is the cardinal rule — violating it causes compilation failures when multiple dependents enable different features.
// ❌ BAD: feature removes functionality
#[cfg(not(feature = "minimal"))]
pub fn advanced_calculation() -> f64 { /* ... */ }
// ✅ GOOD: feature adds functionality
#[cfg(feature = "advanced")]
pub fn advanced_calculation() -> f64 { /* ... */ }
For libraries with many optional modules, choose between granular features and convenience groups:
[features]
default = ["core"]
# Granular
core = []
atmosphere = []
constellation = []
epfd = ["constellation"]
interference = []
# Convenience group
full = ["core", "atmosphere", "constellation", "epfd", "interference"]
Chain features through dependency trees:
[dependencies]
rfconversions = { version = "0.7", optional = true }
plotters = { version = "0.3", optional = true }
[features]
plotting = ["dep:plotters", "dep:rfconversions"]
Test multiple feature combinations to catch compilation errors:
jobs:
test:
strategy:
matrix:
features:
- "" # no default features
- "default"
- "serde"
- "serde,plotting"
- "--all-features"
steps:
- uses: actions/checkout@v4
- run: cargo test --no-default-features --features ${{ matrix.features }}
just# Test all feature combinations
test-features:
cargo test --no-default-features
cargo test
cargo test --all-features
cargo test --features serde
cargo test --features plotting
#[cfg(test)]
mod tests {
#[test]
fn basic_conversion() {
assert!((super::db_to_linear(3.0) - 2.0).abs() < 0.02);
}
#[test]
#[cfg(feature = "serde")]
fn roundtrip_serialization() {
let nt = super::NoiseTemperature { kelvin: 290.0 };
let json = serde_json::to_string(&nt).unwrap();
let back: super::NoiseTemperature = serde_json::from_str(&json).unwrap();
assert!((back.kelvin - 290.0).abs() < f64::EPSILON);
}
}
cfg Beyond FeaturesThe cfg system handles more than features:
// Debug vs release
#[cfg(debug_assertions)]
fn validate_input(x: f64) {
assert!(x.is_finite(), "non-finite input: {x}");
}
#[cfg(not(debug_assertions))]
fn validate_input(_x: f64) {}
// Architecture
#[cfg(target_arch = "aarch64")]
fn use_neon_simd() { /* ARM NEON intrinsics */ }
// Test-only code
#[cfg(test)]
pub(crate) fn test_helper() -> TestFixture { /* ... */ }
// Combine conditions
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
fn apple_silicon_path() { /* ... */ }
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn unix_path() { /* ... */ }
Use doc_auto_cfg (nightly) or manual documentation to make feature requirements visible in rustdoc:
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
Add to Cargo.toml for docs.rs:
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
This automatically tags items in documentation with their required features — readers see exactly what they need to enable.
For stable Rust, document manually:
/// Serialize this type to JSON.
///
/// *Requires the `serde` feature.*
#[cfg(feature = "serde")]
pub fn to_json(&self) -> String { /* ... */ }
// ❌ Breaks when both enabled
#[cfg(feature = "backend-a")]
type Backend = BackendA;
#[cfg(feature = "backend-b")]
type Backend = BackendB;
Instead, use a default with an override, or require the consumer to pick via a trait:
// ✅ Provide both, let consumer choose at the type level
#[cfg(feature = "backend-a")]
pub mod backend_a;
#[cfg(feature = "backend-b")]
pub mod backend_b;
If you have more than ~10 features, consider splitting into multiple crates. Feature combinatorics grow exponentially — testing and maintaining them becomes a burden.
// ❌ Same function, different behavior based on feature
pub fn calculate(x: f64) -> f64 {
#[cfg(feature = "precise")]
{ /* high precision path */ }
#[cfg(not(feature = "precise"))]
{ /* fast path */ }
}
Instead, expose both as separate functions and let the caller decide.
| Pattern | When to Use |
|---|---|
dep:serde optional serialization | Almost every library |
no_std with std feature | Embedded/WASM targets |
cfg(target_os) | Platform-specific I/O or FFI |
cfg(debug_assertions) | Expensive validation in dev only |
cfg(test) helpers | Test utilities that shouldn't ship |
doc_auto_cfg | Any crate with features on docs.rs |
| Feature matrix in CI | Any crate with 2+ features |
Feature flags are one of Cargo's strongest design decisions — they give library authors flexibility without imposing runtime cost on consumers. Keep them additive, test the combinations, and document what each one enables.