The Cargo Guide
Workspace Fundamentals
Why Cargo Workspaces Exist
A Cargo workspace is a way to manage multiple related packages together. Instead of treating each crate as a completely separate project, a workspace gives them a shared coordination layer.
A useful mental model is:
- a package is one crate-producing unit with its own
Cargo.toml - a workspace is a collection of related packages managed together
Workspaces are especially useful when a repository contains:
- one or more reusable libraries
- one or more binaries or tools
- shared dependency policy
- shared build and test workflows
The [workspace] Table
A workspace is defined with a [workspace] table in the root Cargo.toml.
A minimal example:
[workspace]
members = ["app", "core"]
resolver = "3"This says that the repository root is a workspace root, and that app and core are workspace members.
At a practical level, the workspace root becomes the place where Cargo coordinates shared resolution, shared output, and shared lockfile behavior.
A Basic Workspace Layout
A simple workspace layout might look like this:
my_workspace/
āāā Cargo.toml
āāā Cargo.lock
āāā app/
ā āāā Cargo.toml
ā āāā src/main.rs
āāā core/
āāā Cargo.toml
āāā src/lib.rsRoot manifest:
[workspace]
members = ["app", "core"]
resolver = "3"Member library manifest:
[package]
name = "core"
version = "0.1.0"
edition = "2024"Member binary manifest:
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }This is the canonical mental picture for a small multi-package Rust repository.
Members
The members field defines which packages belong to the workspace.
Example:
[workspace]
members = ["app", "core", "tools/cli"]
resolver = "3"Cargo also supports glob patterns in workspace membership.
Example:
[workspace]
members = ["crates/*"]
resolver = "3"This is useful when a repository contains many crates under a common directory structure.
Exclude
If a workspace uses broad membership patterns, exclude can prevent some paths from becoming workspace members.
Example:
[workspace]
members = ["crates/*"]
exclude = ["crates/old_experiment"]
resolver = "3"This lets the workspace stay broad without forcing every matching path into the active member set.
Root Package Workspace vs Virtual Workspace
There are two common workspace shapes.
First, a root package workspace, where the root Cargo.toml contains both [workspace] and [package].
Example:
[workspace]
members = ["core", "tools/cli"]
resolver = "3"
[package]
name = "root_app"
version = "0.1.0"
edition = "2024"Second, a virtual workspace, where the root manifest has [workspace] but no [package].
Example:
[workspace]
members = ["app", "core"]
resolver = "3"A virtual workspace is useful when the repository root is purely an organizational layer and not itself a package.
Virtual Manifests
A virtual manifest is a Cargo.toml with a [workspace] section and no [package] section.
Example:
[workspace]
members = ["app", "core", "tools/cli"]
default-members = ["app"]
resolver = "3"This is often the cleanest workspace style when there is no single primary root crate and you want each real package to live in its own directory.
A useful design intuition is:
- use a root package workspace when the root directory itself is also a crate
- use a virtual workspace when the root directory is mainly a coordination layer
Why resolver Is Explicit in Virtual Workspaces
In virtual workspaces, resolver should be set explicitly because there is no root package edition from which Cargo could otherwise infer it.
Example:
[workspace]
members = ["app", "core"]
resolver = "3"This makes the workspace's dependency resolution behavior explicit at the coordination layer.
default-members
The default-members field controls which workspace members Cargo operates on when a command is run from the workspace root without an explicit package selection flag.
Example:
[workspace]
members = ["app", "core", "tools/cli"]
default-members = ["app", "core"]
resolver = "3"This means that a command like:
cargo buildrun from the workspace root will operate on app and core by default rather than every member.
Behavior When default-members Is Not Set
If default-members is not set, behavior depends on the workspace shape.
In a virtual workspace, commands run from the root select all members by default.
In a non-virtual workspace with a root package, commands run from the root select only the root package by default.
That distinction is important because it changes what a plain command like cargo build means depending on workspace structure.
Shared Cargo.lock
All packages in a workspace share one Cargo.lock file at the workspace root.
Example layout:
my_workspace/
āāā Cargo.toml
āāā Cargo.lock
āāā app/
āāā core/This means the workspace is resolved as one coordinated dependency graph rather than as unrelated package-level lockfiles.
That shared lockfile is one of the major practical benefits of workspaces because it makes builds, updates, and CI behavior more consistent across member packages.
Shared target Directory
Workspace members also share a common output directory, which defaults to target/ at the workspace root.
Example:
my_workspace/
āāā Cargo.toml
āāā Cargo.lock
āāā target/
āāā app/
āāā core/This shared target/ directory improves coordination and avoids each member maintaining a separate build output tree.
Why Shared Output Matters
A shared target/ directory matters because workspace members often depend on one another and are built together in common workflows.
A shared output tree helps Cargo reuse artifacts and maintain a more unified build environment across the repository.
Top-Level Invocation vs Member Invocation
One of the most important workspace habits is understanding where you run Cargo from.
If you run a command from a member crate directory, Cargo normally targets that member package.
Example:
cd my_workspace/app
cargo buildThat usually operates on app.
If you run from the workspace root, Cargo uses workspace package-selection rules instead.
Example:
cd my_workspace
cargo buildNow the selected packages depend on whether the root is virtual, whether default-members is set, and whether you passed -p or --workspace.
Package Selection in Workspaces
In a workspace, package-selection flags become very important.
Select one package:
cargo build -p app
cargo test -p coreSelect all workspace members:
cargo build --workspace
cargo test --workspaceExclude some members while using --workspace:
cargo build --workspace --exclude tools-cliA useful mental model is:
- member directory: Cargo usually targets that one package
- workspace root: Cargo uses workspace defaults unless you override them with selection flags
A Concrete Package Selection Example
Suppose you have this workspace:
[workspace]
members = ["app", "core", "tools/cli"]
default-members = ["app", "core"]
resolver = "3"Then from the workspace root:
cargo buildtargets app and core.
But:
cargo build -p appbuilds only app.
And:
cargo build --workspacebuilds all members, including tools/cli.
Root Package Behavior
When a workspace has a root package, that root package is not automatically part of default-members behavior in the same way as a virtual workspace's member set. In practice, if a root package exists, plain root-level invocation without package-selection flags defaults to the root package unless --workspace or explicit package selection is used.
This is one reason virtual workspaces are often clearer when the repository is meant to act as a multi-package monorepo rather than as one main crate with supporting members.
Common Commands in a Workspace
Typical workspace commands include:
cargo check --workspace
cargo test --workspace
cargo build -p app
cargo doc --workspace
cargo cleanAt the workspace root, many common Cargo commands support package selection in this same general style.
That consistency is one reason workspaces scale well for day-to-day development.
A Full Small Workspace Example
Here is a complete small workspace example.
Root manifest:
[workspace]
members = ["app", "core"]
default-members = ["app"]
resolver = "3"Library manifest:
[package]
name = "core"
version = "0.1.0"
edition = "2024"Library code:
// core/src/lib.rs
pub fn normalize_name(s: &str) -> String {
s.trim().to_lowercase()
}Binary manifest:
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }Binary code:
// app/src/main.rs
fn main() {
println!("{}", core::normalize_name(" Alice "));
}Commands from the workspace root:
cargo build
cargo build -p core
cargo build --workspace
cargo test --workspaceWhen to Split a Repo into Workspace Members
A repository is a good candidate for workspace members when it contains code that naturally breaks into separately meaningful packages.
Common signals include:
- a reusable library and one or more binaries that use it
- several tools sharing common internal crates
- code that needs separate package identities but shared development workflows
- a monorepo where crates should share one lockfile and build environment
A useful rule of thumb is that you split into workspace members when the boundaries are package-shaped, not merely directory-shaped.
When Not to Split Too Early
Not every subdirectory deserves to become its own workspace member.
Splitting too early can create unnecessary package boundaries, more manifests, and more coordination overhead.
A good warning sign is when a proposed member would not really be useful as a separate package and exists only because the directory tree happens to be nested.
In those cases, plain modules inside one package may be a better fit than more workspace members.
A Good Split Example
This is often a strong candidate for workspace structure:
repo/
āāā Cargo.toml
āāā core/
āāā cli/
āāā daemon/Why it works:
corecan hold shared business logicclican provide a command-line interfacedaemoncan provide a long-running service- all three can share one lockfile and coordinated build workflows
That is a package-shaped split, not just a filesystem split.
A Weak Split Example
This is often a weaker reason to create more workspace members:
repo/
āāā Cargo.toml
āāā app/
āāā ui/
āāā parsing/
āāā storage/If ui, parsing, and storage are not truly separate packages with meaningful crate boundaries, then turning each into a workspace member may add more complexity than value.
In many cases, those would be better modeled as modules within a single package first.
Workspace Member Discovery and Path Relationships
Cargo can determine workspace context by searching upward from a subdirectory for a parent Cargo.toml that defines a [workspace] section.
This is why running Cargo inside a member directory still usually understands the surrounding workspace context.
That behavior is useful because it lets developers work from either the root or the member while staying inside the same coordinated workspace.
Team Workflow Advantages of Workspaces
Workspaces give teams several practical benefits:
- one lockfile for coordinated dependency updates
- one target directory for coordinated builds
- easier root-level CI commands like
cargo test --workspace - clearer separation between reusable libraries and top-level binaries
- better monorepo ergonomics without giving up normal Cargo behavior
These are often the real reasons a repo adopts a workspace, even more than the raw syntax itself.
Common Beginner Mistakes
Mistake 1: confusing packages with workspace members.
A workspace member is a package inside a workspace, not a different kind of crate.
Mistake 2: assuming cargo build always means the same thing regardless of current directory.
In workspaces, current directory and package-selection flags matter a great deal.
Mistake 3: splitting a repo into many workspace members before the boundaries are genuinely package-shaped.
Mistake 4: forgetting to set resolver explicitly in a virtual workspace.
Mistake 5: not understanding that all members share one lockfile and one target directory by default.
Hands-On Exercise
Create a small two-member workspace by hand.
Start with this layout:
mkdir -p workspace_lab/app/src workspace_lab/core/src
cd workspace_labCreate the root manifest:
[workspace]
members = ["app", "core"]
default-members = ["app"]
resolver = "3"Create core/Cargo.toml:
[package]
name = "core"
version = "0.1.0"
edition = "2024"Create core/src/lib.rs:
pub fn square(x: i32) -> i32 {
x * x
}Create app/Cargo.toml:
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }Create app/src/main.rs:
fn main() {
println!("{}", core::square(7));
}Then try:
cargo build
cargo build -p core
cargo build --workspace
cargo test --workspaceRun the same kinds of commands from inside app/ and compare the behavior. That contrast is one of the fastest ways to internalize workspace package-selection rules.
Mental Model Summary
A strong mental model for Cargo workspace fundamentals is:
[workspace]defines a multi-package coordination layermembersdetermines which packages belong to the workspacedefault-memberscontrols what root-level commands operate on by default- a virtual manifest is a workspace root with no root package
- workspaces share one
Cargo.lockand onetarget/directory by default - current directory matters for command behavior
-pand--workspaceare the key package-selection tools- a repo should usually be split into workspace members only when the boundaries are truly package-shaped
Once this model is stable, Cargo workspaces become much easier to reason about as a repository architecture tool rather than just a manifest trick.
