Managing Multiple Packages in a Workspace
Learn how to add, organize, and coordinate multiple Rust packages within a single Cargo workspace, from defining dependencies to running tests across the whole project.
A workspace is a container that holds several packages you're building together. The moment you have more than one package in that container, you face a set of practical questions: How do you add a new crate? How do you make crates depend on each other? How do you keep dependency versions consistent across every member? This section answers all of those questions by walking through the day-to-day operations of managing multiple packages under one workspace.
Adding a New Package to the Workspace
The most common way to introduce a new package is to run cargo new from the workspace root. Cargo recognises that you are inside a workspace and automatically updates the top‑level Cargo.toml to list the new package as a member.
If you run cargo new inside an existing workspace directory, Cargo appends the package to the members array without you touching the root manifest.
# Inside the workspace directory
cargo new my_library --lib
The root Cargo.toml will contain the new entry automatically:
[workspace]
resolver = "2"
members = [
"adder",
"my_library",
]
Implicit members:
If you omit the members key entirely, Cargo will treat every crate reachable by a path dependency from any workspace member as a member. This works well for projects where the dependency tree already mirrors the directory structure, but being explicit avoids surprises when someone adds a path‑only dependency that shouldn't be built as part of the workspace.
After adding a package, a single cargo build from the workspace root compiles every member crate. This shared build step is the whole point of the workspace: one target/ directory, one Cargo.lock, and no redundant re‑compilation when you switch between crates.
Specifying Inter‑Package Dependencies
Crates inside a workspace know nothing about each other until you declare a path dependency. That is deliberate: it forces the dependency graph to be explicit and auditable.
Assume we have a binary crate adder and a library crate add_one. To let adder call add_one::add_one, we add a dependency in adder/Cargo.toml:
# adder/Cargo.toml
[dependencies]
add_one = { path = "../add_one" }
Now adder/src/main.rs can use the library:
use add_one;
fn main() {
let num = 10;
println!("{} plus one is {}", num, add_one::add_one(num));
}
Building the workspace will compile add_one first (because adder depends on it), then adder. Cargo respects the dependency order automatically.
Circular dependencies are forbidden:
A crate cannot depend on another crate that, through any chain of dependencies, depends back on it. Cargo will reject such a configuration with a clear error. If you find yourself wanting a circular relationship, extract the shared types into a third crate that both sides can depend on.
You can have any number of inter‑package dependencies. A common pattern is to build a binary that glues together several small library crates, each responsible for a distinct part of the domain logic. The workspace keeps them co‑located and build‑coherent.
Sharing External Dependencies Across the Workspace
When multiple members need the same external crate, you want them to use exactly the same version. Workspaces provide two mechanisms that work together: a single Cargo.lock for the entire workspace, and the [workspace.dependencies] table that lets you define version requirements in one place.
Define the shared dependency once in the root Cargo.toml:
# Workspace root Cargo.toml
[workspace]
resolver = "2"
members = ["adder", "add_one"]
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
rand = "0.8"
Then each member that needs serde or rand writes a lightweight dependency declaration:
# add_one/Cargo.toml
[dependencies]
serde.workspace = true
rand.workspace = true
The version, features, and any other specifiers are pulled from the workspace definition. This guarantees that every crate that opts into a workspace dependency gets the identical version resolution. If you later need to upgrade serde, you change one line in the root manifest and every member follows.
Version consistency is automatic:
Because the workspace produces a single Cargo.lock, even members that do not use workspace.dependencies but specify compatible version ranges (e.g. serde = "1") will resolve to the same concrete version. The [workspace.dependencies] table simply makes the common case explicit and prevents accidental drift when a range is widened in one member but not another.
Each member still needs its own declaration:
Even if add_one already depends on rand, adder cannot use rand unless it also lists rand as a dependency. Cargo’s dependency resolution is per‑crate, not transitive across workspace members. Forgetting this leads to “unresolved import” errors at compile time.
Inheriting Package Metadata
Workspaces also let you share common package metadata such as the version, edition, license, and repository URL. Instead of repeating these in every member’s Cargo.toml, you define defaults in the root [workspace.package] section and let each crate inherit them.
# Workspace root Cargo.toml
[workspace.package]
version = "0.2.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/example/myproject"
A member crate can then inherit these fields:
# core/Cargo.toml
[package]
name = "core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
Any field that is not inherited can be overridden locally. This pattern is invaluable when you maintain a workspace with a dozen crates and need to bump the version for a release — one change in the root propagates to every crate that opted in.
Running Commands for Specific Packages
Cargo commands run from the workspace root typically operate on the whole workspace. When you only want to build, test, or run one member, use the -p (or --package) flag.
# Build only the adder binary
cargo build -p adder
# Run the adder binary (and any binary target within that package)
cargo run -p adder
# Run tests only for the add_one library
cargo test -p add_one
Run the whole workspace’s tests
To execute every test across all members, cargo test without -p is enough. The output separates results by package so you can pinpoint failures immediately.
cargo test
You will see sections labelled Running unittests src/lib.rs (target/debug/deps/add_one-...) and Running unittests src/main.rs (target/debug/deps/adder-...), one per crate.
Filter to a single package’s tests
When debugging a specific library, confine test execution to that package to save time.
cargo test -p add_one
The output will only contain the test results for add_one, ignoring other members entirely.
The -p flag works with nearly every cargo subcommand: cargo check -p <name>, cargo clippy -p <name>, cargo doc -p <name>. It is the primary tool for targeting your actions inside a multi‑package workspace.
Common Mistakes When Managing Workspace Members
Forgetting to add a new crate to members
If you create a subdirectory with a Cargo.toml but forget to list it in the root members array, Cargo will not include that crate when you build the workspace. Running cargo build from the root will succeed but the new crate remains untouched. Always verify that cargo build --workspace compiles the crate you expect.
Accidentally depending on a sibling crate without a path dependency
It's easy to assume that because two crates live in the same workspace, they can see each other's public items. They cannot. A path dependency is mandatory. If you get an error like unresolved import, check that the Cargo.toml of the consuming crate contains a path entry pointing to the sibling.
Introducing incompatible dependency versions
Even though the workspace resolves all dependencies to a single version per semver‑compatible range, you can still create incompatibilities by requiring different major versions (e.g. serde = "1.0" in one crate and serde = "2.0" in another). Cargo will include both versions and each crate gets what it asked for, but the types are then incompatible. Use [workspace.dependencies] to enforce a single version policy.
Circular path dependencies
A crate that depends on itself directly or through a chain of other workspace members will cause a compile‑time error. If you encounter this, refactor the shared logic into a separate base crate that both sides can depend on.
Summary
Managing multiple packages in a workspace is mostly about keeping three things aligned: the member list, the inter‑package dependency graph, and the shared external dependencies. Once you have a workflow that uses cargo new for new crates, path dependencies for internal connections, and [workspace.dependencies] for external crates, adding functionality to a monorepo becomes as routine as adding a module inside a single crate.