Cargo and Feature Flags: Rust's Dependency Maze
A Package Manager That Looks Friendly Until It Isn't
I'm rewriting the hot path of my Solana MEV bot in Rust, and the first wall I hit isn't the borrow checker. It's Cargo.toml. On paper, Cargo is the cleanest dependency manager I've used. One file, one lockfile, one command to build. Compared to the Python virtualenv-pip-poetry-pyproject-conda fan dance, this feels like switching from a 1998 minivan to a Tesla. Then I add reqwest, run cargo build, and stare at the screen for four minutes while OpenSSL, rustls, and a stray C compiler all show up to the party uninvited.
The thing nobody warns you about is that Cargo's clean surface hides a feature-flag system that behaves more like a global broadcast than a local switch. Every crate in your dependency graph gets a vote on what gets compiled. Most of the time the votes agree. When they don't, you end up with binaries that pull in two TLS implementations, build scripts that demand cmake, and a target/ directory that grows like a Costco bulk pack of paper towels. Today's notes are about learning to read that vote count before it decides for me.
What Cargo Actually Does With Features
A feature flag in Cargo is a named toggle declared in Cargo.toml under [features]. Each feature can pull in optional dependencies, enable specific code paths via #[cfg(feature = "...")], or chain into other features. That part is straightforward. The part that bends my brain is the resolution rule.
Per the official Cargo Book, Cargo computes the union of all enabled features across the entire dependency graph and builds each package exactly once with that combined set (The Cargo Book — Features). Read that twice. If crate foo asks for winapi with ["fileapi", "handleapi"] and crate bar asks for winapi with ["std", "winnt"], Cargo doesn't compile two winapi instances. It compiles one winapi with all four features turned on.
That rule is the source of every Cargo headache I've had this week, and the source of why Cargo can compile large workspaces fast enough to be usable. It's a tradeoff baked into the design.
The principle the Rust team enforces on top of this is that features must be additive (The Cargo Book — Features). A feature is allowed to add capabilities. It is not allowed to remove them. A feature called no_std that disables the standard library is an anti-pattern, because the moment a sibling crate quietly enables std, the whole graph gets std, and the supposed no_std build silently grows a dependency on the standard library. The recommended pattern flips it: default to #![no_std] and add a std feature that enables it.
This sounds academic until I realize my MEV bot is going to depend on a dozen DEX SDK crates, half of which I don't control. Every one of them gets a vote.
The TLS Story That Made It Click
The canonical Rust feature-flag horror story is TLS, and I run straight into it the first afternoon. I want reqwest for outbound HTTPS to a few price oracles. I add it to Cargo.toml with no fanfare:
reqwest = "0.12"
This pulls in native-tls on Linux, which means OpenSSL, which means a system dependency on libssl-dev. Fine on my laptop, annoying on a slim Docker image, painful on a cross-compiled target. So I switch to rustls, the pure-Rust TLS implementation:
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
Now my build is reproducible across any platform with a Rust toolchain. Sean McArthur, the reqwest maintainer, notes that a community survey showed roughly 93% of reqwest users were already using rustls before he made it the default in v0.13 (reqwest v0.13: rustls by default). I'm in the majority, apparently.
Then I add a second crate that internally uses reqwest with default-features = true. Cargo unions the features. My binary now compiles both native-tls and rustls. The disk usage doubles. Build time gets noticeably worse. Worse, because features are additive, no warning fires. The TLS pitfall is exactly this: you cannot "opt out" of a TLS backend if any crate downstream opts in. Per the reqwest maintainer's explicit warning, the only reliable fix is to call the backend selector at the builder level in code, not just in Cargo.toml (reqwest v0.13: rustls by default).
This is the moment Cargo features stop feeling like a config detail and start feeling like an ecosystem-wide negotiation I have to participate in.
The Six Tools I Live in Now
When cargo build finishes and I want to know what it actually built, the answer lives in cargo tree. I treat it like the Carfax of my dependency graph — before I commit to a crate, I'm pulling its history.
The basic command shows the full tree with feature annotations:
cargo tree -e features
A more compact view, which I keep open in a tmux pane:
cargo tree -f "{p} {f}"
The killer command is reverse tracing — given a crate, who in my graph is enabling its features?
cargo tree -e features -i reqwest
This is how I find the rogue transitive dependency that secretly turned on default-tls after I went out of my way to disable it. The Cargo Book's example output for this command shows a chain like syn feature "clone-impls" → syn feature "default" → rustversion → myproject, and that's exactly what mine looks like for TLS (cargo tree).
The other commands earn their keep less often but matter when they matter:
cargo tree --duplicates # find packages compiled at multiple versions
cargo tree --no-dedupe -e features # full unflattened tree for cross-reference
cargo tree -e build,features # show only build-time dependency features
--duplicates is the one I run when my binary mysteriously bloats. Two versions of the same crate is a signal that something in the graph is pinning an old version, and Cargo's unification stops at version boundaries. You can have tokio 1.35 and tokio 0.2 in the same binary, and they're effectively two different crates as far as feature unification is concerned.
Optional Dependencies, dep:, and the Namespace Problem
The second design decision I keep tripping over is that feature names share a namespace with dependency names. If I have an optional dependency called serde, Cargo automatically creates a feature called serde that enables it. That's convenient until I want a feature called serde that does more than just turn on the crate — say, also enables a serde derive in another crate.
Rust 1.60 introduced the dep: prefix to break this implicit coupling (The Cargo Book — Features). With dep:, an optional dependency is no longer auto-exposed as a feature:
[dependencies]
ravif = { version = "0.6.3", optional = true }
rgb = { version = "0.8.25", optional = true }
[features]
avif = ["dep:ravif", "dep:rgb"]
Now there is no ravif or rgb feature visible to the outside world. There's only an avif feature that happens to pull both in. This is the right pattern for any crate I plan to publish, because it locks the public feature surface to what I actually mean to expose.
The sibling addition in 1.60, ?/, is for conditional activation. Reading from the Cargo Book directly:
[features]
serde = ["dep:serde", "rgb?/serde"]
The rgb?/serde says: only turn on rgb's serde feature if rgb is already enabled elsewhere. Without the ?, writing rgb/serde would force-enable rgb itself, which is usually not what I want. This is the sort of operator that looks like punctuation noise the first time and becomes essential the third time you need it.
SemVer, Default Features, and How to Make Enemies
Default features are a trap I almost stepped in this morning. The rule is unambiguous in the Cargo Book: removing a feature from default is a SemVer-incompatible breaking change and must not happen in a minor release (The Cargo Book — Features). Adding to default is also dangerous, because every existing user inherits it on the next cargo update.
The other rule that surprises me is unilateral: if any location in the dependency graph enables default features for a crate, they're enabled everywhere. To suppress them you need every consumer to specify default-features = false. One holdout breaks the contract. This is why publishing a crate with a heavy default feature set is borderline antisocial — your users can't easily opt out.
I'm doing the boring thing for my own crates: minimal defaults, explicit opt-ins, and a bulleted feature list in the lib.rs doc comment so anyone using cargo doc can see the menu without diffing my Cargo.toml. The Effective Rust book recommends the document_features crate to auto-generate this from inline comments on the [features] section, which removes the chance that the docs and the manifest drift apart (Effective Rust — Item 26).
There's also a public API trap. You cannot feature-gate public struct fields or trait methods, because external code has no way to statically know whether the field exists. The Cargo Book is explicit that this is prohibited, and the workarounds — separate types per feature, or sealed traits — exist precisely because the language can't represent "sometimes this method is here" in its public surface (The Cargo Book — Features).
Workspaces and the Feature Unification Pitfall
My bot is a workspace. There's a core engine crate, an on-chain program crate, a shared types crate, and a binary crate that wires it all together. Workspaces let me share dependency declarations through [workspace.dependencies] and inherit them in members:
# Root Cargo.toml
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
# Member Cargo.toml
[dependencies]
tokio.workspace = true
serde = { workspace = true, features = ["rc"] }
This is the cleanest way to keep versions consistent across a multi-crate project. But the moment you have a workspace, you inherit a behavior with a name people whisper about: the feature unification pitfall.
The scenario, drawn from a writeup by nickb.dev, is mundane and brutal. Suppose one workspace member, the server, asks for flate2 with zlib-ng-compat. Another member, the desktop binary, just wants plain flate2. Run cargo build -p desktop from the workspace root. Cargo sees both members, unifies features, and decides flate2 should be compiled with zlib-ng-compat enabled — which means libz-sys — which means cmake (Cargo Workspace and the Feature Unification Pitfall). The desktop build, which never asked for cmake, fails because cmake isn't installed. The error message points at libz-sys. You've never heard of libz-sys. Welcome to debugging hell.
The fix isn't complicated once you know it exists, and the official Cargo docs lay out the tools (The Cargo Book — Resolver):
- Enable resolver v2. Available since Cargo 1.50, automatic in Rust 2021 edition. Among other improvements, target-specific dependency features are ignored when not building for that target, build-dependencies and proc-macros get independent feature sets, and dev-dependencies stop activating features when they aren't needed.
- Build packages individually when you don't want unification:
cargo build --manifest-path ./src/desktop/Cargo.toml --bin run_app, orcargo build -p desktop. - Split into independent sub-workspaces if the conflict is structural rather than incidental.
Resolver v2 is the right default for almost every modern project. I add resolver = "2" to my workspace root immediately. The fact that this still has to be a manual opt-in for non-2021-edition crates is one of those pieces of Rust's history that you just accept, like baseball's infield fly rule.
Diagnosing What Cargo Actually Built
When something feels wrong, the diagnostic flow that's been working for me is straightforward:
# What features are active across the graph?
cargo tree -e features
# Why is THIS crate getting THIS feature?
cargo tree -e features -i flate2
# Show the full unflattened path, not the deduplicated summary.
cargo tree --no-dedupe -e features -i libz-sys
The first command is my dashboard. The second is my magnifying glass. The third is my detective mode for when the second isn't enough. I can read the output now without going cross-eyed, which is genuinely a skill that takes a few hours to build.
There's a more systematic approach for libraries: test with --all-features and with --no-default-features at minimum, and ideally test every meaningful combination. Effective Rust points out that N independent features means 2^N possible build configurations, which gets out of hand fast (Effective Rust — Item 26). The cargo-feature-combinations tool exists to enumerate these for CI. For a binary crate like my bot, where I control the feature set, I don't need this. For a library someone else might consume, it would be irresponsible not to.
Version Specifiers, Quietly
The other Cargo behavior I keep relearning is the version specifier semantics. The defaults are not what most package managers do. Per the official spec (The Cargo Book — Specifying Dependencies):
1.2.3means>=1.2.3, <2.0.0. The caret is implicit.0.2.3means>=0.2.3, <0.3.0. For 0.x crates, the minor version is treated as the breaking boundary.0.0.3means>=0.0.3, <0.0.4. For 0.0.x crates, even the patch is breaking.~1.2.3means>=1.2.3, <1.3.0. Tilde gives you patch-level flexibility only.- Pre-release tags like
1.0.0-alphado not match1.0. They have to be requested explicitly.
The 0.x semantics are why so much of the Rust ecosystem behaves like it's on a hair trigger for breaking changes. A bump from 0.4 to 0.5 is a major version bump under SemVer, and the lockfile won't auto-upgrade. This is a feature, not a bug — it forces explicit intent — but it means any time I see 0.x in Cargo.toml, I assume the crate is still negotiating its API.
The rename trick is also worth committing to memory. If I rename a dependency in Cargo.toml:
[dependencies]
bar = { version = "0.1", package = "foo", optional = true }
[features]
log-debug = ["bar/log-debug"] # use the LOCAL name
The feature reference uses the local name (bar), not the original (foo). The Cargo Book calls this out specifically because it's exactly the kind of detail that breaks builds in a way the error message doesn't make obvious (The Cargo Book — Specifying Dependencies).
What This Means For My MEV Bot
For a Solana MEV bot, the practical implications stack up fast. I'm dealing with several Solana SDK crates, async runtime crates, on-chain program crates with their own toolchain expectations, RPC client crates with TLS choices, and serialization libraries with derive-macro features. Each one is a feature-flag negotiation.
My operating rules, after this week:
- Always start from
default-features = falsefor any non-trivial crate, then add only what I need. Bigger initial pain, smaller binary, faster compile. - Pin TLS at the application level. I pick rustls and I pick it explicitly via builder methods, not just in
Cargo.toml, because additive features can't be reasoned about from the manifest alone. - Run
cargo tree -e featuresafter every dependency change. If something I didn't ask for shows up, find out who asked. - Use
resolver = "2"everywhere, no exceptions, even though my workspace happens to be 2021 edition and gets it automatically. - Keep workspace dependencies in
[workspace.dependencies]for version unification, then layer per-member features on top. - Document the feature set in the lib.rs doc comment for any crate I publish, even internally, because future-me reading my own
Cargo.tomlcold is the same as a stranger reading it.
The larger lesson is that Cargo's elegance comes with a coupling cost. Every feature flag is a public statement that the rest of the dependency graph gets to read and respond to. Treating Cargo.toml like a private config file is the fastest way to end up debugging why two TLS stacks are in your binary and your build script is asking for cmake.
Key Takeaways
- Cargo unifies features across the entire dependency graph. If anyone in the graph enables a feature, the whole graph sees it enabled. There is no opt-out at the manifest level.
- Features must be additive, never subtractive. A feature that disables behavior is an anti-pattern because additive resolution will silently undo it.
cargo tree -e featuresis the diagnostic tool. Combined with-ifor reverse tracing and--duplicatesfor version drift, it answers the "why is this here" question.- Resolver v2 is the right default. It fixes target-specific feature leakage, isolates build-dependency features, and enables cross-package
--featuresflags from the workspace root. - Default features are a SemVer contract. Removing one is a breaking change. Suppressing them requires every consumer in the graph to set
default-features = false.
Disclaimer
This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.