Rust projects often depend on external crates to handle tasks such as serialization, HTTP communication, database access, and logging. However, not every application needs every capability provided by a crate. Some applications may require JSON support, while others need asynchronous networking, optional integrations, or additional runtime features.
Enabling every available capability can increase compile time, introduce unnecessary dependencies, and make dependency management more complicated.
Rust Cargo features provide a way to control which optional capabilities are enabled during compilation. They allow crate authors to expose configurable functionality and let application developers select the features their projects require.
For example, a library might support JSON and YAML serialization, but an application may need only JSON. With appropriately designed features, the application can enable JSON support without enabling YAML support.
Cargo features also influence dependency resolution. When multiple dependencies use the same crate with different feature requirements, Cargo generally combines the requested features for that package within the resolved dependency graph. As a result, a feature enabled by one dependency can affect how the crate is compiled elsewhere in the application.
Understanding this behavior is important when building reusable libraries, optimizing compile times, managing optional dependencies, and diagnosing unexpected build behavior.
In this article, you will learn how Cargo features work, how to define and enable them, how optional dependencies interact with features, and how to test different feature combinations reliably.
What Are Cargo Features in Rust?
Cargo features are named configuration options defined in a crate's Cargo.toml file.
A feature can enable conditional compilation, activate optional dependencies, or enable functionality in another dependency.
Consider a library that supports two output formats:
Not every application needs both formats. The library can define separate features so consumers can select the required functionality.
A simple feature configuration looks like this:
[features]
default = ["json"]
json = []
yaml = []
This configuration defines three features:
default enables the json feature by default.
json represents JSON support.
yaml represents YAML support.
The empty arrays indicate that these features do not directly enable other features or dependencies through their declarations.
The feature names themselves do not implement any functionality. Rust source code must use conditional compilation or other appropriate mechanisms to make the features change the build.
For example:
#[cfg(feature = "json")]
pub fn serialize_json() {
println!("JSON serialization is enabled");
}
#[cfg(feature = "yaml")]
pub fn serialize_yaml() {
println!("YAML serialization is enabled");
}
The #[cfg(feature = "...")] attribute tells the compiler to include the corresponding item only when that feature is enabled.
If the json feature is active, the serialize_json function is compiled. If the yaml feature is inactive, the serialize_yaml function is excluded from the build.
This is different from checking a Boolean value at runtime. Conditional compilation determines which code is compiled in the first place.
How Cargo Features Affect Compilation
Cargo reads the feature configuration from Cargo.toml and resolves the features requested by the project and its dependencies.
When you build a package, Cargo determines which features are active and passes the corresponding configuration to the compiler.
For example, the following command builds a package using its default features:
cargo build
To disable the package's default features, use:
cargo build --no-default-features
To enable a specific feature, use:
cargo build --features yaml
To enable multiple features, separate their names with commas:
cargo build --features "json,yaml"
These commands demonstrate the basic feature-selection mechanism. The actual result depends on the feature declarations in the package and on the requirements of its dependencies.
For a library consumer, features can be selected in the dependency declaration. For example:
[dependencies]
data-library = {
version = "1.0",
default-features = false,
features = ["json"]
}
In this example, the application disables the dependency's default features and explicitly enables its json feature.
This configuration is useful when the library's default feature set includes functionality that the application does not require.
However, disabling default features does not guarantee that every optional dependency is removed from the final dependency graph. Another dependency may enable the same feature, or the library may have mandatory dependencies that remain necessary.
Create a Rust Project to Test Features
Let's create a small Rust library that demonstrates conditional compilation.
Open a terminal and create a library project:
cargo new feature-demo --lib
cd feature-demo
Cargo generates the following basic structure:
feature-demo/
├── Cargo.toml
└── src/
└── lib.rs
Open Cargo.toml and configure the package features:
[package]
name = "feature-demo"
version = "0.1.0"
edition = "2024"
[features]
default = ["json"]
json = []
yaml = []
The example uses Rust edition 2024. If your project targets another edition, retain the edition that matches your compatibility requirements.
Next, replace the contents of src/lib.rs with the following code:
#[cfg(feature = "json")]
pub fn output_json() -> &'static str {
"JSON support is enabled"
}
#[cfg(feature = "yaml")]
pub fn output_yaml() -> &'static str {
"YAML support is enabled"
}
pub fn enabled_formats() -> Vec<&'static str> {
let mut formats = Vec::new();
#[cfg(feature = "json")]
formats.push("JSON");
#[cfg(feature = "yaml")]
formats.push("YAML");
formats
}
The library exposes a function for each format and a function that reports which formats are compiled into the library.
When the default features are enabled, JSON support is available.
Build the library:
cargo build
The build includes the code associated with the json feature because it is enabled by default.
Now disable the default features:
cargo build --no-default-features
In this configuration, neither JSON nor YAML is enabled. The enabled_formats function remains available, but its returned vector is empty.
Enable YAML explicitly:
cargo build --no-default-features --features yaml
Now the build includes the YAML-related code but excludes the JSON-related code.
Finally, enable both formats:
cargo build --features "json,yaml"
Both conditional functions are available in the resulting library.
This example demonstrates how feature selection changes the code included in a build.
Use Optional Dependencies with Cargo Features
Cargo features become particularly useful when a capability requires an additional crate.
Suppose a library supports JSON serialization using serde and serde_json. Applications that do not need JSON should be able to use the library without requiring those optional dependencies.
The following configuration defines an optional JSON capability:
[package]
name = "format-library"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = {
version = "1",
optional = true,
features = ["derive"]
}
serde_json = {
version = "1",
optional = true
}
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
The optional = true setting prevents these dependencies from being activated solely because they appear in the dependency table.
The json feature uses the dep: syntax to enable both dependencies when JSON support is requested.
The default feature list enables JSON automatically. Consumers can disable that default if they want a build without JSON support.
Implement JSON Serialization
Add the following code to src/lib.rs:
#[cfg(feature = "json")]
use serde::Serialize;
#[cfg(feature = "json")]
#[derive(Serialize)]
pub struct User {
pub id: u64,
pub name: String,
}
#[cfg(feature = "json")]
pub fn serialize_user(user: &User) -> Result<String, serde_json::Error> {
serde_json::to_string(user)
}
The #[cfg(feature = "json")] attributes ensure that the JSON-specific imports, type, and function are compiled only when the feature is active.
When the json feature is enabled, serde provides the serialization derive macro, while serde_json performs the JSON conversion.
When the feature is disabled, the library does not compile this JSON-specific code.
You can verify both configurations:
cargo check
cargo check --no-default-features
The first command checks the default configuration, which enables JSON. The second checks the package without its default features.
The optional dependencies should not be activated by this feature configuration when JSON is disabled, unless something else in the resolved dependency graph independently requires them.
Understand the Difference Between Default and Optional Features
Default features are enabled automatically when a dependency is used without disabling them.
For example:
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
yaml = []
With this configuration, a consumer that declares the dependency as follows gets JSON support by default:
[dependencies]
format-library = "0.1"
A consumer that does not need the default functionality can declare:
[dependencies]
format-library = {
version = "0.1",
default-features = false
}
The second declaration disables the dependency's default feature set.
This is particularly useful for libraries because it gives consumers control over optional functionality.
However, default features should be chosen carefully. If a feature is enabled by default, downstream applications may inherit its dependency and compilation costs even when they do not explicitly request that capability.
Configuration | Behavior |
|---|
Default features enabled | Activates the package's declared default feature set |
default-features = false
| Prevents the dependency's default features from being enabled by that dependency declaration |
Explicit feature selection | Enables the named features |
Optional dependency | Becomes active when enabled through the relevant dependency or feature configuration |
Mandatory dependency | Remains part of the package's dependency requirements |
Disabling default features does not disable mandatory dependencies or features activated by other dependency declarations.
Understand Feature Unification
One of the most important details about Cargo features is feature unification.
Within a resolved dependency graph, Cargo generally combines the features requested for the same package version and target context. If two dependencies require different features from the same crate, Cargo may compile that crate with both features enabled.
Consider this dependency configuration:
[dependencies]
format-library = {
version = "1.0",
default-features = false,
features = ["json"]
}
report-library = "1.0"
Suppose report-library also depends on format-library and enables its yaml feature.
The resolved build may include both json and yaml features for the shared format-library dependency.
The direct dependency's default-features = false setting does not globally prohibit another dependency from enabling features.
This behavior is important because developers sometimes assume that disabling default features guarantees a minimal build.
Feature unification means that the active feature set depends on the entire resolved dependency graph, not just one dependency declaration.
Inspect Active Features
To investigate feature selection, run:
cargo tree -e features
This displays dependencies along with feature information.
To inspect why a particular dependency is present, use:
cargo tree -i serde
Replace serde with the package you want to investigate.
These commands help identify which dependencies request particular packages and features.
For more focused output, Cargo also supports filtering and feature-specific options through cargo tree. The exact output depends on the selected package and dependency graph.
Use Cargo Features in Application Code
A binary application can also define features to control optional integrations or behavior.
For example, a command-line application might support an optional diagnostic mode.
In Cargo.toml:
[package]
name = "diagnostic-app"
version = "0.1.0"
edition = "2024"
[features]
default = []
diagnostics = []
In src/main.rs:
fn main() {
println!("Application started");
#[cfg(feature = "diagnostics")]
print_diagnostics();
}
#[cfg(feature = "diagnostics")]
fn print_diagnostics() {
println!("Diagnostic information is enabled");
}
Build the application without diagnostics:
cargo build
Enable diagnostics explicitly:
cargo build --features diagnostics
The second build includes the print_diagnostics function and its call.
This pattern can be useful for optional diagnostic output, development-only capabilities, or integrations that should be included only in selected builds.
However, Cargo features are compile-time configuration, not runtime switches. If users need to enable a capability while the application is running, use an appropriate runtime configuration mechanism instead.
Test Different Feature Combinations
Testing only the default configuration can leave feature-specific bugs undiscovered.
A library may compile successfully with default features but fail when those features are disabled. It may also fail when two optional features are enabled together.
For a package with the features defined earlier, run:
cargo test
This tests the default feature configuration.
Next, test without default features:
cargo test --no-default-features
Test each optional feature separately:
cargo test --no-default-features --features json
cargo test --no-default-features --features yaml
Finally, test the combination:
cargo test --features "json,yaml"
These commands check whether the library builds and behaves correctly under different configurations.
For larger projects, testing every possible combination may become expensive. With many independent features, the number of combinations grows rapidly.
Instead, identify the configurations that matter most, including:
The default build.
A minimal build with default features disabled.
Each important optional integration.
Combinations of features that interact with one another.
Supported operating systems and target architectures.
For published libraries, feature combinations should be treated as part of the supported API surface.
Common Cargo Feature Mistakes
Assuming a Feature Exists Because a Dependency Is Installed
Adding a crate to Cargo.toml does not automatically make every feature-specific code path available.
The relevant feature must be declared and enabled, and the source code must use the appropriate conditional compilation attributes.
Verify the feature declaration and the active feature set when a function or module unexpectedly disappears during compilation.
Disabling Default Features Without Checking Other Dependencies
Setting default-features = false affects a particular dependency declaration. It does not necessarily prevent another dependency from activating the same features.
Use cargo tree -e features to inspect the complete resolved feature configuration.
Forgetting to Guard Optional Dependency Usage
If a dependency is optional, code that references it must be excluded when the corresponding feature is disabled.
For example, a function that uses serde_json should be conditionally compiled when serde_json is available only through an optional feature.
Otherwise, a no-default-features build may fail because the source code still references a dependency that is not enabled.
Using Features for Runtime Configuration
Features determine what is compiled. They are not intended to replace environment variables, configuration files, command-line arguments, or runtime settings.
If a user needs to switch behavior without rebuilding the application, use runtime configuration rather than a Cargo feature.
Enabling Every Feature Without Testing Interactions
Two features may introduce conflicting assumptions, duplicate integrations, or incompatible code paths.
A successful build with each feature individually does not guarantee that the combined configuration will work.
Test the feature combinations your library claims to support.
Changing Default Features Without Considering Compatibility
Changing the default feature set can alter dependency requirements and the functionality included by downstream applications.
Before changing defaults in a published library, evaluate the impact on existing consumers and document any behavior that changes.
Best Practices for Designing Cargo Features
Keep Feature Names Clear
Use names that describe the capability being enabled, such as json, tls, metrics, or database-postgres.
Clear names make dependency declarations easier to understand and help users choose the correct configuration.
Keep Default Features Reasonable
Enable functionality by default when it represents the expected experience for most consumers.
Avoid automatically enabling expensive or highly specialized integrations unless they are necessary for the package's normal behavior.
Use Optional Dependencies for Optional Capabilities
If a feature requires an external crate that users should not need in every build, mark that dependency as optional and connect it to the appropriate feature.
This can reduce unnecessary dependency activation for consumers who do not use the capability.
Keep Feature Dependencies Explicit
Use feature declarations to document which capabilities depend on other features or optional dependencies.
For example:
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
advanced-json = ["json"]
Here, advanced-json enables json, which in turn activates the two optional dependencies.
This creates a clear feature relationship and avoids repeating the same dependency list.
Avoid Excessive Feature Combinations
Every additional feature increases the number of configurations that may need testing.
Group tightly related capabilities where appropriate, and introduce separate features when consumers have a meaningful reason to select them independently.
Document Feature Behavior
Explain which features are enabled by default, which dependencies they activate, and what happens when they are disabled.
This is particularly important for libraries that support optional integrations, different runtime environments, or specialized build configurations.
Test Minimal and Full Builds
Include both minimal and fully featured builds in the development workflow.
For example:
cargo check --no-default-features
cargo test
cargo test --all-features
Use --all-features only when enabling every feature represents a valid supported configuration. Some projects intentionally define mutually incompatible features, in which case an all-features build may fail by design.
Advantages and Limitations of Cargo Features
Cargo features provide several benefits:
They allow libraries to expose optional capabilities.
They can prevent unnecessary optional dependencies from being activated.
They support conditional compilation at build time.
They help applications select integrations that match their requirements.
They make it possible to test minimal and fully featured configurations.
They provide a structured way to configure reusable crates.
However, there are limitations:
Feature unification can activate features that a particular dependency declaration did not request.
More features increase testing and maintenance complexity.
Disabling default features does not automatically remove mandatory dependencies.
Feature-specific code can become difficult to maintain if conditional compilation is spread throughout a project.
Feature combinations may introduce compatibility problems that are not visible in the default build.
These limitations can be managed through clear feature design, explicit dependency declarations, and automated testing.
Conclusion
Rust Cargo features provide a flexible mechanism for controlling which capabilities and optional dependencies are included in a build. They are particularly valuable for reusable libraries that support multiple integrations or need to accommodate applications with different requirements.
Start by defining clear features in Cargo.toml, use #[cfg(feature = "...")] to control feature-specific code, and mark dependencies as optional when they should be activated only for selected capabilities.
For reliable dependency management, remember that Cargo features are resolved across the dependency graph. A feature disabled in one dependency declaration may still be enabled elsewhere.
Finally, test the default build, minimal configurations, and important feature combinations. With a well-designed feature model, you can make Rust libraries more flexible without making their build behavior unpredictable.
Join the conversation! Your thoughts help the community grow.