Creating a Workspace
A step-by-step guide to creating a Cargo workspace, a container that lets you manage multiple crates under a shared build system and lock file.
A single Rust crate is enough for many projects, but as the codebase grows, you often want to split it into multiple crates—a library, a CLI, maybe a separate crate for shared data types. The friction comes when those crates need to be built, tested, and versioned together. A Cargo workspace removes that friction by giving all the crates one shared Cargo.lock file and one shared output directory. This ensures that every crate sees the exact same dependency versions and that compiled artifacts are reused, so you do not rebuild unchanged crates.
Why Workspaces Exist
When you have two crates that depend on each other—say, a binary that calls a library—building them in separate projects means each project compiles its own copy of the dependency. If the library changes, the binary must be told to rebuild, and there is no automatic coordination. Worse, the two projects might resolve the same external dependency to different versions, which leads to subtle runtime failures when they are linked together.
A workspace wraps those crates into a single unit for Cargo. All members share the same build cache, so compiling a library crate once makes the result available to every binary crate that depends on it. And the single Cargo.lock at the workspace root guarantees that every crate uses identical versions of every external dependency.
Not every project needs a workspace:
A single crate with modules is still the right choice for many applications. Introduce a workspace only when you genuinely need to compile multiple crates together under a shared lock file—for example, a monorepo with separate libraries, binaries, or integration-test crates.
The Two Kinds of Workspace Roots
Before you create anything, decide whether the workspace will have its own top-level package.
Virtual manifest (no root package)
The workspace root contains only a [workspace] section. This is the most common setup when the workspace is a container for several equal crates and there is no obvious “primary” package. A virtual manifest requires you to set the resolver explicitly because there is no package.edition to infer it from.
[workspace]
resolver = "3"
members = ["adder", "add_one"]
Root package workspace
The workspace root is itself a crate, with a [package] section sitting above [workspace]. This is useful when you want a main binary or library that orchestrates the other members. In a root-package workspace, running bare cargo build from the root will build that root package, not every member—this is controlled by the default-members field.
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["utils", "database"]
The rest of this guide builds a virtual workspace because it keeps the root directory clean and is the recommended default for multi-crate projects that do not need a primary root crate.
Creating a Workspace, Step by Step
The example workspace will contain one binary crate (adder) and one library crate (add_one). The binary depends on the library. Every step modifies real files you can inspect after running the commands.
Create the workspace directory and root manifest
Start by creating an empty directory and writing the minimal Cargo.toml that declares the workspace.
mkdir add
cd add
Now create Cargo.toml with this content:
[workspace]
resolver = "3"
members = []
The resolver = "3" line tells Cargo to use the latest dependency resolution algorithm, which handles feature unification across workspace members more predictably. Without this line in a virtual workspace, Cargo will not know which resolver version to use and will emit a hard error.
Missing resolver in a virtual workspace causes a build error:
If you omit the resolver field in a virtual manifest, Cargo will refuse to build with a message like error: failed to parse manifest … `workspace` … missing field `resolver`. Always set it when there is no root package.
Add the binary crate
Run cargo new inside the workspace directory. Cargo will create the crate and automatically register it in the workspace members list.
cargo new adder
After this command, the root Cargo.toml now contains:
[workspace]
resolver = "3"
members = ["adder"]
The generated adder/src/main.rs prints “Hello, world!” for now.
Add the library crate
Create a second member—a library crate named add_one.
cargo new add_one --lib
The root Cargo.toml automatically updates:
[workspace]
resolver = "3"
members = ["adder", "add_one"]
At this point your directory tree looks like this:
add/
├── Cargo.toml ← workspace root
├── adder/
│ ├── Cargo.toml
│ └── src/main.rs
└── add_one/
├── Cargo.toml
└── src/lib.rs
Notice there is no target directory yet—that appears after the first build.
Write the library function and make it public
Open add_one/src/lib.rs and replace its contents with a function that adds one to an integer. The function must be pub so the binary crate can use it.
pub fn add_one(x: i32) -> i32 {
x + 1
}
Declare the dependency between crates
Cargo does not assume that workspace members depend on each other—you must be explicit. In adder/Cargo.toml, add a path dependency pointing to the library crate.
[package]
name = "adder"
version = "0.1.0"
edition = "2021"
[dependencies]
add_one = { path = "../add_one" }
Now modify adder/src/main.rs to call add_one:
use add_one;
fn main() {
let num = 10;
println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Path dependencies are relative to the crate's Cargo.toml:
The path field is relative to the manifest where the dependency is declared. If you move crates around, update these paths accordingly. An incorrect path will produce a compile error like error: failed to load source for dependency `add_one`.
Build the entire workspace
From the workspace root (add/), run:
cargo build
Cargo compiles the library first, then the binary. The output shows exactly which crates were built:
Compiling add_one v0.1.0 (/path/to/add/add_one)
Compiling adder v0.1.0 (/path/to/add/adder)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
A single target/ directory now exists at the workspace root. There is no target/ inside adder/ or add_one/.
Run the binary from the workspace root
Because the root is not a package itself, running cargo run without a package name will not work (there is nothing to run). Instead, tell Cargo which member to execute with -p:
cargo run -p adder
You should see:
Hello, world! 10 plus one is 11!
If you see this output, the workspace is fully wired up:
The binary successfully linked against the library crate. The shared Cargo.lock was created, and the shared target/ directory holds the compiled artifacts for both crates. You now have a functioning multi-crate project.
What the Shared Target Directory Does
Workspaces place all build artifacts in a single target/ folder at the workspace root. Even if you cd adder and run cargo build from inside the member, the output still lands in add/target/. This is not a cosmetic choice—it prevents recompilation.
When adder depends on add_one, Cargo needs the compiled add_one library to link into the binary. If every crate had its own target/, adder would either recompile add_one into its own output directory or rely on some manual step to locate the artifact. With one shared directory, Cargo compiles add_one once. As long as its source code and dependencies do not change, every member that depends on it reuses the same compiled artifact.
This is the mechanism behind the faster incremental builds you experience when modifying only one crate in a large workspace. The unchanged crates are simply not touched.
Running Tests Across the Workspace
Add a test to the library crate to see how workspace-level testing behaves. Insert the following into add_one/src/lib.rs below the function:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_one() {
assert_eq!(3, add_one(2));
}
}
Now run cargo test at the workspace root:
cargo test
Cargo discovers and runs tests for every member:
Compiling add_one v0.1.0 (/path/to/add/add_one)
Compiling adder v0.1.0 (/path/to/add/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.18s
Running unittests src/lib.rs (target/debug/deps/add_one-…)
running 1 test
test tests::it_adds_one ... ok
Running unittests src/main.rs (target/debug/deps/adder-…)
running 0 tests
Doc-tests add_one
running 0 tests
You can also test a single crate with cargo test -p add_one.
Common Mistakes When Setting Up a Workspace
Several early errors can make it seem like the workspace is broken when it is actually a configuration issue.
- Forgetting
pubon library functions. A function defined in a library crate asfn add_one(x: i32) -> i32withoutpubwill not be visible to the binary crate. The compiler error mentionsfunctionadd_oneis private, which can be confusing if you assume all workspace crates automatically share items. - Expecting automatic dependency resolution between members. Even though crates sit side by side in the same workspace, you must explicitly add a path dependency in
Cargo.toml. Without it,use add_one;fails withunresolved import `add_one`. - Running
cargo runwithout-pfrom the root. In a virtual workspace, there is no default package to run. Cargo will either complain or, if a root package exists, run that one—not necessarily the binary you intended. - Missing
resolverfield in a virtual manifest. This produces a parse error when Cargo reads the root manifest. The fix is always addingresolver = "3"(or"2"for older edition compatibility) under[workspace].
cargo new automatically adds members, but moving crates requires manual updates:
When you run cargo new inside a workspace directory, Cargo appends the new crate to the members array. If you later rename or move a crate directory by hand, you must update the members list yourself. A crate that exists on disk but is not listed in members will not be part of the workspace—its dependencies will not be locked together with the others, and it will use its own target/ directory.
Where to Go From Here
You now have a working workspace with a binary and a library. The workspace root you created is also the place where you will eventually define shared profile settings, workspace-level lints, and inherited dependency versions. This foundation scales cleanly from two crates to dozens without changing the fundamental structure.