Rust’s Cargo.toml files are great for managing dependencies, but when your project starts growing and you’ve got multiple crates that depend on each other, keeping everything organized can become a real headache. That’s where Cargo Workspaces come in, allowing you to treat a collection of related Rust crates as a single project, simplifying dependency management and improving build times.
Imagine you’ve got a web service with a shared library for common data structures, and a separate CLI tool that uses both. Without workspaces, each crate would have its own Cargo.toml, and you’d be manually updating versions and ensuring compatibility across them. With workspaces, you define a single root Cargo.toml that manages all the dependencies for all the crates within the workspace.
Let’s look at a typical workspace structure:
my_monorepo/
├── Cargo.toml
├── shared_lib/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── cli_tool/
├── Cargo.toml
└── src/
└── main.rs
The root Cargo.toml is where the magic happens. It declares the workspace and lists the member crates:
# my_monorepo/Cargo.toml
[workspace]
members = [
"shared_lib",
"cli_tool",
]
Now, when you navigate into shared_lib or cli_tool, you’ll notice their Cargo.toml files are significantly simpler. They only declare their own package metadata and specific dependencies not shared across the workspace. For example, shared_lib/Cargo.toml might look like this:
# my_monorepo/shared_lib/Cargo.toml
[package]
name = "shared_lib"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
And cli_tool/Cargo.toml would declare its dependency on shared_lib:
# my_monorepo/cli_tool/Cargo.toml
[package]
name = "cli_tool"
version = "0.1.0"
edition = "2021"
[dependencies]
shared_lib = { path = "../shared_lib" }
clap = { version = "4.0", features = ["derive"] }
Notice how cli_tool depends on shared_lib using a path dependency. Cargo understands this because both are part of the same workspace.
When you run cargo build from the root my_monorepo/ directory, Cargo builds all the member crates. Crucially, it dedupes dependencies. If both shared_lib and cli_tool depend on serde version 1.0, Cargo will only download and compile serde once. This shared build artifact can significantly speed up compilation times, especially in larger projects.
You can also run commands on specific members or all members. For instance, to run tests for all crates:
cargo test
This will compile and run tests for both shared_lib and cli_tool. If you only wanted to test cli_tool:
cargo test -p cli_tool
The -p flag specifies the package to operate on.
The real power emerges when you have version management complexities. If you want to update a dependency across all crates in the workspace, you can do it from the root Cargo.toml. For example, to update serde to 1.0.150 for all crates that depend on it:
# my_monorepo/Cargo.toml
[workspace]
members = [
"shared_lib",
"cli_tool",
]
[workspace.dependencies]
serde = "1.0.150"
clap = "4.1.0"
Now, any member crate that directly or indirectly depends on serde will use version 1.0.150. You don’t need to touch the individual Cargo.toml files for shared_lib or cli_tool unless they have specific override requirements. This centralized dependency management is a game-changer for maintaining consistency and reducing the risk of version conflicts.
The [workspace.dependencies] table is a powerful feature. It allows you to define dependencies that are shared across all member crates. Any crate in the workspace can then use these dependencies without explicitly listing them in their own Cargo.toml if they are compatible. Cargo will automatically resolve them from the workspace’s definition. This is particularly useful for common build tools or development dependencies.
When you’re working on a specific crate within the workspace, you can often run commands from the root directory and use the -p flag to target your crate. For example, cargo clippy -p shared_lib will run clippy only on the shared_lib crate. This keeps your entire monorepo’s code consistent with linting rules defined in the root Cargo.toml or inherited from the default Rust toolchain.
One of the most surprising benefits is how it streamlines local development when crates depend on each other. If you’re working on shared_lib and make a change, cli_tool will automatically pick up that change the next time it’s compiled within the workspace. You don’t need to publish a new version of shared_lib or manually manage local path dependencies that point outside the workspace. Cargo handles this seamlessly because it understands the interdependencies within the workspace context.
The next step after mastering workspaces is often exploring how to manage internal tooling or conditional compilation across your monorepo, which can be achieved through features and profiles defined at the workspace level.