RustCargo Workspaces

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.lock file 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.toml that 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:

Bash
# 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

TOML
# my_workspace/Cargo.toml  (the workspace manifest)
[workspace]
members = [
    "adder",
    "add_one",
]

The resulting directory structure looks like this:

Bash
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.rs
Note
The root Cargo.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:

TOML
# adder/Cargo.toml
[package]
name    = "adder"
version = "0.1.0"
edition = "2021"

[dependencies]
add_one = { path = "../add_one" }

TOML
# 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:

RUST
// add_one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}

RUST
// 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 adder and add_one both depend on rand, 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.
Tip
Without a workspace, two separate packages depending on the same crate might resolve to different patch versions. The shared 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:

Bash
# 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:

Bash
# 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:

TOML
# 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"

TOML
# 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 }
Tip
Using 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:

TOML
# 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"

TOML
# 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:

TOML
[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:

Bash
# 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:

Bash
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

TOML
# 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"

TOML
# 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 }

TOML
# 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
Warning
Each workspace member must be explicitly listed in 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.package can help)

  • You cannot publish a workspace virtual manifest (one with no [package]) to crates.io — only individual member packages are publishable

  • Path dependencies between members work locally, but published crates must use version dependencies instead

  • Running cargo publish must be done per-member with cargo 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

Success
Cargo workspaces are the solution to the "growing project" problem in Rust. A shared 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.