Skip to content
advanced Phase 9 · Modules, Crates, and Cargo Workspaces

Feature Flags and Optional Dependencies

Gate optional functionality and compile-time configuration with features.

40m
0 problems
Topic Progress 0%

Declaring a Feature and Gating Code Behind cfg

A feature is a named, compile-time switch declared in the manifest and read in the code. The manifest side:

[dependencies]
serde = { version = "1.0", optional = true }
tokio = { version = "1", optional = true, features = ["net"] }

[features]
default = ["json"]           # what users get unless they say otherwise
json = ["dep:serde"]
async-runtime = ["dep:tokio"]
cli = []

The code side is a pair of cfg forms:

#[cfg(feature = "json")]           // item-level: compile this item only if enabled
pub mod json;

#[cfg(feature = "async-runtime")]
use tokio::net::TcpListener;       // imports are gated too

pub fn describe() -> String {
    if cfg!(feature = "cli") {      // expression-level: a bool at runtime, both branches compiled
        "cli build".into()
    } else {
        "library build".into()
    }
}

#[cfg(...)] removes the item before type checking — a gated module referencing an unavailable dependency produces no error when the feature is off, which is precisely how an optional tokio import survives a build with async-runtime disabled. cfg! is different: it evaluates to true/false as a plain expression, so both branches must compile — use it for cheap boolean divergence, not for code that references gated items.

The rules that keep features sane are worth stating because breaking them causes ecosystem-wide breakage:

  1. Features are additive and monotonic. Enabling more features must never change correct behavior beyond adding capability; if feature A and feature B cannot both be on, that is a defect — every crate in the graph enabling features sees the union (see the unification rule in the next chapter).
  2. default is a convenience, not a contract. Users may disable it, so code must compile with --no-default-features — that is why default = ["json"] lists features rather than hiding logic there.
  3. optional = true on a dependency does two things: the dependency is only compiled when enabled, and (unless you write dep: syntax explicitly) a feature of the same name appears for it. Writing dep:serde in the feature list declares "this feature uses the optional serde" without exposing an implicit serde feature name of its own.

Enabling, in the two directions that matter:

cargo build --features cli                # add a feature to your own crate's build
cargo build --no-default-features         # start from nothing
cargo build --no-default-features --features "json,cli"
cargo test  --all-features                # everything on — run this in CI

A detail that saves a whole class of confusion for crates with runnable targets: [[bin]] and [[example]] sections accept required-features, so a binary that only compiles with cli on is skipped (with a clear message) instead of failing when someone builds --no-default-features:

[[bin]]
name = "billing-cli"
path = "src/main.rs"
required-features = ["cli"]

Inside a dependency graph, features are turned on by dependents: [dependencies] mylib = { version = "1", features = ["json"] } opts your crate's users into mylib/json whenever they build you. And the same cfg(feature = ...) vocabulary gates platform, arch, and debug assertions (cfg(unix), cfg(target_arch = "aarch64), cfg(debug_assertions)) — features are one more predicate in the same system, which is why #[cfg(all(unix, feature = "async-runtime"))] composes naturally.

The interview framing: "how does an optional dependency work?" — declared optional = true, referenced as dep:name (or by the implicit feature of the same name) inside [features], gated in code with #[cfg(feature = "...")], enabled by dependents' features = [...], and removable by consumers with default-features = false.

default-features, Unification, and What cargo tree -e features Shows

As a consumer of a crate, you control which features are on with three manifest keys and one build flag:

[dependencies]
mylib = { version = "1", features = ["cli"] }           # defaults + cli
other  = { version = "2", default-features = false, features = ["std"] }  # minimal surface

default-features = false is the mechanism for a lean build: it strips the crate's default list so you take only what you declared. This matters when a default pulls in something heavy — a default async runtime, chrono's oldtime, a TLS backend — and your binary does not need it. The trade-off is real: you now own completeness. If std (or equivalent) is genuinely required by the crate's API you are using, turning it off yields confusing errors, which is why many crates document "keep default features on unless you know otherwise."

The rule that surprises everyone is unification: within one build, the feature set for a crate is the union of everything any dependent asked for. If your binary enables mylib/json and a library you depend on enables mylib/csv, the final build compiles mylib with both — features are not negotiated per-edge, they are merged per-crate-per-build. Two consequences:

  1. You cannot turn a feature off that someone upstream turned on. default-features = false affects only your edge of the graph; if another dependent enables the defaults, they are back on. "My --no-default-features didn't work" is almost always this.
  2. Enabling features costs everyone. Adding mylib/json in one leaf crate grows the compile for every consumer of mylib in that build — features are a shared resource, which is exactly why they must be additive.

