The monorepo maintenance trap
You are managing a workspace with five crates. You need tokio for the server crate. You add it to server/Cargo.toml. Two weeks later, the client crate needs tokio for async tests. You copy the dependency line from the server. Three months later, a security patch drops for tokio. You update the server. You forget the client. The build succeeds. The binary now contains two copies of tokio. The binary is larger. The versions drift. This is the maintenance tax of scattered dependencies.
Cargo workspaces eliminate this tax with a centralized registry. You define the dependency once in the root. Every member references it by name. Cargo guarantees version consistency across the entire project. You update the root, and every member gets the change. No search-and-replace. No drift.
Centralized dependency definitions
The [workspace.dependencies] table lives in the root Cargo.toml. It acts as a local registry for external crates. You list the crate name, version, and features here. Member crates reference these definitions using workspace = true in their own [dependencies] table.
Think of [workspace.dependencies] as a shared pantry. The root defines what is available and in what quantity. The members do not buy their own ingredients. They grab what they need from the pantry. If you restock the pantry, every member gets the new stock automatically.
This pattern solves the diamond dependency problem. If lib-a requests serde 1.0.1 and lib-b requests serde 1.0.2, Cargo must resolve the conflict. It usually picks the higher version, but the mismatch creates noise in the lock file and risks subtle breakage if versions are pinned. With workspace dependencies, both crates request the exact same specification. Cargo sees a single, unified request. The resolution is clean and predictable.
Minimal setup
The root Cargo.toml defines the shared dependencies. The member crates inherit them.
# Root Cargo.toml
[workspace]
members = ["lib-a", "lib-b"]
# Define shared dependencies once.
# All members will use this version and feature set.
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.35", features = ["rt", "macros"] }
# lib-a/Cargo.toml
[package]
name = "lib-a"
version = "0.1.0"
edition = "2021"
[dependencies]
# Inherit version and features from workspace root.
# Cargo injects the details during resolution.
serde = { workspace = true }
tokio = { workspace = true }
# lib-b/Cargo.toml
[package]
name = "lib-b"
version = "0.1.0"
edition = "2021"
[dependencies]
# Same dependency, same version, zero repetition.
serde = { workspace = true }
The workspace = true flag is the signal. Without it, Cargo treats the entry as a standard dependency and ignores the workspace definition. If you define serde in the workspace but forget workspace = true in the member, you end up with two separate dependencies that happen to have the same name. You lose the synchronization benefit.
How Cargo resolves the graph
Cargo reads the root Cargo.toml first. It builds a map of workspace dependencies. When it processes a member crate, it scans the [dependencies] table. If it encounters workspace = true, it looks up the crate name in the map. It copies the version, features, and other configuration keys into the member's dependency request.
The member never sees the raw version string. It only knows it wants the workspace definition. This injection happens before version resolution. The result is that all members emit identical requests for the shared crate. Cargo resolves these requests into a single entry in Cargo.lock.
This mechanism also affects cargo update. When you run cargo update -p serde, Cargo updates the version in the workspace definition. The next build propagates the new version to every member. You do not need to update each crate individually. The workflow scales to hundreds of crates without extra effort.
Convention aside: The community treats [workspace.dependencies] as the contract for external crates. Internal crates use path dependencies. Do not mix the two patterns. Use workspace dependencies for crates published to crates.io. Use path dependencies for crates inside the workspace.
Real-world: Features and overrides
Real projects rarely have identical feature requirements across all crates. The server might need tokio with full networking support. The library might only need the runtime. Workspace dependencies handle this gracefully. You define the base features in the root. Members can add extra features on top.
# Root Cargo.toml
[workspace.dependencies]
# Base features shared by all crates.
reqwest = { version = "0.11", default-features = false, features = ["json"] }
# api/Cargo.toml
[dependencies]
# Inherits default-features = false and "json".
# Adds "blocking" feature specific to this crate.
# Cargo merges the feature lists.
reqwest = { workspace = true, features = ["blocking"] }
# core/Cargo.toml
[dependencies]
# Inherits default-features = false and "json".
# No extra features needed.
reqwest = { workspace = true }
The member can also override default-features. If the workspace sets default-features = false, a member can re-enable defaults if necessary, though this is rare. The inheritance model is additive for features. You define the baseline in the root. Members extend it. You never lose the base configuration.
This structure keeps the root clean. You do not need to define multiple variants of the same crate. You define the common denominator. Members specialize as needed. The binary still deduplicates the crate. Cargo merges the feature sets at the top level. The result is a single reqwest with the union of all requested features.
Pitfalls and compiler errors
Workspace dependencies introduce a few specific failure modes. Understanding these saves debugging time.
If you reference a workspace dependency that does not exist in the root, Cargo rejects the build immediately. The error is explicit.
error: dependency 'foo' in workspace root is not defined
Check the spelling in the root [workspace.dependencies] table. The name must match exactly.
If you forget workspace = true, Cargo does not warn you. It treats the dependency as independent. You might define serde = "1.0" in the workspace and serde = "1.0" in the member. The build succeeds. The versions align by coincidence. You have lost the synchronization. If you update the workspace version later, the member stays on the old version. This drift is silent until you audit the lock file or run into a mismatch. Always use workspace = true for shared dependencies.
Another pitfall involves path dependencies. You cannot use workspace = true for a crate that is also a workspace member. Internal crates use path dependencies.
# WRONG: Cannot use workspace = true for internal crates.
[dependencies]
my-lib = { workspace = true }
# CORRECT: Use path for internal crates.
[dependencies]
my-lib = { path = "../my-lib" }
Cargo enforces this separation. Workspace dependencies are for external crates. Path dependencies are for internal crates. Mixing them causes confusion and errors.
Convention aside: Sort [workspace.dependencies] alphabetically. This makes it easy to scan and find entries. Tools like cargo sort can automate this. The community expects alphabetical order in TOML tables. It reduces cognitive load when reviewing changes.
Decision matrix
Use [workspace.dependencies] when multiple crates share an external dependency and you want version consistency. Use [workspace.dependencies] when you want to update a dependency across the project with a single change. Use [workspace.dependencies] when you want to enforce a baseline feature set across all members.
Reach for path dependencies when referencing another crate within the same workspace. Path dependencies link internal code. They do not go through crates.io. They are resolved locally.
Pick direct dependencies in a member crate when the dependency is unique to that crate and unlikely to be shared. If only one crate uses criterion for benchmarks, define it in that crate. Do not pollute the workspace root with single-use dependencies.
Use workspace = true with feature overrides when the base features are shared, but one crate needs extra capabilities. Add the extra features in the member. Do not duplicate the base features. Let inheritance do the work.
Define it once. Reference it everywhere. Drift is the enemy of maintainability. Trust the workspace table. If a crate needs a dependency, check the root first.