Rust Feature Flags: Conditional Compilation

Rust's feature flag system solves a fundamental problem in library design: how do you provide optional functionality without forcing every user to pay for features they don't use? Unlike runtime...

Key Insights

  • Feature flags in Rust enable conditional compilation that reduces binary size, manages optional dependencies, and provides API flexibility without runtime overhead
  • Properly structured features create a dependency tree where default features provide sensible defaults while advanced users can opt into minimal or maximal configurations
  • Testing feature combinations is critical—a crate with 10 features has 1,024 possible combinations, making tools like cargo-hack essential for maintaining correctness

Understanding Rust’s Conditional Compilation

Rust’s feature flag system solves a fundamental problem in library design: how do you provide optional functionality without forcing every user to pay for features they don’t use? Unlike runtime feature toggles, Rust’s conditional compilation happens at build time, meaning unused code simply doesn’t exist in your final binary.

This matters for several reasons. A web framework might support multiple database backends, but your application only needs PostgreSQL—why include MySQL and SQLite drivers? A serialization library might offer JSON, MessagePack, and CBOR formats, but you only need JSON. Feature flags let library authors provide these options while keeping binaries lean.

The mechanism is straightforward: you declare features in Cargo.toml, then use cfg attributes to conditionally compile code. The compiler eliminates all code paths not enabled by your feature set before generating machine code.

Declaring and Using Features

Features live in your Cargo.toml under the [features] section. Each feature is a named collection of other features or optional dependencies.

[package]
name = "data-processor"
version = "0.1.0"

[dependencies]
serde = { version = "1.0", optional = true }
rmp-serde = { version = "1.1", optional = true }

[features]
default = ["json"]
json = ["serde", "serde_json"]
msgpack = ["serde", "rmp-serde"]
serde_json = { version = "1.0", optional = true }

In your code, use the cfg attribute to conditionally compile items:

#[cfg(feature = "json")]
pub fn serialize_json<T: serde::Serialize>(data: &T) -> Result<String, serde_json::Error> {
    serde_json::to_string(data)
}

#[cfg(feature = "msgpack")]
pub fn serialize_msgpack<T: serde::Serialize>(data: &T) -> Result<Vec<u8>, rmp_serde::encode::Error> {
    rmp_serde::to_vec(data)
}

// Provide a compile-time error if neither feature is enabled
#[cfg(not(any(feature = "json", feature = "msgpack")))]
compile_error!("At least one serialization format must be enabled");

Users enable features when depending on your crate:

[dependencies]
data-processor = { version = "0.1", features = ["msgpack"] }

Building Feature Hierarchies

Real-world crates need sophisticated feature trees. The default feature provides sensible defaults for most users, while power users can opt into minimal builds or enable everything.

[features]
default = ["std"]

# Core features
std = []
alloc = []

# Serialization support
serde = ["dep:serde", "serde/derive"]
serde-std = ["serde", "std"]

# Compression algorithms
compression = ["flate2", "std"]
zstd-compression = ["zstd", "std"]
all-compression = ["compression", "zstd-compression"]

# Everything
full = ["std", "serde-std", "all-compression", "advanced-api"]
advanced-api = ["std"]

[dependencies]
serde = { version = "1.0", optional = true }
flate2 = { version = "1.0", optional = true }
zstd = { version = "0.12", optional = true }

This structure provides clear upgrade paths. A no_std embedded project uses default-features = false. A standard application uses defaults. A power user wanting everything specifies features = ["full"].

The dep: prefix explicitly marks a dependency as a feature, preventing ambiguity between feature names and dependency names—a best practice introduced in recent Cargo versions.

Advanced Conditional Compilation Patterns

Complex projects need sophisticated conditional compilation. Use cfg_attr to conditionally apply attributes:

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config {
    pub timeout: u64,
    pub retries: u32,
}

Combine features with boolean logic:

// Only compile with async feature AND std (not no_std)
#[cfg(all(feature = "async", feature = "std"))]
pub mod async_runtime {
    use std::future::Future;
    
    pub async fn process_async<F: Future>(future: F) -> F::Output {
        future.await
    }
}

// Mutually exclusive backends
#[cfg(all(feature = "tokio-backend", not(feature = "async-std-backend")))]
pub use tokio::spawn;

