Rust

Feature Flags & Conditional Compilation

Use Cargo features and cfg attributes to build flexible, zero-cost library APIs

Feature Flags & Conditional Compilation

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.

The Basics

Defining Features

Features are declared in Cargo.toml:

Cargo.toml
[features]
default = ["std"]
std = []
serde = ["dep:serde"]
plotting = ["dep:plotters"]

Key rules:

  • Features are additive — enabling a feature can only add code, never remove it
  • default features are enabled unless the consumer opts out with default-features = false
  • dep: syntax (Rust 1.60+) prevents optional dependencies from implicitly creating feature names

Using Features in Code

The #[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:

lib.rs
#[cfg(feature = "plotting")]
pub mod plot;

#[cfg(feature = "std")]
pub mod io;

Common Patterns

Optional Serialization

The most common feature flag pattern in the Rust ecosystem:

Cargo.toml
[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 Support

For embedded or WASM targets, support no_std while keeping full functionality available:

Cargo.toml
[features]
default = ["std"]
std = []
lib.rs
#![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"

Platform-Specific Code

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;

Feature Flag Design

Additive Only

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 { /* ... */ }

Granular vs. Grouped

For libraries with many optional modules, choose between granular features and convenience groups:

Cargo.toml
[features]
default = ["core"]

# Granular
core = []
atmosphere = []
constellation = []
epfd = ["constellation"]
interference = []

# Convenience group
full = ["core", "atmosphere", "constellation", "epfd", "interference"]

Feature-Gated Dependencies

Chain features through dependency trees:

Cargo.toml
[dependencies]
rfconversions = { version = "0.7", optional = true }
plotters = { version = "0.3", optional = true }

[features]
plotting = ["dep:plotters", "dep:rfconversions"]

Testing Feature Combinations

In CI

Test multiple feature combinations to catch compilation errors:

.github/workflows/ci.yml
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 }}

Locally with 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

Feature-Gated Tests

#[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 Features

The 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() { /* ... */ }

Documenting Features

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:

Cargo.toml
[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 { /* ... */ }

Anti-Patterns

Mutually Exclusive Features

// ❌ 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;

Too Many Features

If you have more than ~10 features, consider splitting into multiple crates. Feature combinatorics grow exponentially — testing and maintaining them becomes a burden.

Features That Change Behavior

// ❌ 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.

Summary

PatternWhen to Use
dep:serde optional serializationAlmost every library
no_std with std featureEmbedded/WASM targets
cfg(target_os)Platform-specific I/O or FFI
cfg(debug_assertions)Expensive validation in dev only
cfg(test) helpersTest utilities that shouldn't ship
doc_auto_cfgAny crate with features on docs.rs
Feature matrix in CIAny 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.