How to Use Workspaces to Organize Multi-Crate Projects

Organize multi-crate Rust projects by creating a root Cargo.toml with a [workspace] section listing member directories.

When one crate gets too crowded

You're building a CLI tool that wraps a core library. You change a function signature in the library, and suddenly your CLI won't compile because it's still looking at the old version on crates.io. You publish a new version, wait for the registry, update the CLI, and repeat. This loop kills momentum. Or you have three microservices sharing a config parser, and you're copy-pasting the dependency list across five Cargo.toml files. You want one place to manage versions, one command to build everything, and instant feedback when you change shared code.

A Cargo workspace solves this. It groups multiple crates under a single root configuration. You get shared dependencies, unified builds, and the ability to iterate on internal crates without publishing. The workspace treats your collection of crates as a single project for build and dependency purposes, while keeping each crate's code and public API distinct.

How workspaces organize your project

A workspace consists of a root Cargo.toml and one or more member crates. The root file acts as a virtual manifest. It describes the workspace structure but does not define a package itself. Each member crate lives in its own subdirectory and has its own Cargo.toml that defines its package metadata and local dependencies.

Think of a workspace as a shared apartment building. Each crate is a separate apartment. The residents live independently, but they share the same laundry room, gym, and storage. In Rust terms, the shared amenities are your dependencies and build configuration. You define the amenities once in the building's master plan, and every apartment gets access without duplicating the setup.

The workspace creates a unified dependency graph. Cargo resolves dependencies for all members together. If two crates depend on serde, Cargo downloads and compiles serde once. The artifact is shared across the workspace. This saves disk space and reduces build time.

Minimal setup

Create a root Cargo.toml in your project directory. This file contains a [workspace] section and lists the member crates. It must not contain a [package] section. If you add [package], the file becomes a crate definition, which changes how Cargo interprets the directory.

# Root Cargo.toml
# This is a virtual manifest. It has no [package] section.
[workspace]
# List every crate directory that belongs to this project.
members = [
    "core-lib",
    "cli-tool",
]
# Use resolver 2 for modern dependency resolution.
resolver = "2"

Each member crate needs its own Cargo.toml in its subdirectory. The member files look like standard package manifests. They define the package name, version, and edition.

# core-lib/Cargo.toml
[package]
name = "core-lib"
version = "0.1.0"
edition = "2021"

[dependencies]
# Dependencies here are specific to this crate.
serde = { version = "1.0", features = ["derive"] }
# cli-tool/Cargo.toml
[package]
name = "cli-tool"
version = "0.1.0"
edition = "2021"

[dependencies]
# Depend on the sibling crate using a relative path.
core-lib = { path = "../core-lib" }

Run cargo build in the root directory. Cargo reads the workspace manifest, finds the members, and builds them in dependency order. It builds core-lib first, then cli-tool. If you run cargo run, Cargo looks for a binary target. If only one member has a binary, it runs that one. If multiple members have binaries, Cargo requires you to specify which one to run using -p.

Don't forget the resolver = "2" line. Resolver 2 fixes feature unification issues that can bloat your builds in multi-crate projects.

What happens under the hood

When you invoke Cargo in a workspace, it aggregates the dependency requirements of all members. It solves the dependency graph once for the entire workspace. This means version conflicts are resolved globally. If core-lib requires tokio 1.0 and cli-tool requires tokio 1.0, Cargo picks one version that satisfies both.

Local dependencies work seamlessly. When cli-tool depends on core-lib via path = "../core-lib", Cargo sees that core-lib is a workspace member. It links directly to the local build artifact. No version matching occurs. No registry lookup happens. Changes in core-lib are immediately visible to cli-tool on the next build.

The build cache is shared. Cargo stores compiled artifacts in target/. In a workspace, all members share the same target/ directory. If you switch between members, Cargo reuses previously compiled dependencies. This makes iteration fast.

Convention aside: The community standard is to keep the target/ directory in the workspace root. Do not add target/ to .gitignore inside member crates. Add it only in the root .gitignore. This prevents accidental duplication and keeps the repository clean.

