monorepo.
monorepo12 min read

cargo workspace resolver 2

Cargo workspace resolver=2 — feature unification surprises, when to migrate from resolver=1, edition 2021 split.

Cargo Workspace Resolver=2: Feature Unification Surprises and When to Migrate

Last summer I spent an embarrassing afternoon staring at ldd output on a stripped Alpine container, trying to figure out why my supposedly statically-linked Rust agent was crashing on a missing libssl.so. Nothing in my Cargo.toml mentioned native-tls. I'd asked for rustls everywhere I could see. And yet, there it was \u2014 system OpenSSL, threaded through my release binary by a build script I'd forgotten existed. That afternoon is what this article is really about. The villain has a name: resolver=1, doing exactly what it was designed to do.

If you run a polyglot monorepo with a Cargo workspace inside it \u2014 Rust agents alongside Python services and a TypeScript frontend \u2014 the resolver field in your root Cargo.toml is one of those settings that quietly decides what your binaries actually contain. Set it wrong and a cargo build -p mac-agent link step pulls in a TLS backend you never asked for, because some other crate in the workspace enabled a feature your agent inherits transitively.

Resolver=2 is the fix for that. It's been stable since Rust 1.51 (March 2021) and it's the default for any package on edition 2021 or later. But "default for new packages" isn't the same as "active for your workspace," and that distinction is where most of the surprises live. I'll walk through how feature unification differs between resolver=1 and resolver=2, what the edition-2021 split actually does in a workspace that mixes editions, when migration pays off, and the specific failure modes that show up in a monorepo with binary agents plus shared library crates.

1. What resolver=1 actually unified

What exactly was resolver=1 designed to unify? Features across the entire build graph \u2014 every crate that touches a given dependency contributes to one merged feature set that has to satisfy them all. If crate A depends on serde with the derive feature and crate B depends on serde with rc, the resolved serde gets both. That sounds reasonable until you draw the graph for a workspace that mixes targets.

Consider a workspace with three members:

  • agent-core \u2014 a library, depends on tokio with ["rt-multi-thread", "macros"]
  • mac-agent \u2014 a binary built for macOS, depends on agent-core plus tokio with ["fs", "process"]
  • build-helper \u2014 a build.rs-style crate that runs at compile time and depends on tokio with ["rt"]

Under resolver=1, Cargo computes one feature set for tokio that satisfies every consumer: rt, rt-multi-thread, macros, fs, process. Both your runtime binary and your build script link against the same tokio with the same features. The build script gets fs and process it never uses; the binary gets rt which is harmless but extra. More importantly, if build-helper happens to enable a feature that pulls in a heavy native dependency (think openssl-sys via reqwest/native-tls), the runtime binary inherits that linkage too. You end up shipping a binary linked against system OpenSSL because a build-time helper happened to want HTTPS. That's my Alpine afternoon, generalized.

This is the unification surprise resolver=1 produces. It's correct in the sense that Cargo finds a feature set satisfying every constraint, but it leaks features sideways across logical boundaries \u2014 build scripts into binaries, dev-dependencies into release artifacts, target-specific deps into all targets.

2. What resolver=2 changes

Why does the same crate sometimes get compiled twice in a single workspace build? Because resolver=2 separates the feature graph into four logical buckets and resolves each independently:

  1. Normal dependencies of crates being built for the target platform
  2. Build dependencies and proc-macros (always built for the host)
  3. Dev dependencies (only when building tests / examples / benches)
  4. Target-specific dependencies that don't apply to the current target

In the example above, resolver=2 builds tokio with ["rt-multi-thread", "macros", "fs", "process"] for mac-agent's runtime, and a separate tokio with just ["rt"] for build-helper. Two compilations of tokio, two distinct feature sets, no cross-contamination. Your binary stops linking against features that only the build helper wanted.

The cost is real: you compile some crates twice. On a cold build of a moderately sized workspace, resolver=2 will add somewhere in the 5\u201320% range to wall-clock build time depending on how much overlap exists between build deps and runtime deps. On warm incremental builds the cost is negligible, because the host-side artifacts are cached separately from the target-side artifacts.

The official rationale is in the Cargo book under the features chapter on resolver, which documents the four buckets and the exact unification rules.

3. The edition 2021 split

