When the default build gets too heavy
You are building a command-line tool that processes configuration files. Most users will just read YAML. A smaller group wants JSON support. You add serde to handle the deserialization. Now your binary takes four seconds longer to compile. The final executable is three megabytes heavier. Every single user pays that cost, even the ones who never type --format json. You want to ship a lean default build and let users opt into the heavy machinery only when they actually need it.
How optional dependencies actually work
Cargo solves this with conditional dependencies. You mark a dependency as optional, then tie it to a feature flag. A feature flag is a compile-time switch. When the switch is off, Cargo ignores the dependency entirely. It does not download it. It does not compile it. Your code that uses the dependency is also stripped out by the compiler. The result is a smaller binary, faster builds, and a cleaner dependency graph.
Think of it like a modular power strip. The base unit plugs into the wall. You only attach the surge protector or the USB hub when you actually need those ports. The rest of the time, they stay in the box. Rust does the same thing at compile time. The optional = true flag tells Cargo to treat the package as a detachable module rather than a permanent fixture.
Minimal setup
Here is the smallest working configuration. You declare the dependency with the optional flag. You create a feature that enables it. You guard the code that uses it.
[package]
name = "my-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
# Mark the dependency as optional so Cargo skips it by default
serde = { version = "1.0", optional = true }
[features]
# Create a named switch that activates the optional dependency
json-support = ["serde"]
/// Main entry point for the CLI tool
fn main() {
// Only compile this block if the json-support feature is enabled
#[cfg(feature = "json-support")]
{
println!("JSON serialization is available.");
}
// Compile this branch when the feature is explicitly disabled
#[cfg(not(feature = "json-support"))]
{
println!("Running in lightweight mode.");
}
}
Keep your default build lean. Make every extra megabyte earn its place.
What happens during compilation
Run cargo run and you get the lightweight message. Cargo never touches serde. Run cargo run --features json-support and the compiler pulls serde from crates.io, compiles it, and includes the first branch. The #[cfg(feature = "...")] attribute tells the Rust compiler to physically remove the code before it even tries to check types. This is why you do not get errors about missing imports when the feature is off. The code simply does not exist in that build.
Cargo resolves features before the Rust compiler sees your source files. It walks the dependency graph, collects every enabled feature, and passes a list of active flags to rustc. The compiler then evaluates every #[cfg] attribute. If the condition is false, the attribute and its attached item are deleted from the AST. If the condition is true, the item remains and undergoes normal type checking and optimization.
This two-phase process means you can safely write code that references types that might not exist. The compiler only checks the code that survives the #[cfg] filter. You get zero-cost abstractions without sacrificing type safety.
Realistic library pattern
Real projects rarely toggle a single print statement. You usually need to conditionally implement traits, change struct definitions, or swap entire modules. Here is how a library crate handles optional serialization while staying ergonomic for downstream users.
[package]
name = "data-model"
version = "0.1.0"
edition = "2021"
[dependencies]
# Optional dependency for serialization support
serde = { version = "1.0", features = ["derive"], optional = true }
[features]
# Enable the dependency and expose it to downstream crates
serde = ["dep:serde"]
default = []
/// Represents a user account in the system
#[derive(Debug, Clone)]
pub struct User {
pub id: u64,
pub name: String,
}
// Conditionally derive Serialize and Deserialize when the feature is active
#[cfg(feature = "serde")]
impl serde::Serialize for User {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Delegate to serde's derive macro behavior manually for clarity
serializer.serialize_struct("User", 2)
.and_then(|mut s| s.serialize_field("id", &self.id))
.and_then(|mut s| s.serialize_field("name", &self.name))
.map(|s| s.end())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for User {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
// Manual deserialization logic omitted for brevity
unimplemented!()
}
}
Notice the dep:serde syntax in the features table. Older tutorials show ["serde"] or ["dep:serde"]. The dep: prefix is the modern convention. It explicitly tells Cargo that serde refers to a dependency, not a local feature flag. This prevents naming collisions when your crate has a feature named serde and a dependency named serde. The community standardizes on dep: to keep feature resolution unambiguous.
Another convention worth adopting is default = []. Explicitly declaring an empty default feature list signals to users that your crate ships with zero optional capabilities enabled. It prevents accidental feature bloat when downstream crates enable default-features = true on your package.
Testing and feature propagation
Testing conditional code requires a different workflow. You cannot run cargo test and expect both branches to execute. You must run the test suite multiple times with different feature combinations.
# Test the default build without optional dependencies
cargo test
# Test with the serialization feature enabled
cargo test --features serde
# Test all possible feature combinations to catch edge cases
cargo test --all-features
Feature resolution is additive but strictly follows the dependency graph. If crate A depends on crate B with an optional dependency, and crate C depends on both, enabling the feature in crate C does not automatically enable it in crate B unless crate A explicitly forwards it. You cannot reach into a transitive dependency and flip its switches unless the intermediate crate exposes them. This design prevents silent dependency explosions and keeps build graphs predictable.
Use cargo tree --features serde to visualize which packages get pulled in. The output shows exactly which dependencies are active and which remain dormant. Run it before and after enabling a feature to verify you are not accidentally pulling in heavy transitive crates.
Common traps and compiler errors
The most common mistake is forgetting to guard the code that uses the optional crate. If you write use serde::Serialize; at the top of the file without a #[cfg] attribute, the compiler will reject the build when the feature is disabled. You will see E0432 (use of undeclared crate or module). The compiler cannot find serde because Cargo never downloaded it.
Another trap is assuming optional dependencies are automatically enabled for downstream users. If your library exposes a function that requires serde, you must document that the caller needs to enable the feature. Otherwise, their build fails with E0277 (trait bound not satisfied) or E0432. You can mitigate this by re-exporting the feature in your own Cargo.toml and clearly stating it in the documentation.
A third issue involves #[cfg_attr] misuse. Developers sometimes try to conditionally apply derive macros like this: #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]. This works, but it creates a subtle problem. The derive macro expands into code that references serde. If you forget to also conditionally import serde or guard the struct definition, you get E0433 (failed to resolve). The safer pattern is to conditionally implement the traits manually or use #[cfg(feature = "serde")] on the entire struct when the trait is the only reason the struct exists.
Keep your feature boundaries explicit. Guard every import, every type reference, and every function signature that touches an optional dependency.
When to reach for optional dependencies
Use optional dependencies when you want to reduce compile times and binary size for users who do not need a specific capability. Use regular dependencies when every user of your crate requires the package and the overhead is acceptable. Use dev-dependencies when the package is only needed for tests, benchmarks, or examples that do not ship with the final binary. Use feature flags without dependencies when you are toggling internal behavior, like enabling a debug logger or switching between two pure-Rust algorithms. Use the dep: prefix in your features table to avoid ambiguity between local feature names and dependency names. Use default = [] when you want to force downstream users to opt into every capability explicitly.
Treat your dependency graph like a menu. Let customers order exactly what they want, and keep the kitchen from prepping ingredients nobody asked for.