Rust Notes: Workspaces
A practical guide to Rust Cargo workspaces.
Rust Cargo Workspaces
A Cargo workspace is a collection of Rust packages (crates) that Cargo manages together.
Workspaces are especially useful for larger Rust projects where the code is split into multiple crates.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
my-project/
├── Cargo.toml
├── Cargo.lock
├── crates/
│ ├── core/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ │
│ ├── network/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ │
│ └── cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
└── target/
Each directory can contain an independent Cargo package, while the workspace allows Cargo to manage all of them together.
Cargo workspaces provide a shared Cargo.lock, shared target directory, workspace-wide commands, and mechanisms for sharing package metadata and dependency declarations.
1. Why Use a Workspace?
Suppose we have a large project:
1
2
3
4
5
6
my-vmm/
├── vmm-core/
├── vmm-memory/
├── vmm-device/
├── vmm-net/
└── vmm-cli/
Each component could be its own crate.
Without a workspace, each crate would be managed independently.
With a workspace:
1
2
3
4
5
6
7
8
9
my-vmm/
├── Cargo.toml
├── Cargo.lock
├── target/
├── vmm-core/
├── vmm-memory/
├── vmm-device/
├── vmm-net/
└── vmm-cli/
Cargo can manage all of them as one project.
For example:
1
2
3
cargo check --workspace
cargo test --workspace
cargo build --workspace
This is one of the main reasons workspaces are useful for projects such as libraries, applications split into components, and large systems projects.
2. Workspace Does Not Mean One Crate
This distinction is important:
1
2
3
4
5
6
7
Workspace
|
+-- Package / crate A
|
+-- Package / crate B
|
+-- Package / crate C
A workspace is not itself necessarily a crate.
It is a Cargo-level grouping of packages.
Each member still has its own:
1
2
Cargo.toml
src/
and its own package identity.
For example:
1
2
3
4
5
6
7
8
9
10
workspace/
├── Cargo.toml
│
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
│
└── cli/
├── Cargo.toml
└── src/main.rs
core and cli are still separate packages.
3. The Workspace Root
The workspace normally has a root Cargo.toml.
A simple virtual workspace can look like:
1
2
3
4
5
6
7
[workspace]
resolver = "3"
members = [
"core",
"cli",
]
This root Cargo.toml only describes the workspace.
It does not need a [package] section.
This is called a virtual workspace.
The current Rust Book uses resolver = "3" in its workspace examples.
4. Virtual Workspace
A virtual workspace has no package of its own.
Example:
1
2
3
4
5
6
7
8
[workspace]
resolver = "3"
members = [
"crates/core",
"crates/network",
"crates/cli",
]
Directory structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
project/
├── Cargo.toml
│
└── crates/
├── core/
│ ├── Cargo.toml
│ └── src/
│
├── network/
│ ├── Cargo.toml
│ └── src/
│
└── cli/
├── Cargo.toml
└── src/
The root is only the workspace.
The actual packages are:
1
2
3
core
network
cli
5. Workspace with a Root Package
A workspace root can also be a package.
For example:
1
2
3
4
5
6
7
8
9
10
[package]
name = "my-project"
version = "0.1.0"
edition = "2024"
[workspace]
members = [
"core",
"cli",
]
Now the root has two roles:
1
2
3
workspace root
+
package
This is different from a virtual workspace.
6. members
The members field specifies which packages belong to the workspace.
1
2
3
4
5
6
[workspace]
members = [
"core",
"network",
"cli",
]
You can also use paths:
1
2
3
4
5
6
[workspace]
members = [
"crates/core",
"crates/network",
"tools/cli",
]
And globs:
1
2
3
4
[workspace]
members = [
"crates/*",
]
Cargo supports glob patterns such as * and ? for workspace members.
7. exclude
Sometimes a directory matches a workspace pattern but should not be part of the workspace.
For example:
1
2
3
4
5
6
7
8
[workspace]
members = [
"crates/*",
]
exclude = [
"crates/experimental",
]
So:
1
2
3
4
5
crates/
├── core/
├── network/
├── storage/
└── experimental/
becomes:
1
2
3
4
5
6
7
workspace members:
core
network
storage
excluded:
experimental
8. Important: Path Dependencies and Workspace Membership
Suppose:
1
2
[dependencies]
core = { path = "../core" }
and core is located inside the workspace directory.
Cargo can automatically treat path dependencies inside the workspace as workspace members unless excluded.
This is useful to remember when debugging workspace membership.
9. Package vs Crate
These terms are often confused.
A package is a Cargo concept.
A package contains a Cargo.toml and can contain one or more crates.
For example:
1
2
3
4
5
package
├── Cargo.toml
└── src/
├── lib.rs
└── main.rs
The package may contain:
1
2
library crate
binary crate
A workspace contains packages:
1
2
3
4
workspace
├── package A
├── package B
└── package C
For everyday Cargo usage, you will often hear “crate” and “package” used loosely, but they are technically different concepts.
10. Workspace Dependency Management
One of the most useful workspace features is centralizing dependency versions.
Without workspace dependency inheritance, you might have:
1
2
3
4
5
# core/Cargo.toml
[dependencies]
serde = "1.0"
tokio = "1"
and:
1
2
3
4
5
# network/Cargo.toml
[dependencies]
serde = "1.0"
tokio = "1"
and:
1
2
3
4
5
# cli/Cargo.toml
[dependencies]
serde = "1.0"
tokio = "1"
This repeats information.
A workspace can centralize it.
11. [workspace.dependencies]
At the workspace root:
1
2
3
4
5
6
7
8
9
10
11
[workspace]
members = [
"core",
"network",
"cli",
]
[workspace.dependencies]
serde = "1"
tokio = "1"
anyhow = "1"
Then a member can inherit the dependency:
1
2
3
[dependencies]
serde = { workspace = true }
tokio = { workspace = true }
Another crate can do:
1
2
3
[dependencies]
serde = { workspace = true }
anyhow = { workspace = true }
The dependency version is therefore defined centrally.
Cargo officially supports workspace dependency inheritance through [workspace.dependencies] and workspace = true.
12. Workspace Dependencies Do Not Automatically Become Dependencies
This is a very important point.
If the root contains:
1
2
[workspace.dependencies]
serde = "1"
that does not mean every crate automatically gets serde.
A member still needs:
1
2
[dependencies]
serde = { workspace = true }
This is intentional.
Each crate explicitly declares which dependencies it uses.
Think of it as:
1
2
3
4
5
6
7
8
9
workspace.dependencies
|
| defines shared version/configuration
v
member Cargo.toml
|
| workspace = true
v
actual dependency
13. Why Doesn’t the Workspace Automatically Add Dependencies?
Because each crate should remain explicit about its own dependencies.
Suppose:
1
2
3
4
workspace
├── core
├── network
└── cli
Only network uses:
1
reqwest
It would be undesirable for core and cli to automatically depend on reqwest.
Instead:
1
2
3
4
# workspace Cargo.toml
[workspace.dependencies]
reqwest = "0.12"
and:
1
2
3
4
# network/Cargo.toml
[dependencies]
reqwest = { workspace = true }
Only network depends on it.
This keeps crate boundaries clear.
14. Workspace Dependency with Features
The workspace can define common dependency configuration:
1
2
3
4
5
[workspace.dependencies]
tokio = {
version = "1",
features = ["rt", "macros"]
}
A member can inherit it:
1
2
[dependencies]
tokio = { workspace = true }
A member can also add features:
1
2
3
4
5
[dependencies]
tokio = {
workspace = true,
features = ["net", "time"]
}
Workspace dependency features are additive with features specified by the member.
15. Workspace Dependencies and Optional Dependencies
Workspace dependencies cannot themselves be declared as optional.
For example, this is not allowed at the workspace level:
1
2
3
4
5
6
[workspace.dependencies]
foo = {
version = "1",
optional = true
}
Optionality belongs to the member’s own dependency declaration.
16. Workspace Package Metadata
You can also centralize package metadata.
For example:
1
2
3
4
5
6
7
8
9
10
11
[workspace]
members = [
"core",
"network",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/example/project"
Then a member can inherit:
1
2
3
4
5
6
7
[package]
name = "core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
Cargo supports workspace inheritance for package metadata such as version, edition, license, repository, description, rust-version, and other package fields.
17. Why Package Metadata Inheritance Is Useful
Imagine a project with 20 crates.
Without inheritance:
1
2
3
4
5
[package]
version = "0.5.0"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/example/project"
would be repeated across many crates.
With workspace inheritance:
1
2
3
4
5
[workspace.package]
version = "0.5.0"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/example/project"
and each crate can use:
1
2
3
4
5
6
7
[package]
name = "my-crate"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
This makes large repositories easier to maintain.
18. Workspace Members Can Depend on Each Other
Suppose:
1
2
3
4
project/
├── Cargo.toml
├── core/
└── cli/
and:
1
2
3
4
5
[workspace]
members = [
"core",
"cli",
]
cli can depend on core:
1
2
[dependencies]
core = { path = "../core" }
The dependency graph becomes:
1
2
3
4
cli
|
v
core
Cargo understands that both packages belong to the same workspace.
19. Workspace Dependency vs Path Dependency
These are different concepts.
A path dependency:
1
2
[dependencies]
core = { path = "../core" }
means:
Find the
corepackage at this filesystem path.
Workspace membership means:
Manage this package as part of the workspace.
You can combine the concepts.
For example:
1
2
3
4
5
[workspace]
members = [
"core",
"cli",
]
and:
1
2
3
4
# cli/Cargo.toml
[dependencies]
core = { path = "../core" }
20. Workspace Commands
One of the biggest practical benefits is running commands across members.
Check everything
1
cargo check --workspace
Build everything
1
cargo build --workspace
Test everything
1
cargo test --workspace
Run Clippy across the workspace
1
cargo clippy --workspace
Build release versions
1
cargo build --workspace --release
21. Selecting One Package
You don’t always want to build everything.
Use:
1
cargo build -p core
or:
1
cargo test -p core
The -p option selects a package.
For example:
1
cargo test -p network
tests only the network package.
Cargo supports -p/--package and --workspace for selecting packages.
22. cargo run -p
If the workspace contains a binary crate:
1
2
3
4
workspace/
├── cli/
├── server/
└── tools/
you can run:
1
cargo run -p cli
or:
1
cargo run -p server
This is useful when the repository contains several binaries.
23. default-members
A workspace can define which packages should be operated on by default when commands are run from the workspace root.
Example:
1
2
3
4
5
6
7
8
9
10
11
[workspace]
members = [
"core",
"network",
"cli",
]
default-members = [
"core",
"cli",
]
Now commands from the workspace root can operate on the default members instead of every member.
You can still explicitly use:
1
cargo build --workspace
to operate on all members.
Cargo documents default-members as the package selection used when operating from the workspace root without explicitly selecting packages.
24. Workspace Cargo.lock
A major workspace feature is that packages share a single Cargo.lock.
For example:
1
2
3
4
5
6
workspace/
├── Cargo.toml
├── Cargo.lock
├── core/
├── network/
└── cli/
There is normally one lockfile at the workspace root.
This gives the project a single resolved dependency graph.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
core
|
+-- serde 1.x
|
+-- tokio 1.x
network
|
+-- serde 1.x
|
+-- tokio 1.x
cli
|
+-- serde 1.x
Cargo resolves these dependencies together.
Cargo workspaces share a common Cargo.lock and a common output directory by default.
25. Workspace target
Workspace members normally share the same build output directory:
1
2
workspace/
└── target/
instead of:
1
2
3
core/target/
network/target/
cli/target/
This is useful because Cargo can reuse build artifacts across the workspace.
26. The Workspace Dependency Graph
A useful way to think about a large workspace is as a graph.
For example:
1
2
3
4
5
6
7
8
9
10
cli
|
v
service
/ \
v v
network storage
| |
v v
core core
Each node is a crate/package.
The workspace gives Cargo one place to manage the complete project.
This is particularly useful for systems projects where different crates represent different layers.
27. Example: Systems Project
A project similar to a VMM might look like:
1
2
3
4
5
6
7
8
9
vmm/
├── Cargo.toml
│
└── crates/
├── vmm-core/
├── vmm-memory/
├── vmm-device/
├── vmm-net/
└── vmm-cli/
Root:
1
2
3
4
5
6
7
8
9
10
[workspace]
resolver = "3"
members = [
"crates/vmm-core",
"crates/vmm-memory",
"crates/vmm-device",
"crates/vmm-net",
"crates/vmm-cli",
]
Shared dependencies:
1
2
3
4
[workspace.dependencies]
anyhow = "1"
log = "0.4"
thiserror = "2"
Then:
1
2
3
4
5
6
7
8
9
10
# crates/vmm-core/Cargo.toml
[package]
name = "vmm-core"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
log = { workspace = true }
Another crate:
1
2
3
4
5
6
7
8
9
10
# crates/vmm-net/Cargo.toml
[package]
name = "vmm-net"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
log = { workspace = true }
And the CLI:
1
2
3
4
5
6
7
8
9
10
11
# crates/vmm-cli/Cargo.toml
[package]
name = "vmm-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
vmm-core = { path = "../vmm-core" }
vmm-net = { path = "../vmm-net" }
This gives us:
1
2
3
4
5
6
7
vmm-cli
/ \
v v
vmm-core vmm-net
\ /
v v
shared dependencies
28. Workspace Resolver
The workspace can specify Cargo’s dependency resolver:
1
2
[workspace]
resolver = "3"
The resolver determines how Cargo resolves dependencies and features across the dependency graph.
For modern Rust projects, explicitly specifying the resolver in the workspace root makes the intended workspace configuration clear.
Example:
1
2
3
4
5
6
[workspace]
resolver = "3"
members = [
"crates/core",
"crates/network",
]
29. Workspace Profiles
Build profiles such as release configuration are controlled from the workspace root.
For example:
1
2
3
4
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
This applies to the workspace’s build profile.
A useful principle is:
1
2
3
4
5
6
workspace root
|
+-- dependency resolution
+-- package metadata
+-- build profiles
+-- workspace membership
Member manifests primarily describe the individual package.
30. Workspace [patch]
The workspace root is also where [patch] overrides are configured.
For example:
1
2
3
4
[patch.crates-io]
my-library = {
git = "https://github.com/example/my-library"
}
This can be useful when testing a local or Git version of a dependency.
The important point is that [patch] is a workspace-root configuration.
31. Workspace Cargo.toml as Project Configuration
A useful mental model is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
workspace Cargo.toml
|
+-- Which packages?
| members
|
+-- Which packages are default?
| default-members
|
+-- Shared dependency versions?
| workspace.dependencies
|
+-- Shared package metadata?
| workspace.package
|
+-- Dependency overrides?
| patch
|
+-- Build configuration?
profile
Each member’s Cargo.toml then describes:
1
2
3
4
5
6
7
individual package
|
+-- package name
+-- package-specific dependencies
+-- targets
+-- features
+-- package-specific configuration
32. Common Workspace Structure
A common structure for a larger Rust project is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
project/
├── Cargo.toml
├── Cargo.lock
├── README.md
│
├── crates/
│ ├── core/
│ │ ├── Cargo.toml
│ │ └── src/
│ │
│ ├── storage/
│ │ ├── Cargo.toml
│ │ └── src/
│ │
│ ├── network/
│ │ ├── Cargo.toml
│ │ └── src/
│ │
│ └── api/
│ ├── Cargo.toml
│ └── src/
│
└── tools/
└── cli/
├── Cargo.toml
└── src/
Root:
1
2
3
4
5
6
7
8
9
10
11
12
13
[workspace]
resolver = "3"
members = [
"crates/*",
"tools/*",
]
[workspace.dependencies]
anyhow = "1"
thiserror = "2"
tokio = "1"
serde = "1"
33. Workspace and Crate Independence
A workspace does not merge crates together.
For example:
1
2
3
core
network
cli
remain separate crates.
network cannot use a type from core simply because they are in the same workspace.
It needs an explicit dependency:
1
2
[dependencies]
core = { path = "../core" }
Then Rust code can use it:
1
use core::SomeType;
Workspace membership and crate dependencies are therefore separate concepts.
34. Workspace vs Module
Don’t confuse a workspace with a Rust module.
A module:
1
mod network;
is part of a crate’s source-code organization.
A workspace:
1
2
3
4
[workspace]
members = [
"network",
]
is Cargo’s project/package organization.
Think:
1
2
3
4
5
6
7
8
9
10
11
Cargo level:
Workspace
|
+-- Package
|
+-- Crates
|
+-- Modules
|
+-- Functions / Types
35. Workspace vs Library
A library crate:
1
cargo new --lib core
creates a library package.
A workspace:
1
2
[workspace]
members = ["core"]
manages that package together with other packages.
Therefore:
1
library != workspace
A workspace can contain:
1
2
3
4
5
libraries
binaries
build tools
examples
tests
depending on how the project is organized.
36. Creating a Workspace
Start with:
1
2
mkdir my-project
cd my-project
Create the root manifest:
1
2
3
4
5
6
7
[workspace]
resolver = "3"
members = [
"core",
"cli",
]
Create the packages:
1
2
cargo new core --lib
cargo new cli
The resulting structure:
1
2
3
4
5
6
7
8
9
my-project/
├── Cargo.toml
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
│
└── cli/
├── Cargo.toml
└── src/main.rs
37. Adding an Internal Dependency
Suppose cli uses core.
In:
1
cli/Cargo.toml
add:
1
2
[dependencies]
core = { path = "../core" }
Then:
1
use core::some_function;
The dependency is now part of the crate dependency graph.
38. Building the Workspace
From the workspace root:
1
cargo build --workspace
Cargo builds all selected workspace packages.
For development:
1
cargo check --workspace
is often faster because it checks the code without producing the final binaries.
39. Testing a Workspace
Run all tests:
1
cargo test --workspace
Run one package:
1
cargo test -p core
Run tests repeatedly while developing with an external tool such as cargo-watch if desired.
40. Inspecting the Workspace
Useful Cargo commands include:
1
cargo metadata
This is particularly useful when working with large workspaces because it exposes Cargo’s package and dependency metadata in machine-readable form.
For example:
1
cargo metadata --format-version 1
can show:
1
2
3
4
5
packages
workspace_members
workspace_root
target_directory
dependencies
This is useful for tooling and CI systems.
41. Workspace Commands Cheat Sheet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Check all members
cargo check --workspace
# Build all members
cargo build --workspace
# Release build
cargo build --workspace --release
# Test all members
cargo test --workspace
# Run clippy
cargo clippy --workspace
# Build one package
cargo build -p my-crate
# Test one package
cargo test -p my-crate
# Run one binary package
cargo run -p my-cli
# Inspect workspace metadata
cargo metadata --format-version 1
42. Common Mistakes
Mistake 1: Assuming workspace dependencies are automatically available
This:
1
2
[workspace.dependencies]
serde = "1"
does not automatically make serde available to every crate.
You still need:
1
2
[dependencies]
serde = { workspace = true }
Mistake 2: Forgetting a member
If a package isn’t included through members or another workspace membership mechanism, Cargo may not treat it as part of the workspace.
Check:
1
cargo metadata --format-version 1
Mistake 3: Confusing path dependency with workspace membership
This:
1
core = { path = "../core" }
describes a dependency.
This:
1
2
[workspace]
members = ["core"]
describes workspace membership.
They solve different problems.
Mistake 4: Putting workspace configuration in the wrong manifest
Some settings belong at the workspace root.
For example:
1
[workspace.dependencies]
belongs in the workspace root.
Likewise, workspace-level [patch] and [profile] configuration belongs in the root manifest.
43. Workspace Design Principle
A good workspace usually has a clear dependency direction.
For example:
1
2
3
4
5
6
7
8
9
10
cli
|
v
api
/ \
v v
network storage
\ /
v v
core
Avoid unnecessary dependencies in the opposite direction:
1
2
3
4
core
|
v
cli
because low-level crates should generally not depend on high-level application crates.
A workspace makes it easy to create many crates, but it does not automatically create good architecture.
The dependency graph still needs to be designed carefully.
44. Workspace in Large Rust Projects
Workspaces become particularly valuable when a project contains:
- multiple libraries
- multiple binaries
- reusable internal crates
- platform-specific components
- test utilities
- command-line tools
- benchmarks
- procedural macros
- system-level components
For example:
1
2
3
4
5
6
7
rust-vmm/
├── vmm-core/
├── vmm-memory/
├── vmm-device/
├── vmm-net/
├── vmm-block/
└── tools/
Each crate can evolve independently while Cargo provides a unified project structure.
45. Workspace Mental Model
The most useful mental model is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
WORKSPACE
|
+---------------+---------------+
| | |
core network cli
| | |
+-------+-------+-------+-------+
|
dependency graph
|
+-------+-------+
| |
external internal
dependencies dependencies
The workspace is the container for the project.
The crates remain independent.
Cargo uses the workspace to coordinate:
1
2
3
4
5
6
7
8
dependency resolution
Cargo.lock
build artifacts
package selection
shared metadata
shared dependencies
profiles
workspace-wide commands
46. Key Takeaways
- A Cargo workspace is a collection of packages managed together.
- A workspace can contain many independent crates.
- A workspace does not merge crates into one crate.
- Each member still has its own
Cargo.toml. - A virtual workspace has a root
Cargo.tomlwithout[package]. membersdefines workspace members.excludecan remove packages from workspace membership.default-memberscontrols the default package selection from the workspace root.- Workspace members normally share one
Cargo.lock. - Workspace members normally share one
targetdirectory. [workspace.dependencies]centralizes dependency declarations.workspace = truelets individual crates inherit workspace dependencies.- Workspace dependencies are not automatically available to every crate.
[workspace.package]can centralize package metadata such as version, edition, license, and repository.-pselects a particular package.--workspaceselects all workspace members.path = "../crate"creates an internal path dependency; workspace membership is a separate concept.- Workspace-level
[profile]and[patch]configuration belongs in the workspace root. cargo metadatais useful for understanding and tooling around large workspaces.- A workspace is primarily a Cargo/project organization mechanism, while modules organize code inside a crate.
The simplest way to remember it is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Workspace
↓
manages multiple packages
Package
↓
contains one or more crates
Crate
↓
contains Rust code
Dependency
↓
connects crates/packages together
For large Rust projects, the workspace is the layer that turns a collection of independent crates into a single manageable repository.