Realistic example with shared dependencies

Workspaces shine when you have shared dependencies. The [workspace.dependencies] section lets you define dependencies once and inherit them across members. This keeps versions synchronized and reduces boilerplate.

# Root Cargo.toml
[workspace]
members = ["lib", "bin", "tests"]
resolver = "2"

# Define shared dependencies once.
# All members can inherit these without repeating versions.
[workspace.dependencies]
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"

Member crates inherit these dependencies using workspace = true. You can still override features or add optional flags at the crate level.

# bin/Cargo.toml
[package]
name = "bin"
version = "0.1.0"
edition = "2021"

[dependencies]
# Inherit version and features from workspace.
tokio = { workspace = true }
# Inherit version, but add a local feature override.
serde = { workspace = true, features = ["rc"] }
# Local crate dependency.
lib = { path = "../lib" }
# lib/Cargo.toml
[package]
name = "lib"
version = "0.1.0"
edition = "2021"

[dependencies]
# Inherit shared deps.
serde = { workspace = true }
# Add a dependency not shared by all crates.
regex = "1.0"

This setup gives you centralized control. If you need to upgrade tokio, you update the version in the root Cargo.toml. All members pick up the change on the next build. You avoid the drift where one crate is on 1.28 and another is on 1.30.

Convention aside: Use workspace = true for dependencies that appear in multiple crates. Keep crate-specific dependencies in the member Cargo.toml. This makes the dependency graph clear. Readers can see shared infrastructure in the root and local needs in the members.

Pitfalls and compiler errors

Workspaces introduce a few gotchas. Understanding them saves debugging time.

If you forget to list a crate in members, Cargo treats it as a standalone project. You won't get a compiler error, but you'll lose workspace benefits. The crate won't share the dependency cache. It won't build when you run cargo build in the root. If you try to add a crate that doesn't exist, Cargo stops immediately: error: failed to load manifest for workspace member ....

If you create a cyclic dependency, Cargo detects it and aborts. error: cyclic dependency detected. Workspaces do not prevent cycles. You still need to structure your crates so that dependencies flow in one direction.

Resolver 1 has a feature unification bug. If you omit resolver = "2", Cargo uses resolver 1 by default. In resolver 1, if any crate in the workspace enables a feature for a dependency, that feature is enabled for all crates. This can pull in unwanted code and increase binary size. Resolver 2 scopes features to the crates that actually need them. Always use resolver 2 in new workspaces.

If you try to run cargo publish from the root, Cargo rejects you. Workspaces are not publishable units. You must publish individual members. Run cargo publish -p lib to publish a specific crate. The workspace manifest is ignored by the registry.

Large workspaces can slow down builds. If you have twenty crates and only work on one, cargo build compiles everything. Use default-members to limit the default build set.

[workspace]
members = ["lib", "bin", "tools", "examples"]
default-members = ["bin"]

Now cargo build only builds bin. Run cargo build --workspace to build everything. This keeps your daily workflow fast while preserving the ability to check the full project.

Don't let the workspace scope creep. If a crate doesn't share dependencies or build steps, it probably doesn't belong in the workspace.

Decision: workspace vs single crate vs separate repos

Use a workspace when you have a library and a binary that evolve together; the binary can depend on the library via path = ".." and pick up changes instantly without publishing. Use a workspace when multiple crates share heavy dependencies like tokio or serde; define them in [workspace.dependencies] to keep versions synchronized and reduce download time. Use a workspace when you want a unified build command; cargo build in the root compiles every member, and cargo test runs the full suite. Use a workspace when you need to run integration tests that span multiple crates; the test crate can depend on all workspace members and exercise their interactions.

Reach for a single crate when your codebase is under a few thousand lines and splitting it adds complexity without clear benefit. Reach for separate repositories when the crates have different release schedules, different maintainers, or different license requirements. Reach for separate repositories when the crates serve completely different domains and sharing dependencies would create unwanted coupling.

Trust the workspace for cohesion. It keeps related code together without forcing a monolithic structure.

Where to go next