Cargo's resolver version controls how aggressively merging happens. With resolver = "2" (the default for edition 2021+), features are not unified across different targets (a dev-only cfg(windows) dependency no longer leaks features into your Linux build) or into build/dev-dependencies unnecessarily — the classic symptom of resolver 1 was a #[cfg(feature = "std")]-free build still compiling with std because a test-only dependency asked for it.

The leak scenario, spelled out, because it is the one that wastes an afternoon: under resolver 1, cargo test pulls your criterion dev-dependency, which (transitively) enables serde/std; your library's build now has serde/std on even though cargo build --no-default-features correctly removed it — so a #[cfg(feature = "std")]-gated path compiles in tests but not in the shipped build, and the discrepancy surfaces as a test-only failure. Resolver 2 stops features from flowing through dev-dependencies and build-dependencies, which removes that entire class of surprise; a workspace that predates edition 2021 should set resolver = "2" at the root explicitly.

Diagnosing any of this is one command:

cargo tree -e features                  # the graph annotated with active features
cargo tree -i mylib -e features         # who enabled which of mylib's features
cargo build --no-default-features --features "std"   # verify the minimal configuration
cargo check --all-features              # verify the maximal one (CI should do both)

-e features prints edges labeled with the features they activate, so a stray std shows up as a path from the dependent that requested it. CI that runs --all-features and --no-default-features --features <minimal> catches both directions of bit rot: code that only compiles with everything on, and code that assumes defaults.

Optional Dependencies as Compile-Time Architecture

An optional dependency is where a feature stops being a switch inside your code and becomes a decision about what your crate is:

[dependencies]
serde = { version = "1.0", optional = true }
rand  = { version = "0.8", optional = true }

[features]
default = ["serde-support"]
serde-support = ["dep:serde"]     # explicit: this feature uses optional serde
fuzzing = ["dep:rand"]            # rand exists only in fuzz builds
#[cfg(feature = "serde-support")]
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
pub struct Record { pub id: u64, pub label: String }

cfg_attr is the quiet hero: it applies an attribute conditionally, so the derive exists only when the feature is on — you cannot write #[cfg] on an individual derive without gating the whole struct. The same mechanism powers the crate-level architecture switch shown below: #![cfg_attr(not(feature = "std"), no_std)] flips the whole crate into core-only mode without duplicating the manifest.

Three design rules separate maintainable feature sets from traps:

  1. Weak dependency features for optional upgrades. The syntax serde?/derive means "if I have serde enabled, also enable its derive feature" — without the ?, naming serde/derive would force-enable the optional serde itself, defeating optional = true. Weak features let you deepen integration with something a user may not have pulled in.
  2. Features must be independent switches, never modes. full = ["a", "b", "c"] (a convenience bundle) is fine; json = [] removing something csv adds is not — features may only add. Break that and unification from the previous chapter silently turns your "either/or" into "both," producing either compile errors or wrong behavior for someone else's build.
  3. Test the matrix, not just the corners. cargo test --all-features and cargo test --no-default-features are the two cheapest points; cargo hack check --feature-powerset (the cargo-hack tool) walks pairwise combinations on CI when features interact. A crate whose features are truly additive passes all three for free — that is the practical test of rule 1.

The most common architecture built from these parts is the std-optional pattern that libraries share with the embedded world:

[features]
default = ["std"]
std = []
#![cfg_attr(not(feature = "std"), no_std)]     // crate attribute, gated

#[cfg(feature = "std")]
pub fn read_config() -> std::io::Result<String> { std::fs::read_to_string("app.toml") }

#[cfg(not(feature = "std"))]
pub fn read_config() -> Result<String, ()> { Err(()) }   // the core-only fallback

With default-features = false the crate compiles against core only — no allocator, no filesystem — and the two read_config definitions coexist because exactly one of them is erased before type checking. #[cfg(not(feature = "..."))] is the complement you will use constantly: "everything except X," the shape needed whenever a default feature flips a definition rather than adding one.

Where this lands in a project: features are the API of a manifest, consumed by dependents you will never read, which is why cargo tree -e features in your own repo is as much a debugging tool for others' crates as for yours. You now have the full compile-time toolkit — cfg predicates, gated items, optional dependencies, and the unification semantics that bind them together — and the roadmap turns to the other half of compile-time metaprogramming: Macro Rules Basics, where syntax itself becomes something you define.

Quiz

1. What is the difference between `#[cfg(feature = "x")]` and `cfg!(feature = "x")`?

Question 1 options

