Dependency Sharing in Cargo Workspaces
How workspaces share a single Cargo.lock and output directory, and how to centralize dependency versions across all member crates.
When you build a project that spans multiple crates—maybe a library, a CLI tool, and a web server all in one repository—keeping every crate on the same version of every external dependency becomes a real problem without a deliberate mechanism to enforce it. Cargo workspaces solve this through two coordinated mechanisms: a single shared Cargo.lock file that records the exact versions of every dependency used anywhere in the workspace, and the [workspace.dependencies] table that lets you declare version requirements once and inherit them in every member crate. Together they eliminate version drift, reduce duplicated compilation, and make dependency auditing straightforward.
The Problem Dependency Sharing Solves
In a multi‑crate repository without workspaces, each crate has its own Cargo.lock and its own target/ directory. A change in a shared dependency version in one crate’s Cargo.toml does not automatically propagate to the others. You end up with two copies of serde 1.0.190 and serde 1.0.195 living in two different crates, compiled separately, and potentially exposing subtly incompatible APIs. The binary crate that links both libraries now has two versions of serde in its dependency graph, which bloats compile times and binary size, and can cause type mismatches if a library’s public API exposes a type from the wrong version.
A workspace replaces this scattered model with one Cargo.lock at the root that governs every member crate. When any crate in the workspace requires serde, the entire workspace resolves to a single version. That version gets compiled once and reused by all members.
Workspaces and version consistency:
Even without [workspace.dependencies], the shared lockfile already forces compatible resolution. Adding the workspace dependency table is an organisational step that makes the intended versions explicit and maintainable.
The Shared Cargo.lock
A workspace root directory contains one Cargo.lock file. No member crate generates its own. Whenever you add or update a dependency in any member’s Cargo.toml, Cargo resolves the entire workspace’s dependency graph together and writes the result into that single lockfile.
my-workspace/
├── Cargo.toml # [workspace] root (no [package] if virtual)
├── Cargo.lock # <--- shared lockfile
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── server/
│ ├── Cargo.toml
│ └── src/main.rs
└── target/ # <--- shared build output
If core/Cargo.toml adds serde = "1.0" and server/Cargo.toml adds serde = "1.0.180", the resolver sees that both version requirements are compatible (both accept a 1.0.x release) and picks one concrete version—say 1.0.197—that satisfies both. The lockfile records exactly that version. Both crates compile against the same serde instance.
This unification happens even if you run cargo build from inside core/. The compiled artifacts still land in my-workspace/target/, not core/target/. Crates that depend on each other inside the workspace (server depends on core) benefit the most: the dependency is compiled once, and the intermediate artifacts are reused without re‑compilation when you switch from building one member to another.
Centralizing Dependency Versions with [workspace.dependencies]
Rust 1.64 introduced the [workspace.dependencies] table, which lets you declare dependency specifications once in the workspace root and inherit them in member crates with workspace = true. This is not just a convenience; it is a source of truth. When you need to bump tokio from 1.35 to 1.40 across ten crates, you change one line.
Here is a workspace root that defines shared dependencies:
# my-workspace/Cargo.toml
[workspace]
resolver = "2"
members = ["core", "server", "cli"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"
A member crate then references them:
# my-workspace/server/Cargo.toml
[package]
name = "server"
version.workspace = true
edition.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true, features = ["json"] }
core = { path = "../core" }
The workspace = true keyword tells Cargo “look up the version (and any shared features) from [workspace.dependencies] in the root”. Member crates can still add local features or override the dependency’s name, but the version is locked to what the workspace declares. This avoids the classic mistake where one crate depends on serde = "1.0.180" and another on serde = "1.0.181" and a human reviewer has to spot the discrepancy.
Workspace inheritance also works for package fields:
You can define [workspace.package] with version, edition, authors, license, and more, and then use version.workspace = true in each member. This keeps metadata consistent across every crate you intend to publish.
How Version Resolution Works Across the Workspace
Cargo’s dependency resolver treats the entire workspace as one unified dependency graph. It starts with every dependency requirement from every member crate, including dev‑dependencies and build‑dependencies. The resolver then attempts to find the smallest set of crate versions that satisfies all constraints.
If two crates ask for serde with overlapping semver ranges—1.0 in one, 1.0.190 in another—the resolver will pick a single version within the intersection (e.g. 1.0.197). That version is compiled once.
If the requirements are incompatible—one crate requires serde = "0.9" and another requires serde = "1.0"—the resolver includes both versions in the lockfile. The workspace still shares a single lockfile, but the binary will contain two copies of serde. This is sometimes unavoidable when upgrading a dependency incrementally, but it is an explicit signal that two parts of the codebase are on different major versions.
Multiple versions of the same crate:
Having two versions of the same crate in the dependency graph is not an error, but it can lead to type mismatches if a public API exposes a type from one version while a downstream crate expects the other. Cargo warns about this with “multiple versions of crate X” in cargo tree -d.
Feature Unification
When the resolver chooses one version of a crate used by multiple workspace members, it must also decide which features to enable. It does this by taking the union of all requested features from all members. If core enables serde/derive and server enables serde/json, the compiled serde will have both derive and json active.
This unification is usually harmless because Cargo features are additive by design, but it can pull in extra dependencies or activate slower code paths that a crate might not expect. If one library only ever serialises JSON and another never does, both will still get the json feature enabled because the crate is compiled once.
Resolver version 2 and platform‑specific features:
Workspaces should set resolver = "2" in the root Cargo.toml so that features are not unified across host and target platforms unnecessarily, avoiding build issues with optional platform‑specific dependencies.
Path Dependencies Inside a Workspace
Workspace members can depend on each other using path dependencies, just like any other local crate:
# server/Cargo.toml
[dependencies]
core = { path = "../core" }
Because the whole workspace already shares a lockfile, path dependencies do not introduce any extra resolution work. Cargo knows that core is a workspace member, so it compiles it as part of the same build graph. Any external dependency that core pulls in will also be shared with server if both crates need it.
One detail: a path dependency to a crate outside the workspace will not be covered by the shared lockfile and will have its own resolution. To keep everything unified, add that external crate as a workspace member or vendor it.
Common Mistakes and How to Avoid Them
Expecting [workspace.dependencies] to automatically add the dependency to every crate. The workspace dependency table only defines the version and features. A member crate must still declare the dependency in its own [dependencies]. If a crate forgets to write tokio = { workspace = true }, it cannot use tokio regardless of what the workspace root says.
Missing dependency declaration:
A missing [dependencies] entry in a member crate that tries to use tokio; will fail with “unresolved import tokio”, even if the root [workspace.dependencies] lists it. The workspace table is a shared configuration, not an automatic injection.
Mixing workspace inheritance with direct version strings. A member crate can still write serde = "1.0" instead of serde = { workspace = true }. That is valid, but now there are two sources of truth. If you later bump the workspace version, this crate will stay pinned to its own range until you manually update it.
Forgetting that workspace = true only works for keys defined in the workspace root. If a member writes tokio = { workspace = true, features = ["net"] } but the workspace root only defines tokio = "1" without any features, the local features override will be applied, but the base version comes from the workspace. This is intended and useful; just remember that the root must at least list the dependency name.
A Complete Example
Let’s build a workspace that exercises all the sharing mechanisms. The project has a library core that provides data types, a binary server that depends on it, and both use serde and tokio.
Directory layout:
shared-demo/
├── Cargo.toml # workspace root (virtual, no [package])
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
└── server/
├── Cargo.toml
└── src/main.rs
Workspace root shared-demo/Cargo.toml:
[workspace]
resolver = "2"
members = ["core", "server"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
core/Cargo.toml:
[package]
name = "core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { workspace = true }
core/src/lib.rs:
use serde::Serialize;
#[derive(Serialize)]
pub struct User {
pub name: String,
pub age: u8,
}
server/Cargo.toml:
[package]
name = "server"
version.workspace = true
edition.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true, features = ["json"] }
core = { path = "../core" }
server/src/main.rs:
use core::User;
#[tokio::main]
async fn main() {
let user = User { name: "Alice".into(), age: 30 };
let json = serde_json::to_string(&user).unwrap();
println!("User as JSON: {}", json);
}
Now build the workspace from the root:
cargo build --workspace
Output:
Compiling serde v1.0.197
Compiling serde_json v1.0.114
Compiling tokio v1.36.0
Compiling core v0.1.0 (.../core)
Compiling server v0.1.0 (.../server)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.23s
The lockfile at shared-demo/Cargo.lock contains exactly one entry for serde version 1.0.197 and one for tokio version 1.36.0. Even though server enabled the extra json feature, both crates compile against the same serde instance.
If you see single versions of shared dependencies in the lockfile, sharing is working:
Run cargo tree -d to spot duplicate dependencies. In a healthy workspace with consistent version requirements, you should see no duplicates for your core dependencies.
The workspace dependency table turned a maintenance headache—bumping six Cargo.toml files whenever tokio released a new minor version—into a single‑line edit at the root. The shared lockfile ensured that no matter which engineer added a dependency or from which directory cargo build was invoked, every crate compiled against the same concrete artifacts.
Summary
Dependency sharing in Cargo workspaces is a structural guarantee, not a convention. The shared Cargo.lock forces the entire workspace to resolve external dependencies together, eliminating version drift and duplicate compilation. The [workspace.dependencies] table makes those versions explicit and centrally governable. When a new developer clones the repository and runs cargo build, they get exactly the same dependency versions as everyone else—no “works on my machine” surprises caused by slightly different lockfiles.