The Cargo Guide
Advanced Feature Design
Why Advanced Feature Design Matters
Once a crate has more than a few simple optional capabilities, feature design becomes part of package architecture rather than just manifest syntax. At that point, you are no longer asking only whether a feature can be turned on. You are asking whether the feature system remains predictable across a workspace, across transitive dependency graphs, across platforms, and across team workflows.
A useful mental model is:
- beginner feature design asks "how do I make this optional?"
- advanced feature design asks "what kind of capability surface will still make sense after the crate grows?"
Feature Unification as the Central Constraint
The most important advanced idea is that Cargo features are generally unified across the resolved graph. If different parts of the graph request different features on the same crate instance, the effective build may end up with the union of those features.
That means features should usually be designed so that enabling more of them is still coherent.
A dangerous mental model is:
- feature A means one exclusive mode
- feature B means a different exclusive mode
A healthier model is:
- feature A adds one capability
- feature B adds another capability
- enabling both still produces a meaningful crate
A Small Example of Why Unification Changes Design
Suppose a crate starts with this manifest:
[features]
json_backend = []
xml_backend = []And then code like this:
#[cfg(feature = "json_backend")]
pub fn backend_name() -> &'static str {
"json"
}
#[cfg(feature = "xml_backend")]
pub fn backend_name() -> &'static str {
"xml"
}This looks fine only if exactly one feature is ever enabled. But Cargo feature unification makes it entirely possible for both features to be enabled together somewhere in the graph. At that point, the design is fragile.
A more durable design would expose additive capabilities instead:
#[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
}Feature Unification Across a Workspace
Feature unification becomes more visible in workspaces because multiple packages may depend on the same shared crate in different ways.
Example workspace root:
[workspace]
members = ["app", "core", "tools"]
resolver = "3"Suppose app enables one feature of a shared dependency and tools enables another. If both packages are selected in one build context, the shared dependency may be compiled with the unified feature set.
This creates a practical design pressure: feature boundaries that feel tidy within one crate can become messy once many workspace members depend on the same crate with different needs.
A Workspace Example with Shared Dependency Pressure
Imagine this structure:
my_workspace/
├── Cargo.toml
├── app/
│ ├── Cargo.toml
│ └── src/main.rs
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
└── tools/
├── Cargo.toml
└── src/main.rscore/Cargo.toml:
[package]
name = "core"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
tracing = { version = "0.1", optional = true }
[features]
serde_support = ["dep:serde"]
tracing_support = ["dep:tracing"]app/Cargo.toml:
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core", features = ["serde_support"] }tools/Cargo.toml:
[package]
name = "tools"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core", features = ["tracing_support"] }Now the workspace may compile core with both features enabled in some build contexts. This is not necessarily a problem, but it means the core crate must be designed to make that combination sensible.
Resolver Context and Why It Matters
Advanced feature design is shaped by the fact that feature activation is a property of the resolved build graph, not only of one local manifest. That means a feature plan that feels fine inside one crate may behave differently once:
- the crate is part of a workspace
- tests bring in dev dependencies
- examples and tools depend on the same shared crate
- multiple crates request different features on the same dependency
This is why experienced Cargo users think about feature design as graph design, not just manifest decoration.
Platform Gating vs Feature Gating
Another advanced design decision is choosing between platform gating and feature gating.
Feature gating answers a question like:
- should this capability be optional for users?
Platform gating answers a question like:
- should this dependency or code path exist only on specific targets?
A platform-specific dependency looks like this:
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[target.'cfg(unix)'.dependencies]
libc = "0.2"A feature-gated dependency looks like this:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]These are not interchangeable. Platform gating is about target environment. Feature gating is about capability selection.
When Platform Gating Is the Right Tool
Use platform gating when code or dependencies only make sense on a particular target family.
Example:
#[cfg(windows)]
pub fn platform_name() -> &'static str {
"windows"
}
#[cfg(unix)]
pub fn platform_name() -> &'static str {
"unix"
}This should not usually be modeled as user-facing crate features because the user is not deciding the platform. The target triple is.
When Feature Gating Is the Right Tool
Use feature gating when the capability is conceptually optional across the same target.
Example:
#[cfg(feature = "json")]
pub fn format_name() -> &'static str {
"json"
}
#[cfg(not(feature = "json"))]
pub fn format_name() -> &'static str {
"plain"
}This is a user-visible capability choice, not an environment constraint.
Combining Platform and Feature Gating Carefully
Sometimes a capability is both optional and platform-specific. In that case, it can be appropriate to combine both dimensions.
Example manifest:
[dependencies]
tracing = { version = "0.1", optional = true }
[features]
tracing_support = ["dep:tracing"]
[target.'cfg(unix)'.dependencies]
libc = "0.2"Example code:
#[cfg(all(unix, feature = "tracing_support"))]
pub fn enable_unix_tracing() {
let _ = libc::getpid();
tracing::info!("unix tracing enabled");
}The important design discipline is to keep the two axes conceptually separate even if they intersect in implementation.
Private and Internal Features
As a crate grows, it is tempting to add many tiny features for internal control. But every public feature name becomes part of the crate's long-term surface area.
A common design goal is to keep user-facing features small and meaningful, while avoiding accidental exposure of purely internal toggles.
An unhealthy surface might look like this:
[features]
fast_path_v2 = []
parser_rewrite = []
new_stack = []
internal_opt = []
alt_codegen = []These names are hard for users to reason about because they describe implementation churn rather than stable capability.
A healthier surface would instead emphasize capability-oriented features like:
[features]
json = []
xml = []
serde_support = ["dep:serde"]
tracing_support = ["dep:tracing"]The Idea of Feature Leakage
Feature leakage happens when internal implementation structure becomes visible as part of the public feature API, or when a dependency's feature mechanics effectively become part of your crate's public surface without clear design intent.
For example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
flate2 = { version = "1", optional = true }
[features]
serde = ["dep:serde"]
zlib_backend = ["dep:flate2"]This is not automatically wrong, but it can be a form of leakage if users are forced to think in terms of your exact dependency stack rather than your crate's own capability model.
A more intentional design might be:
[features]
serialization = ["dep:serde"]
compression = ["dep:flate2"]Capability-Based Feature Design
Capability-based design means features represent user-meaningful abilities rather than internal implementation decisions.
Example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
tracing = { version = "0.1", optional = true }
[features]
default = ["text"]
text = []
json = []
serialization = ["dep:serde"]
observability = ["dep:tracing"]This is often easier to understand than feature names like serde, derive, tracing, or log_backend, because the names describe capabilities the user can reason about.
A Capability-Based Example in Code
Manifest:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
tracing = { version = "0.1", optional = true }
[features]
default = ["text"]
text = []
json = []
serialization = ["dep:serde"]
observability = ["dep:tracing"]Rust code:
#[cfg(feature = "serialization")]
use serde::Serialize;
#[cfg_attr(feature = "serialization", derive(Serialize))]
pub struct Report {
pub title: String
}
#[cfg(feature = "observability")]
pub fn trace_load() {
tracing::info!("loading report");
}This design tells users what the crate can do, not just how it happens to be built.
Keeping Compile Matrices Tractable
As features multiply, the number of possible build combinations can grow very quickly. This is the feature compile matrix problem.
For example, even a small crate with these features:
[features]
text = []
json = []
xml = []
serialization = ["dep:serde"]
observability = ["dep:tracing"]already has many possible combinations.
You usually do not want to test every possible subset forever unless the crate is small and the matrix is still manageable. Advanced feature design therefore tries to keep the space of meaningful combinations under control.
Ways to Keep the Matrix Manageable
A few design choices help keep the matrix tractable.
First, keep the public feature count modest.
Second, prefer additive features over many overlapping mode switches.
Third, make defaults represent the main supported path.
Fourth, identify a few high-value combinations to test deliberately.
A practical test set might look like this:
cargo test
cargo test --no-default-features
cargo test --features json
cargo test --features "json serialization"
cargo test --all-featuresThis is often more realistic than trying to exhaustively test every possible combination.
Grouping Features into Meaningful Layers
A scalable pattern is to define a few top-level capability features and let them enable lower-level dependencies.
Example:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1", optional = true }
[features]
default = ["text"]
text = []
json = ["dep:serde_json", "serialization"]
serialization = ["dep:serde"]
observability = ["dep:tracing"]Now json is not just a raw dependency switch. It becomes a top-level capability that can pull in the lower-level pieces it needs.
Designing for Workspace Consumers
In a workspace, features are often consumed by more than one crate. That means feature names and defaults should make sense for multiple audiences, not just for the crate author's immediate use case.
A shared library crate used by binaries, tools, examples, and tests should usually:
- keep defaults modest
- keep capabilities additive
- avoid hidden assumptions that only one caller shape exists
- document which feature combinations are intended to be stable
This is especially important when one workspace member uses the crate as a heavy integration point and another uses it as a lightweight utility.
Documentation Strategy for Features
Feature systems only scale if users can understand them. That means feature documentation is not optional once a crate has a nontrivial feature surface.
A useful documentation strategy is to explain:
- what each feature enables conceptually
- whether it is in the default set
- whether it enables optional dependencies
- whether it is intended for ordinary users or only niche integrations
- which feature combinations are especially common or supported
A simple README section might look like this:
[features]
default = ["text"]
text = []
json = ["dep:serde_json", "serialization"]
serialization = ["dep:serde"]
observability = ["dep:tracing"]And then explain in prose:
text: plain text support, enabled by defaultjson: JSON formatting supportserialization: derive serialization traits for exposed data typesobservability: enable tracing hooks for diagnostics
Documenting Defaults and Non-Defaults Clearly
One of the most common points of confusion is whether a feature is enabled by default.
That means docs should state clearly:
- which features are part of
default - what changes when
--no-default-featuresis used - which feature combinations are most likely to be used in practice
For example, a user should not have to guess whether json is available in a normal build or whether it requires:
cargo build --features jsonDocumenting Feature Combinations with Commands
It is often helpful to document real command examples.
For example:
cargo build
cargo build --features json
cargo build --features "json serialization"
cargo build --no-default-features --features observability
cargo test --all-featuresThis makes the intended feature surface more concrete for users and contributors.
A Full Advanced Example
Suppose you have a reusable reporting crate in a workspace.
Manifest:
[package]
name = "report_core"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1", optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
default = ["text"]
text = []
json = ["dep:serde_json", "serialization"]
serialization = ["dep:serde"]
observability = ["dep:tracing"]Rust code:
#[cfg(feature = "serialization")]
use serde::Serialize;
#[cfg_attr(feature = "serialization", 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";
}
}
#[cfg(feature = "observability")]
pub fn trace_load() {
tracing::info!("loading report");
}
#[cfg(all(unix, feature = "observability"))]
pub fn unix_observability_hint() -> i32 {
libc::getpid() as i32
}Useful commands:
cargo build
cargo build --features json
cargo build --features "json serialization"
cargo build --no-default-features --features observability
cargo test --all-featuresThis example combines additive features, optional dependencies, capability-based naming, platform-specific dependencies, and a manageable top-level feature surface.
Common Advanced Feature Smells
Some common warning signs are:
- features are named after temporary implementation experiments
- defaults pull in nearly every optional subsystem
- two features only make sense if one of them is off
- users must understand your dependency internals to use the crate correctly
- the supported feature combinations are unclear
- the test matrix is so large that nobody knows what is actually covered
These are usually signals to simplify the feature surface, not to add even more switches.
Hands-On Exercise
Create a crate with a small but realistic advanced feature surface.
Start here:
cargo new advanced_feature_lab --lib
cd advanced_feature_labEdit Cargo.toml:
[package]
name = "advanced_feature_lab"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1", optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
default = ["text"]
text = []
json = ["dep:serde_json", "serialization"]
serialization = ["dep:serde"]
observability = ["dep:tracing"]Add code:
#[cfg(feature = "serialization")]
use serde::Serialize;
#[cfg_attr(feature = "serialization", derive(Serialize))]
pub struct Item {
pub name: String
}
pub fn output_mode() -> &'static str {
#[cfg(feature = "json")]
{
return "json";
}
#[cfg(not(feature = "json"))]
{
return "text";
}
}
#[cfg(feature = "observability")]
pub fn observe() {
tracing::info!("observing");
}Then test a few intentional combinations:
cargo test
cargo test --no-default-features
cargo test --features json
cargo test --features "json serialization"
cargo test --all-featuresThis exercise helps connect feature architecture, platform gating, and test matrix discipline in one place.
Mental Model Summary
A strong mental model for advanced feature design in Cargo is:
- feature design happens in the context of graph-wide unification, not only local manifests
- workspace usage makes feature choices more visible and more consequential
- platform gating and feature gating solve different problems and should not be conflated
- private or internal implementation detail should not automatically become public feature API
- capability-based feature names usually scale better than dependency-shaped or implementation-shaped names
- feature leakage makes crates harder for users to reason about
- the compile matrix should be managed intentionally rather than allowed to explode combinatorially
- documentation is part of feature design, not an afterthought
Once this model is stable, features become a tool for designing a durable package surface instead of a growing pile of conditional switches.
