Skip to content
rusthacks.dev

Cargo Workspaces Explained for Splitting a Large Project

A workspace groups crates under one Cargo.lock and target directory. See when to split, the root manifest, virtual versus root package workspaces, running commands per member and what to avoid.

6 min read
Summarize in AI
Flat illustration for the post “Cargo Workspaces Explained for Splitting a Large Project”, composed around cargo.

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:

Text
inventory/
├── Cargo.toml
└── crates/
    ├── core/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── cli/
        ├── Cargo.toml
        └── src/main.rs

The root manifest has no [package] table. It is a virtual manifest: it exists to name the members and share settings.

Cargo.tomlTOML
[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" }
crates/core/Cargo.tomlTOML
[package]
name = "inventory-core"
description = "Stock levels and reorder rules"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
fastrand.workspace = true
crates/core/src/lib.rsrust
pub fn reorder_qty(stock: u32, target: u32) -> u32 {
    target.saturating_sub(stock)
}

pub fn sample_sku() -> u32 {
    fastrand::u32(1000..9999)
}
crates/cli/Cargo.tomlTOML
[package]
name = "inventory-cli"
version.workspace = true
edition.workspace = true
publish = false

[dependencies]
inventory-core.workspace = true
crates/cli/src/main.rsrust
fn main() {
    let sku = inventory_core::sample_sku();
    let n = inventory_core::reorder_qty(12, 50);
    println!("SKU {sku}: reorder {n}");
}
Text
$ cargo run -q -p inventory-cli
SKU 6088: reorder 38

The 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.lock at the root, covering every member. Updating a dependency for one crate updates it for all of them, so there is no version skew between core and cli.
  • One target/ directory. A dependency compiled for core is reused by cli, which is the main saving. Two separate projects would compile fastrand twice.
  • 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:

Shell
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'])"
Text
inventory-cli 0.4.0 2024 None
inventory-core 0.4.0 2024 MIT OR Apache-2.0

The 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:

Shell
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 --workspace

Inside 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:

.cargo/config.tomlTOML
[alias]
ci = "clippy --workspace --all-targets --locked -- -D warnings"
t = "test --workspace --locked"
Cargo.tomlTOML
[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:

Text
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.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.