Feature Flags

Conditional compilation strategies

intermediate
featurescfgconditional
🎮 Interactive Playground

What are Feature Flags?

Cargo feature flags enable optional functionality at compile time. They allow crates to provide different capabilities without forcing all dependencies on every user.

The Problem

Without feature flags:

  • Dependency bloat: Users get dependencies they don't need
  • Compile time: Building unused code slows compilation
  • Binary size: Unused features increase executable size
  • Flexibility: Can't adapt to different use cases

Example Code

# Cargo.toml - Feature definition
[package]
name = "mylib"
version = "1.0.0"
edition = "2021"

[features]
# Default features enabled when dependency is added
default = ["std"]

# Standard library support (can be disabled for no_std)
std = []

# Async runtime support
async = ["tokio"]
async-std = ["dep:async-std"]

# Serialization support
serde = ["dep:serde", "dep:serde_json"]

# Database backends
postgres = ["sqlx/postgres"]
mysql = ["sqlx/mysql"]
sqlite = ["sqlx/sqlite"]

# TLS implementations (mutually exclusive in practice)
native-tls = ["dep:native-tls"]
rustls = ["dep:rustls"]

# Debugging features
debug = ["tracing/max_level_debug"]
trace = ["tracing/max_level_trace"]

# Kitchen sink - all features
full = ["std", "async", "serde", "postgres", "rustls"]

# Unstable features (require nightly)
unstable = []

[dependencies]
# Always included
thiserror = "1.0"