2. Your crate builds `mylib` with `default-features = false`, but `mylib` still compiles with its default features. Most likely cause?

Question 2 options

3. What does `optional = true` on a dependency do?

Question 3 options

4. You want your feature `json` to enable `serde/derive` *only if* the user already enabled optional `serde`. Which syntax?

Question 4 options

Flashcards

Question

How do you declare a feature and gate code with it?

Answer

In `Cargo.toml`: `[features] default = ["json"]; json = ["dep:serde"]`. In code: `#[cfg(feature = "json")]` on items/imports/impls (removed before type checking) or `cfg!(feature = "json")` as a `bool` where both branches must compile.

Question

What does `default-features = false` do, and what can it not do?

Answer

It drops the *dependency's* `default` feature list on your edge of the graph, so you take only what you declared. It cannot revoke features another dependent enabled — feature sets are unified as a union per crate per build.

Question

What is feature unification, and which resolver setting improves it?

Answer

For one build, each crate compiles with the union of features requested by all its dependents — nobody can subtract. `resolver = "2"` (default since edition 2021) narrows merging so features don't leak across targets, build-dependencies, or dev-dependencies.

Question

What do `dep:name` and `dep?/feature` mean in a `[features]` entry?

Answer

`dep:name` — "this feature enables the optional dependency `name`" without exposing an implicit feature named after it. `dep?/feature` — a *weak* feature: enable that dependency's sub-feature only if the dependency is already enabled, never forcing `optional = true` crates on.

Question

How do you inspect and test a crate's feature matrix?

Answer

Inspect: `cargo tree -e features` (edges labeled with features they activate), `cargo tree -i <crate> -e features` for who enabled what. Test: `cargo check --all-features`, `cargo check --no-default-features --features <minimal>`, and `cargo hack check --feature-powerset` for combinations in CI.

Revision Notes

Key Takeaways

  • 1. Features are declared in the manifest and read with the same `cfg` vocabulary as platform predicates; `#[cfg]` erases items before type checking while `cfg!` is a bool whose both branches must compile
  • 2. `default-features = false` prunes your edge of the graph only — unification means a feature anyone enables is on for the whole build, so features must stay additive
  • 3. `optional = true` turns a dependency into a compile-time choice; `dep:name` avoids implicit feature aliases and `dep?/feature` extends a dependency's features without forcing it on
  • 4. `cfg_attr` is how optional derives and attributes attach without gating the entire item
  • 5. Debugging features is a command, not a guess: `cargo tree -e features` shows who enabled what, and a CI matrix over `--all-features` and `--no-default-features` catches both directions of drift

Interview Tips

  • • "\"How does an optional dependency work?\" — `optional = true`, `dep:` in `[features]`, `#[cfg(feature = ...)]` in code, dependents opt in with `features = [...]`, and `default-features = false` for the minimal surface"
  • • "\"Two crates enable different features of the same library — what gets built?\" — the union, because features unify per crate per build; add that nobody can subtract, which is why features must be additive and why `default-features = false` sometimes appears not to work"
  • • "\"`cfg` vs `cfg!`\" — item removal before type checking vs a compile-time bool requiring both branches to type check; give the gated-import example, since that is where the difference bites"
  • • "\"How would you test a crate with optional features?\" — `--all-features` plus `--no-default-features --features <minimal>` in CI, `cargo tree -e features` to explain surprising results, `cargo hack --feature-powerset` for combinations"

Cheat Sheet

Feature Flags and Optional Dependencies

Declare:

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

[features]
default = ["json"]
json = ["dep:serde"]          # explicit: uses optional serde
async = ["dep:tokio"]
cli = []                      # a pure code gate
full = ["json", "async"]      # bundle (additive only)

Gate:

#[cfg(feature = "json")] pub mod json;              // removed before type checking
#[cfg_attr(feature = "json", derive(Serialize))]    // conditional attribute
use tokio::net::TcpListener;   // must itself be under #[cfg(feature = "async")]
if cfg!(feature = "cli") { ... }                     // bool; both branches compile

Consume:

mylib  = { version = "1", features = ["cli"] }
lean   = { version = "2", default-features = false, features = ["std"] }
cargo build --no-default-features --features "json,cli"
cargo check --all-features                # CI: both extremes
cargo tree -e features                    # who enabled what

Rules: features are additive and monotonic (never modes); unification means the build gets the union of every dependent's request; weak features (serde?/derive) extend without forcing; resolver = "2" stops features leaking across targets/dev/build deps.

CI matrix: --all-features × --no-default-features --features <minimal> (add cargo hack --feature-powerset when features interact).

Continue Learning