When one crate isn't enough
Your project started as a single binary. Now it has a CLI, a small HTTP server, a shared library of utilities, and a couple of test crates that exercise integration scenarios. They all share types, they all share dependencies, and you really don't want to maintain four separate Cargo.toml files with the same serde = "1.0" line copy-pasted everywhere.
This is what Cargo workspaces are for. A workspace is a folder that contains multiple crates ("members") under one umbrella. They share a build directory, a Cargo.lock file, and you can declare dependency versions once and inherit them everywhere.
If you've used npm workspaces or pnpm workspaces in JavaScript, the idea is the same. The implementation in Cargo is unusually clean: it's all configured in regular Cargo.toml files, and it works the same on every platform.
Setting up a workspace from scratch
Create a top-level folder. Inside it, write a Cargo.toml that declares the workspace but is not itself a crate:
# Cargo.toml at the repo root
[workspace]
# resolver = "2" enables the modern dependency resolver. New projects always want this.
resolver = "2"
# These are the directories Cargo treats as workspace members.
# Globs work, so you can write "crates/*" if you prefer.
members = [
"crates/cli",
"crates/server",
"crates/core",
]
Notice there's no [package] section. That means the root is a "virtual workspace": it has no crate of its own, just a set of children. (You can also have a "root crate" workspace where the top-level Cargo.toml is both a workspace and a regular crate. Both work; virtual workspaces tend to be cleaner.)
Each member directory has its own ordinary Cargo.toml:
# crates/core/Cargo.toml
[package]
name = "myapp-core"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
Your folder layout looks like this:
myapp/
├── Cargo.toml (the workspace root)
├── Cargo.lock (one shared lock file)
└── crates/
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── cli/
│ ├── Cargo.toml
│ └── src/main.rs
└── server/
├── Cargo.toml
└── src/main.rs
From the root, cargo build builds every member. cargo test tests every member. cargo build -p myapp-cli builds only that one. The shared target/ directory means changes to myapp-core only recompile downstream crates that depended on it, not the universe.
Member crates depending on each other
The CLI almost certainly wants to use code from myapp-core. You declare that as an ordinary path dependency:
# crates/cli/Cargo.toml
[package]
name = "myapp-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
# path = relative to this crate's Cargo.toml. Cargo recognises this is a workspace
# member and uses the in-tree source rather than crates.io.
myapp-core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
If you eventually publish to crates.io, add version = "0.1" alongside path. Cargo uses the path for in-workspace builds and the version for downstream consumers who fetch from crates.io.
Sharing dependencies with workspace inheritance
Repeating serde = "1" in every member's Cargo.toml is tedious and error-prone. Workspace inheritance lets you declare it once at the root and reference it by name in each member.
# Cargo.toml (workspace root)
[workspace]
resolver = "2"
members = ["crates/*"]
# Common package metadata that should be the same in every member.
[workspace.package]
edition = "2024"
license = "MIT OR Apache-2.0"
authors = ["You <you@example.com>"]
repository = "https://github.com/you/myapp"
# Common dependency versions, declared once.
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
Then each member crate inherits with the .workspace = true shorthand:
# crates/core/Cargo.toml
[package]
name = "myapp-core"
version = "0.1.0"
# Inherited from [workspace.package]
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
# Inherited from [workspace.dependencies]
serde.workspace = true
anyhow.workspace = true
If you want a member to extend a workspace dep with extra features, you can:
[dependencies]
serde = { workspace = true, features = ["rc"] }
Workspace inheritance arrived in Cargo 1.64 and is now the standard way to manage versions. The first time you bump serde from 1.0.197 to 1.0.200, you'll edit one line, not seventeen.
Running commands across the workspace
A handful of cargo flags become much more useful in a workspace:
# Build every member.
cargo build
# Build/test only one member.
cargo build -p myapp-cli
cargo test -p myapp-core
# Run a binary defined in a specific member.
cargo run -p myapp-server
# Test everything except a particular crate.
cargo test --workspace --exclude myapp-cli
# Check every member's docs build.
cargo doc --workspace --no-deps
cargo fmt and cargo clippy also accept --workspace so they cover all members in one go.
A common pitfall: forgetting to add a member
If you create a new directory crates/utils/ and forget to add it to members, Cargo won't see it. Worse, when a sibling tries to depend on it via path = "../utils", you'll get:
error: failed to load manifest for workspace member `/path/to/myapp/crates/utils`
note: this may be fixable by ensuring that this crate is being checked out from the proper location in the workspace and that it is being properly checked out from the right branch
Add the new directory to the members array (or use a glob like crates/*) and try again.
The other classic stumble: each member having its own Cargo.lock. That happens if you accidentally created a member crate outside the workspace directory, or if the workspace root's Cargo.toml is missing the [workspace] table. There should be exactly one Cargo.lock at the root of the whole tree; delete the strays.
When a workspace is overkill
If you have one binary and one library and they're tightly coupled, you don't need a workspace. The library can live as a module inside the binary crate. Workspaces shine when you have multiple crates that get built or published independently, or when separate binaries genuinely share substantial code.
A useful intermediate: keep a single binary crate, and split internal modules out into their own files. You only reach for a workspace when those modules grow into a thing that wants its own version, dependency set, or release cadence.