Every member of your workspace can be on edition 2021 and Cargo can still be running the resolver=1 algorithm for the whole build \u2014 and that mismatch is exactly what bites mixed workspaces.

When you create a new package with cargo new --edition 2021, the generated Cargo.toml doesn't contain a resolver field. The package opts into resolver=2 implicitly because edition 2021 sets that as its default. But this implicit opt-in only works for standalone packages.

In a workspace, the resolver is determined by the workspace root, not by individual members. If your workspace Cargo.toml has no [workspace] resolver setting and your root package is on edition 2018 (or has no package section at all because it's a virtual workspace), Cargo defaults the entire workspace to resolver=1 \u2014 even if every single member crate is on edition 2021.

This produces a confusing diagnostic the first time you hit it: warning: virtual workspace defaulting to resolver version 1. The edition 2021 crates will use the version 2 resolver if ran outside of the workspace. That warning is Cargo telling you your members would behave differently if built in isolation than when built as part of the workspace. Almost always, you want the workspace-level resolver to match what the members expect.

The fix is one line in the workspace root:

[workspace]
resolver = "2"
members = [
    "crates/agent-core",
    "crates/mac-agent",
    "crates/linux-agent",
    "crates/shared-protocol",
]

Add that and the warning goes away, and \u2014 more importantly \u2014 every workspace member resolves features the way it would resolve them as a standalone package. This is the single most common resolver-related cleanup in older Rust workspaces, and you should do it before you debug any other resolver behavior. If you do nothing else with this article, do this.

4. When migrating from resolver=1 actually pays off

If you inherited a workspace on resolver=1, migration isn't automatic and isn't free. The build graph changes, which means feature flags that were silently enabled by transitive dependencies may stop being enabled, and code that compiled before may fail. Here are the situations where the migration is worth the churn:

You ship binaries with different feature sets than your build scripts need. If your workspace has a build.rs that pulls in any crate with optional native features (TLS, compression, image codecs), you're almost certainly carrying those features into your release binaries today. Resolver=2 fixes this.

You support multiple platforms with target-specific dependencies. If your Cargo.toml has [target.'cfg(unix)'.dependencies] or [target.'cfg(windows)'.dependencies] blocks, resolver=1 unifies features across all targets when resolving. Resolver=2 only considers features needed for the current target. A workspace with a Mac agent and a Linux agent sharing a core crate will resolve cleaner features under resolver=2 because Mac-only deps stop influencing the Linux build's feature set.

Your dev-dependencies pull in something heavy. A common pattern is dev-dependencies = { criterion = "0.5" } for benchmarks. Resolver=1 will sometimes pull criterion's full feature surface into the normal build graph if the dependency edge wires through carelessly. Resolver=2 isolates dev-deps to test/bench builds only.

You hit a "feature mysteriously enabled" debugging session. If you've ever run cargo tree -e features and seen a feature enabled with no clear source, resolver=1's unification across all buckets is usually the culprit. Resolver=2's split makes cargo tree output match intuition.

If none of those apply \u2014 you have a single-binary workspace with one platform and no build scripts \u2014 the migration isn't worth doing for its own sake. The build artifacts will be byte-identical or near-identical, and you trade a small warm-build win for a cold-build penalty.

5. A concrete migration session

Here's what migrating a real workspace looks like end-to-end. Start with the current state:

cargo tree -e features --workspace > /tmp/features-before.txt
cargo build --release --workspace 2>&1 | tee /tmp/build-before.log

Snapshot the feature graph and the build output so you have something to diff against. Then add the resolver field:

[workspace]
resolver = "2"
members = ["crates/*"]

Run the same commands again:

cargo clean
cargo tree -e features --workspace > /tmp/features-after.txt
cargo build --release --workspace 2>&1 | tee /tmp/build-after.log
diff /tmp/features-before.txt /tmp/features-after.txt | head -100

The diff is the actionable output. Every line removed from the "after" file is a feature that was being enabled spuriously under resolver=1. Most of those removals are safe \u2014 they were features your code didn't actually use. A small number may be features your code DID use but only got because some sibling crate happened to enable them. Those show up as compile errors in the second build, and the fix is to declare the feature explicitly in the consuming crate's Cargo.toml:

# Before \u2014 relied on transitive feature unification
[dependencies]
reqwest = "0.12"

# After \u2014 declares what it actually uses
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }

