The Cargo Guide
Feature System Fundamentals
Why Cargo Features Exist
Cargo features let one package expose multiple capability sets without splitting into many separate crates. They are a way to make parts of a package optional, configurable, or layered.
A useful mental model is:
- dependencies choose what external crates you can use
- features choose which optional capabilities of your package are active
Features are often used for:
- optional integrations like
serde - switching on extra functionality
- reducing dependency footprint for users who do not need everything
- keeping one crate flexible across different environments
The [features] Table
Features are declared in the [features] table in Cargo.toml.
A small example:
[package]
name = "feature_demo"
version = "0.1.0"
edition = "2024"
[features]
default = ["text"]
text = []
json = []
xml = []In this example:
text,json, andxmlare feature namesdefaultis the set that is enabled unless the user opts out
A feature value is usually a list of things that enabling that feature should also enable.
Features as Capability Switches
A feature does not do anything by itself unless your crate uses it in code or ties it to dependencies.
For example:
[features]
json = []Then in Rust code:
#[cfg(feature = "json")]
pub fn output_format() -> &'static str {
"json"
}
#[cfg(not(feature = "json"))]
pub fn output_format() -> &'static str {
"plain"
}This is the basic pattern: declare a feature in the manifest, then use conditional compilation in code.
Optional Dependencies as Features
One of the most common Cargo patterns is making a dependency optional.
Example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }That means the dependency is not always enabled. It becomes part of the build only when activated through features.
A common manifest pattern is:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]This creates a user-facing feature named serde_support that enables the optional dependency serde.
The dep: Syntax
The dep: syntax explicitly refers to an optional dependency from the [dependencies] table.
Example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]This is useful because it separates two ideas clearly:
serde_supportis the public feature name of your crateserdeis the dependency being enabled
That makes feature design cleaner than exposing raw dependency names directly when you want a more intentional public feature surface.
Using an Optional Dependency in Code
Suppose you want a type to derive Serialize only when a feature is enabled.
Manifest:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]Rust code:
#[cfg(feature = "serde_support")]
use serde::Serialize;
#[cfg_attr(feature = "serde_support", derive(Serialize))]
pub struct User {
pub name: String
}This lets the same crate compile with or without serialization support.
Default Features
The special default feature defines which features are enabled when the user does not specify otherwise.
Example:
[features]
default = ["text", "serde_support"]
text = []
serde_support = ["dep:serde"]This means a normal build will enable both text and serde_support unless the user opts out.
Default features are powerful, but they should be used carefully because many users will get them automatically.
Disabling Default Features
Users can opt out of default features from the command line.
Example:
cargo build --no-default-featuresAnd they can opt out while enabling specific features explicitly:
cargo build --no-default-features --features jsonThis is important because it lets users choose a smaller or more specialized capability set than the crate's default configuration.
Enabling Features from the Command Line
Cargo provides several command-line switches for feature control.
Enable one or more named features:
cargo build --features json
cargo build --features "json serde_support"Enable every feature:
cargo build --all-featuresDisable defaults:
cargo build --no-default-featuresCombine disabling defaults with explicit features:
cargo test --no-default-features --features "xml"A useful mental model is:
--featuresadds named features--all-featuresturns everything on--no-default-featuresstarts from a blank feature baseline
Additive Feature Design
Cargo features work best when they are additive.
Additive means enabling a feature adds capability rather than changing meaning in a conflicting way.
Good additive example:
[features]
default = ["text"]
text = []
json = []
xml = []
serde_support = ["dep:serde"]Here, turning on json or xml adds support. It does not try to negate another feature's meaning.
Additive design scales well because feature unification across the dependency graph tends to combine features rather than pick one winner.
Why Additive Design Matters
Cargo's feature model is not designed around mutually exclusive switches. It is designed around accumulation.
If two parts of a build graph request different features on the same crate, Cargo may unify them so both are enabled together.
That means features should usually be designed so that enabling more of them is still meaningful and safe.
This is why additive features are the healthy default design.
Avoiding Mutually Exclusive Feature Traps
A common mistake is to design features as if they were exclusive modes.
Problematic pattern:
[features]
json_backend = []
xml_backend = []Then code assumes only one can ever be enabled:
#[cfg(feature = "json_backend")]
pub fn backend_name() -> &'static str {
"json"
}
#[cfg(feature = "xml_backend")]
pub fn backend_name() -> &'static str {
"xml"
}If both features become enabled together through graph unification, that design becomes awkward or incorrect.
A better approach is either:
- make both features safely additive, or
- move mode selection to runtime or to separate crates if the modes truly conflict
A Safer Alternative to Exclusive Modes
Instead of pretending only one feature can exist, design code that can handle both being present.
Example:
#[cfg(feature = "json_backend")]
pub fn supports_json() -> bool {
true
}
#[cfg(not(feature = "json_backend"))]
pub fn supports_json() -> bool {
false
}
#[cfg(feature = "xml_backend")]
pub fn supports_xml() -> bool {
true
}
#[cfg(not(feature = "xml_backend"))]
pub fn supports_xml() -> bool {
false
}Now both features can be enabled together without contradiction.
Feature Naming
Feature names are part of your crate's public interface. They should be chosen carefully.
Good feature names are usually:
- clear
- stable
- capability-oriented
- not overly tied to internal implementation details unless that detail is intentionally public
For example:
[features]
serde_support = ["dep:serde"]
cli = []
json = []
tracing = ["dep:tracing"]These names communicate capability. By contrast, names like feature1, alt_mode, or newstuff are much harder for users to reason about.
Dependency Names vs Public Feature Names
It is often better to expose feature names that describe user-visible capability rather than raw dependency names.
For example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]This is often clearer than making the public feature itself be named serde, especially if you want the feature to communicate meaning rather than implementation.
A user can then build with:
cargo build --features serde_supportA Small End-to-End Feature Example
Suppose you are building a report crate with optional JSON and serialization support.
Manifest:
[package]
name = "report_features"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
default = ["text"]
text = []
json = []
serde_support = ["dep:serde"]Rust code:
#[cfg(feature = "serde_support")]
use serde::Serialize;
#[cfg_attr(feature = "serde_support", derive(Serialize))]
pub struct Report {
pub title: String
}
pub fn output_mode() -> &'static str {
#[cfg(feature = "json")]
{
return "json";
}
#[cfg(not(feature = "json"))]
{
return "text";
}
}Commands:
cargo build
cargo build --features json
cargo build --features "json serde_support"
cargo build --no-default-features --features "json"Designing Feature Surfaces That Scale
A feature surface scales well when it remains understandable as the crate grows.
Good scaling principles include:
- keep features capability-based
- keep them additive
- avoid exposing too many purely internal toggles
- make defaults useful but not excessively heavy
- avoid a large number of tiny overlapping switches unless users genuinely need them
A small, well-shaped feature surface:
[features]
default = ["text"]
text = []
json = []
serde_support = ["dep:serde"]
tracing = ["dep:tracing"]This scales better than a long list of implementation-driven switches that users cannot easily reason about.
Keeping Defaults Modest
Default features should usually represent the most common and broadly useful experience, but they should not quietly pull in every optional subsystem.
Heavier default set:
[features]
default = ["json", "xml", "serde_support", "tracing", "cli"]More modest default set:
[features]
default = ["text"]
json = []
xml = []
serde_support = ["dep:serde"]
tracing = ["dep:tracing"]
cli = []The second design gives users more control and usually produces fewer surprises.
Features and Dependency Footprint
Feature design has direct consequences for dependency footprint.
For example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
tracing = { version = "0.1", optional = true }
[features]
serde_support = ["dep:serde"]
tracing = ["dep:tracing"]A user who does not enable either feature avoids those optional dependencies entirely.
This is one reason features are such an important Cargo design tool.
Testing Feature Combinations
If your crate supports multiple feature combinations, it is useful to test more than the default case.
Examples:
cargo test
cargo test --no-default-features
cargo test --features json
cargo test --features "json serde_support"
cargo test --all-featuresThis helps catch feature interactions early, especially if some code paths exist only under certain combinations.
A Common Anti-Pattern
A frequent anti-pattern is using features to represent mutually exclusive global modes that really should be modeled somewhere else.
For example:
[features]
postgres = []
sqlite = []
mysql = []If the crate assumes exactly one database backend can ever be enabled, feature unification can make the design brittle.
Safer alternatives may include:
- supporting multiple backends additively
- selecting the backend at runtime
- splitting backend-specific logic into separate crates
Hands-On Exercise
Create a small crate and add two additive features plus one optional dependency feature.
Start here:
cargo new feature_lab --lib
cd feature_labEdit Cargo.toml:
[package]
name = "feature_lab"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
default = ["text"]
text = []
json = []
serde_support = ["dep:serde"]Add code:
#[cfg(feature = "serde_support")]
use serde::Serialize;
#[cfg_attr(feature = "serde_support", derive(Serialize))]
pub struct Item {
pub name: String
}
pub fn mode() -> &'static str {
#[cfg(feature = "json")]
{
return "json";
}
#[cfg(not(feature = "json"))]
{
return "text";
}
}Now try these commands:
cargo build
cargo build --features json
cargo build --features "json serde_support"
cargo build --no-default-features --features json
cargo test --all-featuresThis exercise makes the relationship between manifest features and compiled code concrete.
Common Beginner Mistakes
Mistake 1: treating features as exclusive mode switches rather than additive capabilities.
Mistake 2: exposing too many tiny internal toggles as public features.
Mistake 3: making default features too heavy.
Mistake 4: forgetting that optional dependencies need to be enabled through the feature system.
Mistake 5: using dependency names as public feature names without thinking about long-term clarity.
Mistake 6: never testing non-default feature combinations.
Mental Model Summary
A strong mental model for Cargo feature fundamentals is:
[features]defines optional capability sets for a crate- optional dependencies are commonly activated through features
dep:explicitly refers to an optional dependency from a feature definitiondefaultdefines the features users get automatically--features,--all-features, and--no-default-featurescontrol feature activation from the command line- healthy feature systems are usually additive rather than mutually exclusive
- good feature names communicate capability clearly
- scalable feature surfaces stay understandable as the crate grows
Once this model is stable, Cargo features become much easier to design as part of API and package architecture rather than as ad hoc toggles.
