A workspace is how Cargo lets several crates build as one project: one lockfile, one target directory, one set of versions. You reach for it when a single crate has grown a parser, a database layer and a binary that nobody can test in isolation, and the compile times show it. This post builds a two-crate workspace from nothing, runs it, breaks it three ways and reads Cargo's own messages, then covers when a workspace makes things worse.
The workspace below ran on Cargo 1.99 with edition 2024 and resolver 3. The only external dependency is fastrand 2.
The smallest workspace that earns its name
The layout is a root manifest and a crates/ directory holding one library and one binary:
inventory/
├── Cargo.toml
└── crates/
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
└── cli/
├── Cargo.toml
└── src/main.rsThe root manifest has no [package] table. It is a virtual manifest: it exists to name the members and share settings.
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
fastrand = "2"
inventory-core = { path = "crates/core", version = "0.4.0" }[package]
name = "inventory-core"
description = "Stock levels and reorder rules"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
fastrand.workspace = truepub fn reorder_qty(stock: u32, target: u32) -> u32 {
target.saturating_sub(stock)
}
pub fn sample_sku() -> u32 {
fastrand::u32(1000..9999)
}[package]
name = "inventory-cli"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
inventory-core.workspace = truefn main() {
let sku = inventory_core::sample_sku();
let n = inventory_core::reorder_qty(12, 50);
println!("SKU {sku}: reorder {n}");
}$ cargo run -q -p inventory-cli
SKU 6088: reorder 38The SKU is random, so yours will differ; 38 is 50 minus 12. Two details matter. members = ["crates/*"] is a glob, so adding a crate means creating a directory, not editing the root. And fastrand.workspace = true says "take the version and features from the root's [workspace.dependencies]", which is the mechanism behind sharing versions.
What the members share
Three things are shared automatically, and they are why a workspace is more than a folder of crates:
- One
Cargo.lockat the root, covering every member. Updating a dependency for one crate updates it for all of them, so there is no version skew betweencoreandcli. - One
target/directory. A dependency compiled forcoreis reused bycli, which is the main saving. Two separate projects would compilefastrandtwice. - One resolver and one feature graph. Features unify across members, which is an effect to be aware of: see feature unification.
Cargo also lets members inherit [package] fields, dependencies and lints from the root, which is what the .workspace = true lines do. Inspect what each crate ended up with:
cargo metadata --format-version 1 --no-deps | python3 -c "
import sys, json
for p in json.load(sys.stdin)['packages']:
print(p['name'], p['version'], p['edition'], p['license'])"inventory-cli 0.4.0 2024 None
inventory-core 0.4.0 2024 MIT OR Apache-2.0The binary prints None for its license because it never inherited one, which is fine for an unpublished crate and a useful reminder that inheritance is opt-in per field.
Commands and what they select
From the root, cargo build on a virtual manifest builds every member. Narrow it with -p:
cargo build # all members
cargo test -p inventory-core # one crate
cargo run -p inventory-cli # one binary
cargo clippy --workspace --all-targets
cargo build --exclude inventory-cli --workspaceInside a member's directory, plain cargo build builds that member (and what it depends on), because Cargo walks up to find the workspace root and then selects the package in the current directory. That is why the same command can mean different things depending on where you stand.
Deciding where the crate boundaries go
The mistake that follows a first workspace is splitting by layer name: a models crate, a services crate, a utils crate. Those boundaries follow the folder structure of a framework, not the build. A boundary is worth a crate when one of three things is true. The part changes much less often than its neighbours, so keeping it compiled saves minutes on every edit. The part needs a different dependency set, such as a parser that must stay free of a web framework so another tool can reuse it. Or you need to publish, test or version it on its own. A utils crate fails all three: it changes constantly, depends on everything and is published by nobody, so it becomes the file everyone recompiles.
A useful test before creating a crate is to name the one thing it exports. inventory-core exports reorder rules. If the honest answer is "various helpers", leave the code where it is and use a module. Cargo can only parallelise and cache across crates, and the unit of rebuilding is the crate, so a boundary between two crates that always change together buys nothing and costs a public API you must now keep stable.
Running the same command for everyone
CI and contributors should build the workspace the same way. A short alias in .cargo/config.toml at the root keeps the flags in one place, and default-members controls what a bare cargo build selects when you do not want it to compile the heavy binary every time:
[alias]
ci = "clippy --workspace --all-targets --locked -- -D warnings"
t = "test --workspace --locked"[workspace]
members = ["crates/*"]
default-members = ["crates/core"]
resolver = "3"With that, cargo test from the root tests only core, while cargo t tests everything. Use default-members sparingly: it is a source of "why did CI run something I did not" confusion, since a reader sees different behaviour at the root and in a member directory. The lockfile flag in the alias is deliberate, because a workspace with one lockfile should fail the build when a manifest edit forgot to update it; the lockfile post explains why.
Three ways it breaks
Ask for a dependency the root never declared. Edit the root to rename fastrand to rand and the members still say fastrand.workspace = true:
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`Read it from the bottom: the last Caused by is the actual problem, and each line above it is a layer of "while loading". Cargo reports the whole chain, which is why workspace errors look long.
The second failure is a directory the glob matches that is not a crate, such as crates/notes/ with no manifest, which stops the whole workspace with failed to load manifest for workspace member. Exclude it (exclude = ["crates/notes"]) or move it. The third is a crate that lives inside the workspace tree but is not a member; Cargo then complains that the current package believes it is in a workspace but is not listed. The fix is to add it to members or to exclude.
When a workspace is the wrong shape
A workspace couples its members. They share a lockfile, so you cannot give one crate an old version of a dependency while another takes the new one without a version split in the graph, and they share a build directory, so a cargo clean wipes everyone's artifacts. Splitting a crate only to look tidy often makes incremental builds slower, since each boundary is another place the compiler cannot optimise across. It pays off when you want to test or publish parts separately, when a rarely-changed layer sits under a frequently-changed one so the lower crate stays cached, or when several binaries share a core.
If two projects have different release schedules and different owners, separate repositories with published crates are cleaner than one workspace. The authoritative rules are in the workspaces chapter of the Cargo book; the settings you can inherit are covered in lints and package inheritance.



