The day a workspace has six crates is the day you discover that serde is declared in all six, at three different versions, with two different feature lists. [workspace.dependencies] fixes that: declare a dependency once at the root, and members opt in with workspace = true. This post shows the syntax, what each member can still override, the failure when you forget to declare, and how this interacts with feature unification.
The manifests ran on Cargo 1.99 with edition 2024; the single external crate is fastrand 2, and the other dependency is a path crate inside the workspace.
Declare once, inherit everywhere
The root manifest holds a table of dependencies with their versions and any features every member needs:
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.dependencies]
fastrand = "2"
inventory-core = { path = "crates/core", version = "0.4.0" }A member then names the dependency with no version at all:
[package]
name = "inventory-core"
version = "0.4.0"
edition = "2024"
[dependencies]
fastrand.workspace = trueThat one line pulls the version (and features) from the root. The sibling binary uses the same trick for the internal crate, which is the pattern worth adopting even for two crates: a path dependency declared with a version at the root is the form that works both in the workspace and once published, so no member ever hardcodes a relative path.
[package]
name = "inventory-cli"
version = "0.4.0"
edition = "2024"
publish = false
[dependencies]
inventory-core.workspace = truefn main() {
let sku = inventory_core::sample_sku();
println!("SKU {sku}");
}With crates/core/src/lib.rs defining sample_sku() as fastrand::u32(1000..9999), cargo run -q -p inventory-cli prints a four digit number, and cargo tree -p inventory-cli shows the chain:
inventory-cli v0.4.0 (/work/inventory/crates/cli)
└── inventory-core v0.4.0 (/work/inventory/crates/core)
└── fastrand v2.5.0What a member may add or override
Inheritance is a starting point, not a lock. A member can switch on more features than the root declares, and can mark the dependency optional:
[dependencies]
serde = { workspace = true, features = ["derive"] }
fastrand = { workspace = true, optional = true }
[features]
rng = ["dep:fastrand"]Features add to whatever the root listed, and optional is allowed because it is a property of the edge between this crate and the dependency, not of the dependency itself. What a member cannot do is change the version, the source, or turn off a feature the root enabled: default-features = false on a member is ignored if the root did not also disable defaults, and recent Cargo warns about it. To get different defaults for one member, declare it separately and accept that the version is no longer centrally managed.
The three dependency kinds inherit independently. [dev-dependencies] and [build-dependencies] each accept name.workspace = true, and platform tables do too, so a test helper shared across crates gets the same single-source-of-truth treatment as a runtime dependency.
The error you get when you forget
Inheriting something the root never declared fails at the manifest, before any compilation. The message has three nested layers, and the useful line is the last:
error: failed to load manifest for workspace member `/work/inventory/crates/cli`
referenced via `crates/*` by workspace at `/work/inventory/Cargo.toml`
Caused by:
failed to load manifest for dependency `inventory-core`
Caused by:
failed to parse manifest at `/work/inventory/crates/core/Cargo.toml`
Caused by:
error inheriting `fastrand` from workspace root manifest's `workspace.dependencies.fastrand`
Caused by:
`dependency.fastrand` was not found in `workspace.dependencies`The error surfaces on cli, the crate you were building, though the broken manifest is core's; Cargo is following the dependency edge. That detour is the thing that sends people to the wrong file. Read to the bottom, find the crate named in the second-to-last failed to parse manifest line, and add the missing key to the root.
Updating a shared dependency
The benefit shows on the day a dependency needs to move. Raising the requirement is a one-line edit at the root, then one command re-resolves the lockfile for the whole workspace:
cargo update -p fastrand
cargo update -p fastrand --precise 2.4.1
cargo tree -dThe first moves fastrand to the newest 2.x allowed by the root requirement. The second pins an exact version, which is the tool for stepping back after a regression. The last, cargo tree -d, lists any crate that now appears at two versions; in a workspace that has centralised its dependencies, an empty result is the sign it worked. A duplicate there usually means one member still declares the dependency locally with its own version, which is exactly the drift this table exists to remove, so grep the member manifests for the name.
Moving an existing workspace over
You can migrate one dependency at a time, since members that still declare their own versions keep working. A safe order is to pick the dependency that appears in the most manifests, declare it in the root with the highest version currently in use, and replace the member lines with .workspace = true. Build after each one. The first build can fail if two members used different feature sets: the root now has to carry the union, and any member that relied on a smaller set will compile more code than before. cargo tree -e features before and after shows precisely which features moved. cargo add -p inventory-core fastrand adds a dependency to one member, and when the root already declares it, the line it writes is the workspace = true form.
What this does to the lockfile and to feature unification
Centralising versions does not change resolution: Cargo still resolves one graph and writes one Cargo.lock. It only changes where you edit. A one-line version bump at the root moves every member, so a dependency update becomes a single reviewable change instead of six near-identical ones.
It does not isolate features. If the root declares tokio = { version = "1", features = ["rt"] } and one member adds features = ["full"], every member gets full because there is one tokio in the build; see feature unification. A common discipline is to put only the features everyone needs at the root, and let the one member that needs more request them, so cargo tree -e features -i tokio points at the member that widened it.
Publishing from a workspace
Internal path dependencies must carry a version to be publishable, which is why the root declares inventory-core = { path = "...", version = "0.4.0" }. When you publish inventory-core, Cargo rewrites the path out of the uploaded manifest and keeps the version. Publish order matters: a crate must reach the registry before one that depends on it. Release tools such as release-plz and cargo-release compute that order for you, and cargo package --workspace checks every member's package contents in one run.
When this is not worth it
For a workspace of two crates with one shared dependency, the saving is a single duplicated line, and the indirection costs a reader a hop to find the version. It pays off with several members, with dependencies that everyone must keep in lockstep (the serde and tokio families), and with tooling that rewrites versions, since cargo upgrade and Dependabot both handle the central table well. Dependencies used by exactly one member can stay local: declaring them at the root only to inherit them once adds a layer without removing any duplication. The syntax reference is the dependencies table section of the workspaces chapter.



