Cargo Workspaces
How to organize multiple related Rust packages that evolve together using Cargo workspaces, including creation, package management, and dependency sharing.
As a Rust project grows, the single library or binary crate you started with may start to feel cramped. You might want to extract a reusable core library, add a separate command-line tool, or split a monolithic crate into several smaller ones that each have a clear purpose. These pieces still belong together—they are developed in the same repository, released together, and should use the exact same versions of shared dependencies. Cargo workspaces are the built-in mechanism for this kind of multi-crate project.
A workspace is a collection of Rust packages that share a single Cargo.lock file and a single build output directory (target/). Sharing these resources means all member crates are compiled with consistent dependency versions, and Cargo can avoid rebuilding the same dependency multiple times. Workspaces are Cargo’s answer to what other ecosystems call a monorepo for Rust code.
Think of a workspace as a big folder that contains several smaller Rust projects, each with its own Cargo.toml, but all managed together. The workspace itself is just configuration—it has no code of its own. It tells Cargo: “these packages belong together; treat them as one unit.”
Creating a Workspace
A workspace is defined by a root Cargo.toml that contains a [workspace] section instead of a [package] section. This is called a virtual manifest because it describes the workspace but does not itself produce a crate. The steps below build a small workspace with one binary crate and two library crates—the classic “adder” example.
Create the workspace root directory and virtual manifest
First, make a new directory for the workspace and create a Cargo.toml that defines the workspace and its member list.
[workspace]
resolver = "2"
members = []
Setting resolver = "2" enables the edition-2021 resolver, which handles feature unification across the workspace more predictably. The members list starts empty; Cargo will add members automatically when we create packages inside this directory.
Resolved edition:
The resolver = "2" setting is the recommended default for all new workspaces. It avoids surprising behavior when crate features are merged across the workspace. If you are using the 2021 edition or later, this is already the default, but being explicit prevents accidental downgrades.
Add the first binary crate
Run cargo new inside the workspace root to create a binary crate called adder. Cargo detects the parent workspace manifest and adds the new package to the members list automatically.
cargo new adder
After the command, the root Cargo.toml will look like this:
[workspace]
resolver = "2"
members = ["adder"]
The directory structure now contains a shared target/ directory and a single package:
.
├── Cargo.lock
├── Cargo.toml
├── adder
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── target
Shared target directory:
Even if you run cargo build from inside adder/, the compiled artifacts go into the workspace’s top-level target/ directory, not adder/target/. This sharing avoids recompiling the same crate multiple times when multiple workspace members depend on it.
Add a library crate
Create a library crate named add_one that will contain a simple function. Use the --lib flag to generate a library instead of a binary.
cargo new add_one --lib
The root Cargo.toml now lists both members:
[workspace]
resolver = "2"
members = ["adder", "add_one"]
In add_one/src/lib.rs, define a public function:
pub fn add_one(x: i32) -> i32 {
x + 1
}
This crate is now part of the workspace but does not yet have any relationship with adder. Dependencies between workspace crates are not automatic—you must declare them explicitly, just like any other dependency.
The workspace is now structurally complete. A build (cargo build) compiles all member crates in dependency order, placing the final artifacts in the shared target/ directory. The binary at adder/src/main.rs still prints “Hello, world!”—connecting the crates is the next step.
Managing Multiple Packages in a Workspace
Workspaces become useful when member packages depend on each other. Cargo does not assume that all members want to use every other member; each dependency relationship must be declared in the consuming crate’s Cargo.toml using a path dependency.
Wiring crates together
To let the adder binary use the add_one library, add a dependency entry in adder/Cargo.toml that points to the add_one crate via its relative path:
[dependencies]
add_one = { path = "../add_one" }
Now adder/src/main.rs can call the add_one function:
fn main() {
let num = 10;
println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Building the workspace from the root compiles add_one first, then adder, and links them together:
cargo build
Compiling add_one v0.1.0 (file:///projects/add/add_one)
Compiling adder v0.1.0 (file:///projects/add/adder)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s
Notice that add_one is compiled before adder because of the dependency. The shared target/ directory means add_one is compiled only once; if you later add a third crate that also depends on add_one, it will reuse the already-built artifact.
Missing path dependency:
If you forget to add the path dependency and try to use add_one; directly, Cargo will look for an external crate named add_one on crates.io. The error message will say “no external crate \u003ccode\u003eadd_one\u003c/code\u003e” because workspace membership alone does not create a dependency.
Running and testing specific packages
In a workspace, many Cargo commands default to operating on the package in the current directory. From the workspace root, there is no default package, so you must use the -p (or --package) flag to target a specific member.
# Run the adder binary from the workspace root
cargo run -p adder
The output confirms everything is wired correctly:
Hello, world! 10 plus one is 11!
Workspace-level testing works the same way. Running cargo test from the root runs all tests across all member crates. You can also target a single package:
# Run tests for all crates
cargo test
# Run tests only for add_one
cargo test -p add_one
If the add_one crate contains a test module, cargo test from the root will run those tests along with any tests in adder. The output distinguishes which package each test belongs to, making it easy to spot failures.
Adding more crates
You can continue adding library or binary crates with cargo new, and they will automatically join the workspace. Suppose you create an add_two library that provides an add_two function, and adder depends on it. The process is identical: generate the crate, implement the function, add a path dependency in adder/Cargo.toml, and call the new function from main.
Workspaces scale to dozens of crates. Many large Rust projects, such as rust-analyzer, use a flat layout where each crate lives in a directory with the same name as the crate, directly under the workspace root or inside a crates/ folder. The Cargo namespace of crates is flat—there is no hierarchical grouping beyond the workspace itself—so a flat directory structure avoids the maintenance burden of nested folders that may become inconsistent over time.
Dependency Sharing
A single Cargo.lock at the workspace root is the mechanism that ensures every crate in the workspace uses identical versions of all dependencies. When multiple crates depend on the same external crate, Cargo resolves them to a single version (if their version requirements are compatible) and records that version once. No duplicate copies are downloaded or compiled.
Specifying workspace-level dependencies
To avoid repeating the same version string across multiple member Cargo.toml files, Cargo supports workspace-level dependency declarations via the [workspace.dependencies] table. Define the version (and any features) once, then each member crate can inherit that definition.
Root Cargo.toml with shared dependencies:
[workspace]
resolver = "2"
members = ["adder", "add_one"]
[workspace.dependencies]
rand = "0.8.5"
serde = { version = "1.0", features = ["derive"] }
A member crate that needs rand can then reference it as:
[dependencies]
rand = { workspace = true }
The workspace = true key tells Cargo to pull the version and feature specification from the workspace root. If you later upgrade rand to 0.9, you change one line in the root manifest, and every member that uses rand gets the new version on the next build.
This pattern is optional but becomes valuable as soon as a workspace contains more than two crates. It prevents version drift where one crate uses rand 0.8.5 and another uses rand 0.8.4, which could lead to subtle type mismatches or compile errors when those crates interact.
Inheriting features:
When a dependency is inherited with workspace = true, the member crate can still add extra features locally. For example, rand = { workspace = true, features = ["std_rng"] } will combine the workspace definition with the additional features. However, if the workspace already defines default-features = false, the member crate will not silently re-enable default features; the workspace definition takes precedence.
Adding an external dependency to a single crate
External dependencies are not shared automatically just because they exist somewhere in the workspace. If add_one depends on rand but adder does not declare it, adder cannot use rand—even though rand is compiled for the workspace.
When you add rand to add_one/Cargo.toml:
[dependencies]
rand = "0.8.5"
Cargo will download and compile rand. The top-level Cargo.lock records the resolved version. If you then try to write use rand; in adder/src/main.rs without adding rand to adder/Cargo.toml, the compiler will produce an error:
error[E0432]: unresolved import `rand`
--> adder/src/main.rs:2:5
|
2 | use rand;
| ^^^^ no external crate `rand`
To make rand available in adder, declare it as a dependency in adder/Cargo.toml as well. Cargo will not download another copy; it resolves the same version already recorded in the lock file, and the existing compiled artifact is reused.
Version compatibility across the workspace
Cargo resolves dependency versions conservatively. If two workspace crates specify compatible version requirements (e.g., "0.8.5" and "0.8.4" both satisfy ^0.8), they will share a single resolved version—typically the highest that satisfies all requirements. If requirements are truly incompatible (e.g., one crate requires 0.7 and another requires 0.8), Cargo will include both versions, and the lock file will record both. This situation is rare in practice, and using workspace-level dependency declarations ([workspace.dependencies]) makes it even less likely.
Building and testing with shared dependencies
All standard Cargo commands respect the workspace structure. cargo build --workspace builds every member crate. cargo test --workspace runs tests across all crates. For targeted work, -p narrows the scope:
# Build only the add_one library
cargo build -p add_one
# Run clippy on all workspace crates
cargo clippy --workspace
The shared target directory ensures that incremental compilation benefits all crates—changing a single source file in add_one does not trigger a rebuild of adder unless the public API of add_one actually changed.
Consistent dependencies, no surprises:
If you run cargo test --workspace and all tests pass, you can be confident that every crate in the workspace was tested against the same set of dependency versions. There is no risk of a dependency working in one crate but breaking another because of a version mismatch within the same build.
Workspace layout and maintainability
A common pattern for medium to large workspaces is to keep the root manifest virtual (no [package] section) and place all member crates inside a crates/ directory or directly at the top level. The flat structure—where each crate’s directory name matches the crate name—makes navigation and scripting predictable. Internal crates that are not intended for publication can use version = "0.0.0" in their Cargo.toml to signal that they are workspace-only.
If some crates are public libraries with strict semantic versioning, consider separating them into a libs/ directory to enforce that they do not accidentally depend on internal-only crates. This is an organizational convention, not a Cargo requirement.
Summary
Cargo workspaces solve a coordination problem: when a project grows into several crates that must evolve together, the workspace keeps dependency versions consistent, avoids duplicate compilation, and provides a single set of commands to build, test, and lint everything at once. The key mechanisms are the shared Cargo.lock, the shared target/ directory, and the optional [workspace.dependencies] table for centralizing version specifications.