This is the resolver=2 migration in microcosm: features become explicit at the consumer, not implicit through unification. The codebase gets more honest about its dependencies, at the cost of more verbose Cargo.toml files. For a workspace with 10\u201320 members, expect to add explicit features to maybe 3\u20135 dependency declarations.

6. The TLS backend trap

The most common concrete failure mode is the TLS backend choice \u2014 and yes, this is the trap I fell into. Crates like reqwest, tonic, and sqlx support both native-tls (links against system OpenSSL or SChannel) and rustls (pure Rust). Under resolver=1, if any crate in your workspace transitively enables native-tls, every crate in the workspace gets native-tls whether it asked for it or not.

In a monorepo with a Linux agent (where you want rustls so the binary is statically linkable) and a separate build helper that uses reqwest for fetching artifacts (where the TLS backend doesn't matter), you can easily end up with the Linux agent linking against libssl.so despite asking for rustls-tls. The diagnostic \u2014 when you ldd the binary on a stripped Alpine container and find it crashes because OpenSSL is missing \u2014 is brutal, because nothing in your Cargo.toml mentions native-tls.

Resolver=2 fixes this by computing the build helper's TLS features separately from the agent's. Your agent gets rustls-tls only, links statically, ships clean. The migration cost \u2014 declaring features explicitly on the build helper instead of relying on defaults \u2014 is small compared to the operational pain of debugging mystery TLS linkage in production. Trust me on the comparison.

7. Resolver=3 and where things are heading

The Rust 2024 edition introduced resolver=3, which adds a single behavior on top of resolver=2: MSRV-aware version selection. When a dependency has multiple compatible versions, resolver=3 prefers the newest version whose rust-version field is satisfied by your toolchain. This is documented under the MSRV-aware resolver section of the Cargo book.

For a workspace, resolver=3 is purely additive \u2014 it doesn't change feature unification at all. If you're on resolver=2 and you want resolver=3's MSRV behavior, the migration is just bumping the number. There is no equivalent of the feature-graph diff exercise. Most workspaces that have completed the resolver=1 \u2192 resolver=2 migration should adopt resolver=3 the next time they touch the workspace root, with the caveat that resolver=3 only takes effect on Rust 1.84 and later.

8. When to leave resolver=1 alone

A few categories of workspace are genuinely better off on resolver=1:

  • Pre-1.51 toolchain compatibility \u2014 if you're pinned to a Rust version older than 1.51 (rare, but legacy embedded targets sometimes are), resolver=2 isn't available.
  • Build times dominated by cold builds in CI \u2014 if your CI cache is unreliable and your build is bound on the duplicated host/target compilations, the resolver=2 overhead may not be worth it for the feature-hygiene gain. A better fix is to fix the CI cache, but in the meantime resolver=1 is faster.
  • Single-binary workspaces with no build scripts and one platform \u2014 feature unification produces the right answer here too, and you save the duplicate compilation.

Outside those cases, the default for any new workspace should be resolver=2 (or resolver=3 if your MSRV permits), and the inherited-from-2018 default of resolver=1 should be treated as a migration debt to be paid down opportunistically.

9. Checklist for adoption

Before flipping the resolver in a workspace that has been on resolver=1 for a while, run through this checklist:

  1. Snapshot cargo tree -e features --workspace and a full release build log.
  2. Add resolver = "2" to [workspace] in the root Cargo.toml.
  3. Run cargo clean && cargo build --release --workspace and capture errors.
  4. For each "feature not found" or "unresolved import" error, identify which feature was being unified-in and add it explicitly to the consuming crate's [dependencies] declaration.
  5. Diff the feature trees and audit removals \u2014 most are safe, a few may indicate a feature you actually want to keep but now need to declare explicitly.
  6. Re-run your test suite, including cargo test --workspace --all-features and cargo test --workspace --no-default-features if your crates support those modes.
  7. Check binary sizes with cargo bloat --release --crates before and after \u2014 resolver=2 typically reduces binary size by 2\u201315% when build-script features were leaking.

The whole cycle takes an afternoon for a workspace with 10\u201320 members. The payoff is honest feature declarations, smaller release binaries, and the elimination of an entire category of "why is this linked against X" mystery. I'd argue it's also the difference between a workspace you can reason about and one that occasionally surprises you on a Friday evening deploy.

References: