Post

Rust Notes: Workspaces

A practical guide to Rust Cargo workspaces.

Rust Notes: 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 core package 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.toml without [package].
  • members defines workspace members.
  • exclude can remove packages from workspace membership.
  • default-members controls the default package selection from the workspace root.
  • Workspace members normally share one Cargo.lock.
  • Workspace members normally share one target directory.
  • [workspace.dependencies] centralizes dependency declarations.
  • workspace = true lets 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.
  • -p selects a particular package.
  • --workspace selects 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 metadata is 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.

This post is licensed under CC BY 4.0 by the author.