Cargo Workspaces
As a Rust project grows, keeping every crate in a single package becomes
unwieldy. Cargo workspaces let you manage multiple related packages together,
sharing a single Cargo.lock, a single build output directory, and a single
set of resolved dependency versions — while each package keeps its own
Cargo.toml.
What is a Workspace?
A workspace is a collection of packages that:
- Share a single
Cargo.lockfile at the workspace root (ensuring all member packages use identical dependency versions). - Share a single
target/directory at the workspace root (so artifacts are not duplicated across packages). - Are coordinated by a root
Cargo.tomlthat contains a[workspace]section.
Workspaces do not merge packages into one crate — each package still has its own compilation unit and its own public API.
When to Use a Workspace
A large project that is logically split into multiple crates (e.g. a web server, its data models, and shared utilities)
A monorepo containing several related but independently publishable crates
Projects with a binary crate and a companion library crate that share dependencies
Teams that want consistent dependency versions across all packages without manual coordination
Creating a Workspace
Start with a root directory containing a Cargo.toml that declares the workspace
members. This root Cargo.toml does not need its own [package] section — it
can be a virtual manifest that only coordinates members:
# Create the workspace root mkdir my_workspace && cd my_workspace # Create member packages cargo new adder # binary crate cargo new add_one --lib # library crate
# my_workspace/Cargo.toml (the workspace manifest)
[workspace]
members = [
"adder",
"add_one",
]The resulting directory structure looks like this:
my_workspace/
├── Cargo.toml ← workspace manifest (no [package])
├── Cargo.lock ← shared lock file
├── target/ ← shared build output
├── adder/
│ ├── Cargo.toml ← member package manifest
│ └── src/
│ └── main.rs
└── add_one/
├── Cargo.toml ← member package manifest
└── src/
└── lib.rsCargo.toml is a virtual manifest when it has only a [workspace] section and no [package]. This is the most common pattern for large projects. Alternatively, the root can be a real package that is also a workspace member — useful for small two-crate projects.Member Package Cargo.toml Files
Each member package is a normal Cargo package with its own Cargo.toml:
# adder/Cargo.toml
[package]
name = "adder"
version = "0.1.0"
edition = "2021"
[dependencies]
add_one = { path = "../add_one" }# add_one/Cargo.toml [package] name = "add_one" version = "0.1.0" edition = "2021" [dependencies] # external dependency used only by this member rand = "0.8"
Inter-Crate Dependencies Within a Workspace
To use one workspace member from another, add it as a path dependency. Cargo
resolves path dependencies relative to the package containing the Cargo.toml:
// add_one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
x + 1
}// adder/src/main.rs
use add_one::add_one;
fn main() {
let result = add_one(5);
println!("5 + 1 = {}", result); // 5 + 1 = 6
}5 + 1 = 6
Shared Cargo.lock
The workspace-level Cargo.lock records the exact version of every dependency
used by every member. This is one of the most important benefits of workspaces:
- All members use the same resolved version of shared dependencies.
- If
adderandadd_oneboth depend onrand, they will use the exact same version — no version conflicts, no duplicate compilations. - The lock file is committed to version control so every developer (and CI) builds with identical dependency versions.
Cargo.lock prevents this entirely — an important reliability guarantee for larger projects.Shared target/ Directory
All members compile into the workspace root's target/ directory. This means:
- Common dependencies are compiled once and reused by all members.
- Incremental builds are faster because cached artifacts are shared.
- Disk usage is significantly lower than having separate
target/directories per package.
Running Cargo Commands
From the workspace root, most Cargo commands operate on all members by default:
# Build all members cargo build # Test all members cargo test # Check all members (fast type-check, no binary output) cargo check # Run clippy on all members cargo clippy
To target a specific member, use the -p (short for --package) flag:
# Build only the adder package cargo build -p adder # Test only the add_one library cargo test -p add_one # Run the adder binary cargo run -p adder
workspace.dependencies — Define Versions Once
Rust 1.64 introduced workspace.dependencies: declare a dependency's version in
the workspace manifest and have all members inherit it with workspace = true.
This prevents version drift when the same crate is used by multiple members:
# my_workspace/Cargo.toml (workspace manifest)
[workspace]
members = ["adder", "add_one", "utils"]
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
rand = "0.8"
anyhow = "1.0"# add_one/Cargo.toml — inherit versions from the workspace
[package]
name = "add_one"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = { workspace = true } # uses rand = "0.8" from workspace
serde = { workspace = true } # uses serde = "1.0" from workspace
anyhow = { workspace = true }workspace.dependencies means you only need to update a version in one place when upgrading a dependency shared across members. This is the recommended approach for any workspace with three or more members.workspace.package — Share Package Metadata
Similarly, you can define package metadata once and inherit it in member
Cargo.toml files using workspace.package:
# my_workspace/Cargo.toml [workspace] members = ["adder", "add_one"] [workspace.package] version = "1.2.0" authors = ["Your Name <you@example.com>"] edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/you/my_workspace"
# adder/Cargo.toml [package] name = "adder" version.workspace = true # inherits "1.2.0" edition.workspace = true # inherits "2021" license.workspace = true repository.workspace = true
default-members
By default, Cargo operates on all workspace members. You can narrow this using
default-members — commands without -p will then target only the listed
packages:
[workspace] members = ["api", "models", "utils", "migration-tool"] default-members = ["api"] # cargo build / cargo run targets only api by default
Testing Across a Workspace
cargo test run from the workspace root runs every test in every member package.
Use -p to run tests for a specific package, and -- --test-name to run a
single test by name:
# Run all tests everywhere cargo test # Run tests for a specific member cargo test -p add_one # Run a test by name (within a specific package) cargo test -p add_one -- test_add_one # Run integration tests only cargo test -p adder --test integration_test
Real-World Example: Web Application Workspace
A common pattern is splitting a web application into focused crates:
web_app/
├── Cargo.toml ← workspace manifest
├── Cargo.lock
├── target/
├── api/ ← the HTTP server binary
│ ├── Cargo.toml
│ └── src/main.rs
├── models/ ← shared data types and database logic
│ ├── Cargo.toml
│ └── src/lib.rs
└── utils/ ← shared utilities (logging, config, etc.)
├── Cargo.toml
└── src/lib.rs# web_app/Cargo.toml
[workspace]
members = ["api", "models", "utils"]
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
anyhow = "1.0"
tracing = "0.1"# api/Cargo.toml
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
models = { path = "../models" }
utils = { path = "../utils" }
tokio = { workspace = true }
serde = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }# models/Cargo.toml
[package]
name = "models"
version = "0.1.0"
edition = "2021"
[dependencies]
utils = { path = "../utils" }
serde = { workspace = true }
sqlx = { workspace = true }
anyhow = { workspace = true }Workspace Gotchas
members — Cargo does not automatically discover subdirectories. Forgetting to add a new package means Cargo ignores it entirely.Each member still has its own version number — the workspace does not enforce uniform versioning (though
workspace.packagecan help)You cannot publish a workspace virtual manifest (one with no
[package]) to crates.io — only individual member packages are publishablePath dependencies between members work locally, but published crates must use version dependencies instead
Running
cargo publishmust be done per-member withcargo publish -p package_name
Quick Reference
Command | What it does |
|---|---|
cargo build | Build all workspace members |
cargo build -p name | Build one specific member |
cargo test | Test all workspace members |
cargo test -p name | Test one specific member |
cargo run -p name | Run a specific binary member |
cargo check | Type-check all members (fast) |
cargo clippy | Lint all members |
cargo update | Update Cargo.lock across all members |
Cargo.lock guarantees consistent dependency versions across every package. A shared target/ directory means common dependencies are compiled only once. workspace.dependencies lets you update a shared dependency version in one place. Use -p package_nameto target individual members with any Cargo command.