#[cfg(all(feature = "async-std-backend", not(feature = "tokio-backend")))]
pub use async_std::task::spawn;

#[cfg(all(feature = "tokio-backend", feature = "async-std-backend"))]
compile_error!("Cannot enable both tokio-backend and async-std-backend");

Platform-specific features combine target configuration with custom flags:

#[cfg(all(
    feature = "hardware-acceleration",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
mod simd_ops {
    pub fn fast_transform(data: &[u8]) -> Vec<u8> {
        // SIMD implementation
        todo!()
    }
}

#[cfg(not(all(
    feature = "hardware-acceleration",
    any(target_arch = "x86_64", target_arch = "aarch64")
)))]
mod simd_ops {
    pub fn fast_transform(data: &[u8]) -> Vec<u8> {
        // Portable fallback
        data.iter().map(|&b| b.wrapping_add(1)).collect()
    }
}

Testing Feature Combinations

A crate with N features has 2^N possible feature combinations. You cannot manually test them all, but you must verify that common combinations work.

Test specific features:

cargo test --no-default-features
cargo test --features "json"
cargo test --features "msgpack"
cargo test --all-features

For CI, test critical combinations:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    strategy:
      matrix:
        features:
          - "--no-default-features"
          - "--features json"
          - "--features msgpack"
          - "--features json,msgpack"
          - "--all-features"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test ${{ matrix.features }}

For exhaustive testing, use cargo-hack:

cargo install cargo-hack

# Test all feature combinations (exponential!)
cargo hack test --feature-powerset

# Test each feature individually
cargo hack test --each-feature

# Exclude problematic combinations
cargo hack test --feature-powerset --exclude-features "tokio-backend,async-std-backend"

Best Practices and Common Mistakes

Name features descriptively. Use json-serialization rather than json if the feature does more than just add a dependency. Use std for standard library support, alloc for allocation without std, following ecosystem conventions.

Document features in Cargo.toml:

[features]
## Enables JSON serialization via serde_json
json = ["serde", "serde_json"]

## Enables all compression algorithms (requires std)
all-compression = ["compression", "zstd-compression"]

Avoid feature creep. Every feature adds maintenance burden and testing complexity. Before adding a feature, ask: is this a genuine use case, or should this be a separate crate?

Respect SemVer. Adding features is backward-compatible. Removing features is a breaking change. Changing what a feature enables requires careful consideration—it might break users who depend on the current behavior.

Bad example:

[features]
# BAD: Unclear what "fast" means
fast = ["simd", "unsafe-optimizations"]

# BAD: Mutually exclusive features without guards
backend-a = ["dep-a"]
backend-b = ["dep-b"]
# User can enable both, causing compilation errors

Good example:

[features]
## Enables SIMD optimizations (x86_64/aarch64 only)
simd = []

## Enables unsafe performance optimizations (may violate strict aliasing)
unsafe-optimizations = []

## Combines SIMD and unsafe optimizations
maximum-performance = ["simd", "unsafe-optimizations"]

Real-World Architecture Patterns

Popular crates demonstrate effective feature flag design. Tokio uses features to separate runtime components:

[features]
default = []
full = ["fs", "io-util", "io-std", "macros", "net", "rt", "sync", "time"]
fs = []
io-util = []
net = []
rt = ["rt-multi-thread"]
rt-multi-thread = []

For no_std support, create a clear separation:

// lib.rs
#![cfg_attr(not(feature = "std"), no_std)]

#[cfg(feature = "alloc")]
extern crate alloc;

#[cfg(feature = "alloc")]
use alloc::vec::Vec;

#[cfg(feature = "std")]
use std::vec::Vec;

// Always available
pub fn process_array<const N: usize>(data: [u8; N]) -> [u8; N] {
    data.map(|b| b.wrapping_add(1))
}

// Only with allocation
#[cfg(any(feature = "alloc", feature = "std"))]
pub fn process_vec(data: Vec<u8>) -> Vec<u8> {
    data.into_iter().map(|b| b.wrapping_add(1)).collect()
}

This pattern lets embedded developers use your crate without allocation, while standard applications get convenient Vec-based APIs.

Feature flags are a powerful tool for managing complexity in Rust libraries. Use them to make your crate flexible without sacrificing performance or forcing users to pay for unused functionality. Start simple with default and std features, add optional dependencies as needed, and always test your feature combinations.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.