# Optional dependencies (feature-gated)
tokio = { version = "1.35", features = ["full"], optional = true }
async-std = { version = "1.12", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
sqlx = { version = "0.7", optional = true }
native-tls = { version = "0.2", optional = true }
rustls = { version = "0.22", optional = true }
tracing = { version = "0.1", optional = true }

[dev-dependencies]
# Test dependencies always available
tokio = { version = "1.35", features = ["full", "test-util"] }
// src/lib.rs - Conditional compilation
#![cfg_attr(not(feature = "std"), no_std)]

// Conditionally use alloc for no_std
#[cfg(not(feature = "std"))]
extern crate alloc;

#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};

// Feature-gated modules
#[cfg(feature = "async")]
pub mod async_client;

#[cfg(feature = "serde")]
pub mod serialization;

#[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
pub mod database;

// Core functionality always available
pub mod core;
pub mod error;

// Re-exports based on features
pub use core::*;
pub use error::*;

#[cfg(feature = "async")]
pub use async_client::AsyncClient;

#[cfg(feature = "serde")]
pub use serialization::{FromJson, ToJson};

/// Prelude module adapts to enabled features
pub mod prelude {
    pub use crate::core::*;
    pub use crate::error::*;

    #[cfg(feature = "async")]
    pub use crate::async_client::AsyncClient;

    #[cfg(feature = "serde")]
    pub use serde::{Deserialize, Serialize};
}
// src/core.rs - Core types with optional trait implementations
use crate::error::Error;

/// Core data type
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Data {
    pub id: u64,
    pub name: String,
    pub value: Vec<u8>,
}

impl Data {
    pub fn new(id: u64, name: impl Into<String>) -> Self {
        Data {
            id,
            name: name.into(),
            value: Vec::new(),
        }
    }

    /// Serialize to JSON (requires serde feature)
    #[cfg(feature = "serde")]
    pub fn to_json(&self) -> Result<String, Error> {
        serde_json::to_string(self).map_err(|e| Error::Serialization(e.to_string()))
    }

    /// Deserialize from JSON (requires serde feature)
    #[cfg(feature = "serde")]
    pub fn from_json(json: &str) -> Result<Self, Error> {
        serde_json::from_str(json).map_err(|e| Error::Serialization(e.to_string()))
    }
}

/// Synchronous client (always available)
pub struct Client {
    endpoint: String,
}

impl Client {
    pub fn new(endpoint: impl Into<String>) -> Self {
        Client {
            endpoint: endpoint.into(),
        }
    }

    pub fn get(&self, path: &str) -> Result<Data, Error> {
        // Sync implementation
        let _ = (path, &self.endpoint);
        Ok(Data::new(1, "test"))
    }
}
// src/async_client.rs - Async client (feature-gated)
#![cfg(feature = "async")]

use crate::core::Data;
use crate::error::Error;

/// Async client for concurrent operations
///
/// This type is only available when the `async` feature is enabled.
///
/// # Example
///
///
rust,ignore

/// use mylib::AsyncClient;

///

/// #[tokio::main]

/// async fn main() -> Result<(), mylib::Error> {

/// let client = AsyncClient::new("https://api.example.com");

/// let data = client.get("/users/1").await?;

/// Ok(())

/// }

///

pub struct AsyncClient {
    endpoint: String,
    #[cfg(feature = "native-tls")]
    tls: native_tls::TlsConnector,
    #[cfg(feature = "rustls")]
    tls: rustls::ClientConfig,
}

impl AsyncClient {
    pub fn new(endpoint: impl Into<String>) -> Self {
        AsyncClient {
            endpoint: endpoint.into(),
            #[cfg(feature = "native-tls")]
            tls: native_tls::TlsConnector::new().unwrap(),
            #[cfg(feature = "rustls")]
            tls: rustls::ClientConfig::builder()
                .with_safe_defaults()
                .with_native_roots()
                .with_no_client_auth(),
        }
    }

    pub async fn get(&self, path: &str) -> Result<Data, Error> {
        // Would use tokio for async HTTP
        let _ = path;
        tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
        Ok(Data::new(1, "async result"))
    }

    pub async fn post(&self, path: &str, data: &Data) -> Result<Data, Error> {
        let _ = (path, data);
        Ok(Data::new(2, "posted"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_async_get() {
        let client = AsyncClient::new("https://example.com");
        let result = client.get("/test").await;
        assert!(result.is_ok());
    }
}

// src/database.rs - Database support with multiple backends
#![cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]

use crate::core::Data;
use crate::error::Error;

/// Database connection abstraction
pub struct Database {
    #[cfg(feature = "postgres")]
    pool: sqlx::PgPool,
    #[cfg(feature = "mysql")]
    pool: sqlx::MySqlPool,
    #[cfg(feature = "sqlite")]
    pool: sqlx::SqlitePool,
}

impl Database {
    /// Connect to PostgreSQL
    #[cfg(feature = "postgres")]
    pub async fn connect_postgres(url: &str) -> Result<Self, Error> {
        let pool = sqlx::PgPool::connect(url)
            .await
            .map_err(|e| Error::Database(e.to_string()))?;
        Ok(Database { pool })
    }

    /// Connect to MySQL
    #[cfg(feature = "mysql")]
    pub async fn connect_mysql(url: &str) -> Result<Self, Error> {
        let pool = sqlx::MySqlPool::connect(url)
            .await
            .map_err(|e| Error::Database(e.to_string()))?;
        Ok(Database { pool })
    }

    /// Connect to SQLite
    #[cfg(feature = "sqlite")]
    pub async fn connect_sqlite(url: &str) -> Result<Self, Error> {
        let pool = sqlx::SqlitePool::connect(url)
            .await
            .map_err(|e| Error::Database(e.to_string()))?;
        Ok(Database { pool })
    }

    pub async fn get(&self, id: u64) -> Result<Data, Error> {
        // Query implementation
        let _ = id;
        Ok(Data::new(id, "from db"))
    }
}
// src/error.rs - Error type adapts to features
/// Error type for the library
#[derive(Debug)]
pub enum Error {
    /// Generic error
    Generic(String),

    /// Serialization error (only with serde feature)
    #[cfg(feature = "serde")]
    Serialization(String),

    /// Database error (only with database features)
    #[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
    Database(String),

    /// Async error (only with async feature)
    #[cfg(feature = "async")]
    Async(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::Generic(msg) => write!(f, "{}", msg),
            #[cfg(feature = "serde")]
            Error::Serialization(msg) => write!(f, "serialization error: {}", msg),
            #[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
            Error::Database(msg) => write!(f, "database error: {}", msg),
            #[cfg(feature = "async")]
            Error::Async(msg) => write!(f, "async error: {}", msg),
        }
    }
}

impl std::error::Error for Error {}
// tests/feature_tests.rs - Testing features
//! Integration tests that verify feature combinations

#[test]
fn test_core_always_available() {
    use mylib::Client;
    let client = Client::new("https://example.com");
    assert!(client.get("/test").is_ok());
}

#[cfg(feature = "serde")]
#[test]
fn test_serde_serialization() {
    use mylib::Data;

    let data = Data::new(1, "test");
    let json = data.to_json().unwrap();
    let restored: Data = Data::from_json(&json).unwrap();
    assert_eq!(data.id, restored.id);
}

#[cfg(feature = "async")]
#[tokio::test]
async fn test_async_client() {
    use mylib::AsyncClient;

    let client = AsyncClient::new("https://example.com");
    let result = client.get("/test").await;
    assert!(result.is_ok());
}

#[cfg(all(feature = "async", feature = "postgres"))]
#[tokio::test]
async fn test_postgres_integration() {
    // Only runs when both features enabled
}

Why This Works

  1. Compile-time selection: No runtime overhead
  2. Dependency control: Only include what's needed
  3. Clear API: Feature-gated items documented
  4. Additive design: Features don't break each other

Feature Design Principles

| Principle | Example |

|-----------|---------|

| Additive | Features add functionality, never remove |

| Minimal default | Only essential features enabled by default |

| Documented | Each feature explains what it enables |

| Testable | CI tests common feature combinations |

⚠️ Common Feature Patterns

# Runtime selection
async-tokio = ["tokio"]
async-std = ["async-std"]

# TLS backend selection
native-tls = ["dep:native-tls"]
rustls-tls = ["dep:rustls"]

# no_std support
std = []
alloc = []

# Debugging levels
debug = ["tracing/max_level_debug"]

# Full feature set
full = ["std", "async-tokio", "rustls-tls", "serde"]

⚠️ Anti-patterns

# DON'T: Mutually exclusive features that break
[features]
backend-a = ["dep:a"]
backend-b = ["dep:b"]
# What happens if both enabled?

# DO: Document exclusivity or handle both
[features]
backend-a = ["dep:a"]
backend-b = ["dep:b"]
# Docs: "Enable only one backend"
# Or handle both in code with priority
// DON'T: Feature that removes functionality
#[cfg(not(feature = "minimal"))]
pub fn advanced_feature() { }

// DO: Feature that adds functionality
#[cfg(feature = "advanced")]
pub fn advanced_feature() { }

Exercises

  1. Add a no_std feature that removes std dependency
  2. Create mutually exclusive feature groups with compile-time errors
  3. Add feature-gated benchmarks
  4. Set up CI to test all feature combinations

🎮 Try it Yourself

🎮

Feature Flags - Playground

Run this code in the official Rust Playground