How to create Cargo workspace

To create a Cargo workspace, initialize a root project with `cargo init --name <name>`, add a `[workspace]` section to its `Cargo.toml` listing your member paths, and then create or move your individual crates into those paths.

When one crate isn't enough

You are building a CLI tool. It depends on a library you are also writing. You fix a bug in the library, update the version in the CLI, run tests, realize you broke the library, fix that, update the version again. Rinse and repeat. You have two Cargo.toml files, two target directories, and a headache. You want to build everything at once. You want to share dependencies without copy-pasting version strings. You want a monorepo that doesn't feel like a mess.

Cargo workspaces solve this. A workspace lets you group multiple crates into a single project. Cargo treats the group as one unit for building, testing, and dependency resolution. You get a unified build command, shared artifacts, and centralized configuration. The complexity of managing multiple crates drops significantly.

What a workspace actually is

A Cargo workspace is a collection of crates that live in the same directory tree. The workspace root contains a Cargo.toml that lists the member crates. The root itself usually does not contain source code. It acts as a configuration hub.

Think of a workspace like a university campus. The campus administration sets the rules, manages the central library, and ensures departments can collaborate. Each department has its own building and staff, but they all follow campus-wide policies and share resources. In Rust terms, the workspace root is the administration. The member crates are the departments. They share the dependency graph and the build output.

The root Cargo.toml can take two forms. A virtual workspace has only a [workspace] section and no [package] section. The root is purely configuration. A package workspace has both [package] and [workspace] sections. The root is a crate itself, often a binary, that depends on other members. Both forms work. The choice depends on whether the root directory needs to produce a binary or library.

Keep the workspace flat. Deep directory nesting makes navigation painful and tooling slower.

Minimal setup

Start by creating the root directory and initializing it. You do not need to add code to the root yet.

mkdir my-workspace
cd my-workspace
cargo init --name my-workspace

This creates a standard project structure. The root Cargo.toml currently has a [package] section. You need to convert this to a workspace. Open Cargo.toml and replace the [package] section with a [workspace] section. List the paths to your member crates.

# my-workspace/Cargo.toml
[workspace]
members = [
    "lib-crate",
    "bin-crate",
]

The members array contains paths relative to the root Cargo.toml. Cargo will look for Cargo.toml files in those directories. If a directory is missing, Cargo warns you. If the Cargo.toml is malformed, Cargo stops.

Create the member crates using cargo new. This generates the standard crate structure inside the workspace.

cargo new lib-crate
cargo new bin-crate

Run cargo build from the root directory. Cargo reads the workspace manifest, finds both members, and builds them. The output appears in a single target directory at the workspace root. You do not need to run cargo build inside each crate.

Trust the shared target directory. Your disk space and build times will improve immediately.

How Cargo handles the build

When you run cargo build at the root, Cargo performs a unified resolution. It collects all members and merges their dependency graphs. If lib-crate depends on serde and bin-crate depends on serde, Cargo downloads and compiles serde once. It shares the compiled artifact across both crates.

This unified resolution prevents version drift. Cargo picks a single version of each dependency that satisfies all members. If lib-crate requires serde 1.0.190 and bin-crate requires serde 1.0.195, Cargo selects 1.0.195 because it satisfies both ranges. You avoid the "dependency hell" where different parts of your project use incompatible versions of the same library.

Cargo builds crates in parallel, respecting their dependency order. If bin-crate depends on lib-crate, Cargo builds lib-crate first. Once lib-crate is ready, Cargo builds bin-crate. This parallelism speeds up large workspaces significantly.

The build output lands in target/ at the workspace root. Each crate gets its own subdirectory inside target/debug or target/release. This keeps artifacts organized while sharing common dependencies. You can delete the target directory once to clean everything. You do not need to clean each crate individually.

Real-world configuration

Real projects share more than just the build system. They share dependencies, versions, and metadata. Use [workspace.dependencies] to define shared dependencies centrally. This creates a single source of truth for versions and features.

# my-workspace/Cargo.toml
[workspace]
members = [
    "lib-crate",
    "bin-crate",
]

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.30", features = ["full"] }

Inside a member crate, reference the shared dependency using workspace = true. This tells Cargo to inherit the version and features from the workspace definition.

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

[dependencies]
serde = { workspace = true }

The convention is to use workspace = true for all dependencies defined in the workspace. This signals to readers that the version is managed centrally. It also prevents accidental version drift when you update a dependency in one crate but forget another.

Members can also depend on each other using path dependencies. This is how crates within the workspace communicate.

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

[dependencies]
lib-crate = { path = "../lib-crate" }
serde = { workspace = true }

The path dependency points to the local crate. Cargo treats this as a local override. It does not check the registry for this dependency. Changes in lib-crate are immediately visible to bin-crate. This enables rapid iteration. You edit lib-crate, run cargo build in the workspace, and bin-crate picks up the changes instantly.

If your workspace contains tools or examples that should not be built with cargo build, use the exclude field. This keeps the default build fast and focused.

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

Treat workspace.dependencies as the law. If a version changes, it changes everywhere.

Pitfalls and gotchas

Workspaces introduce a few specific traps. Circular dependencies are the most common. If crate-a depends on crate-b and crate-b depends on crate-a, Cargo rejects the build. The error message is clear: error: cyclic dependency detected. You must break the cycle by extracting shared code into a third crate or restructuring the dependencies.

Feature mismatches can cause subtle errors. If you define serde in [workspace.dependencies] with features = ["derive"], all members using workspace = true get those features. If one member needs serde without derive to save compile time, you cannot use workspace = true for that member. You must repeat the dependency definition with different features. This breaks the single source of truth pattern. The workaround is to define the minimal set of features in the workspace and add extra features in the member using features = ["extra-feature"] alongside workspace = true. This works if the workspace definition allows it. If the member needs fewer features, you must define a separate workspace dependency or repeat the version.

Virtual workspaces cannot be published. If the root has no [package] section, cargo publish fails. You must publish individual members using cargo publish -p crate-name. This is intentional. The workspace is a build configuration, not a crate.

Large workspaces can slow down builds if you do not use default-members. By default, cargo build builds all members. If you have fifty libraries and one binary, you might only want to build the binary during development. Set default-members to focus the build.

[workspace]
members = ["lib-a", "lib-b", "app"]
default-members = ["app"]

Now cargo build only builds app. You can still build everything with cargo build --workspace. This gives you control over build scope without losing the ability to compile the full tree.

If a member tries to use a trait or method that requires a feature not enabled, the compiler rejects the code with E0277 (trait bound not satisfied) or E0599 (no method found). This often happens when workspace dependencies have incomplete feature sets. Check the workspace definition first. The error is usually in the configuration, not the code.

Choosing the right structure

Use a Cargo workspace when you have multiple crates that depend on each other and need to be versioned together.

Use a Cargo workspace when you want to share dependencies and build artifacts across a monorepo to save disk space and build time.

Use a virtual workspace (root has no [package]) when the root directory is purely a configuration hub and you never intend to publish the root as a crate.

Use a package workspace (root has [package]) when the root itself is a crate, often a binary, that depends on the other members.

Reach for separate repositories when the crates have different release cycles, different authors, or different security requirements.

Pick default-members when your workspace grows large and you want cargo build to focus on the main applications instead of compiling every library and test harness.

Where to go next