A workspace with two crates, one feature and a lint table is about forty lines of TOML, and it is the point where most Cargo surprises start. A feature turned on by one crate quietly turns on for the whole build. A version bumped in one member drifts from its sibling. A cargo publish that worked in the workspace fails on a clean checkout because a path dependency has no version. This guide builds a small workspace on Rust 1.90 with edition 2024, then walks each of those traps with the output Cargo printed.
The project we build
The workspace is called wordstat. It has a library, wordstat-core, that counts words, and a binary, wordstat, that calls it. The library has one optional feature, unicode, that adds a character counter. That is enough structure to show workspaces, features, shared settings and publishing without a single external dependency, so every command below runs offline.
wordstat/
Cargo.toml
crates/
core/
Cargo.toml
src/lib.rs
cli/
Cargo.toml
src/main.rsThe root manifest holds what members share
A workspace root is a Cargo.toml with a [workspace] table. It lists members, picks the dependency resolver, and can hold values the members inherit, so a version number or a license exists in one file rather than five.
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.lints.rust]
unsafe_code = "forbid"members = ["crates/*"] is a glob, so adding a directory under crates/ adds a member with no edit here. resolver = "3" is the default for edition 2024 packages, but a virtual workspace has no package of its own to take the edition from, so it must be written. Leave it out in a virtual workspace and Cargo uses the old resolver 1 and prints a warning about it. Resolver 3 also makes dependency resolution respect each member's rust-version, which matters later in the section on the minimum supported version.
The [workspace.package] table is a set of defaults. A member opts in per field, which is deliberate: a crate that must version independently simply writes its own version.
A member inherits with workspace = true
[package]
name = "wordstat-core"
description = "Count words"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[features]
default = []
unicode = []
[lints]
workspace = truepub fn count(text: &str) -> usize {
text.split_whitespace().count()
}
#[cfg(feature = "unicode")]
pub fn count_chars(text: &str) -> usize {
text.chars().count()
}The dotted form version.workspace = true means "take this from [workspace.package]". The [lints] table does the same for lints, so the unsafe_code = "forbid" rule at the root applies to every crate that opts in. One place to tighten a rule, one place to loosen it.
The binary depends on the library by path, and turns the unicode feature on:
[package]
name = "wordstat"
description = "Count words from the command line"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
wordstat-core = { path = "../core", version = "0.1.0", features = ["unicode"] }
[lints]
workspace = truefn main() {
let text = std::env::args().skip(1).collect::<Vec<_>>().join(" ");
println!(
"{} words, {} chars",
wordstat_core::count(&text),
wordstat_core::count_chars(&text)
);
}cargo run -q -p wordstat -- the quick brown fox4 words, 19 charsNote the dependency line has both path and version. Locally Cargo uses the path and ignores the version. On crates.io there are no paths, so Cargo strips the path and keeps the version. Omit the version and publishing fails, which is the first failure in this guide.
One lock file, one target directory
Run cargo build anywhere in the workspace and Cargo writes a single Cargo.lock and a single target/ at the root. That is the real payoff of a workspace. Two crates that both depend on serde compile it once, and both get the same version, so a type from one crate can cross into the other. Without a workspace, two separate projects can resolve serde to different patch releases and compile it twice.
Select members with -p, or the whole set with --workspace:
cargo build -p wordstat-core
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warningsRun from the root of a virtual workspace, a bare cargo build builds every member. Run from inside crates/core, it builds only that crate and its dependencies. This trips people in CI, where the working directory decides which crates get tested; passing --workspace removes the guesswork.
Features are additive, and that has a consequence
A feature is a name that switches code on. unicode = [] declares one with no extra dependencies. The rule that surprises people is that features unify: if any crate in the build asks for a feature of a dependency, that dependency is compiled with it, for everyone. Cargo builds each crate once per feature set, not once per consumer.
Here wordstat asked for unicode, so building wordstat-core inside the same build also has it on. You can see who asked:
cargo tree -e featureswordstat v0.1.0 (/work/ws/crates/cli)
├── wordstat-core feature "default" (command-line)
│ └── wordstat-core v0.1.0 (/work/ws/crates/core)
└── wordstat-core feature "unicode"
└── wordstat-core v0.1.0 (/work/ws/crates/core)This is why a feature must only add things. If unicode changed the behavior of count instead of adding count_chars, then a downstream crate that never asked for it would get different results the day some other dependency in its tree turned it on. A feature that removes or swaps behavior is a bug waiting for a lockfile update.
The classic test is to build the library alone, with and without the feature, in CI:
cargo build -p wordstat-core --no-default-features
cargo build -p wordstat-core --all-featuresBoth must compile. If the first fails, a default feature is hiding a missing cfg. See feature unification for the cases where the workspace root enabling a feature changes what an individual member builds, and optional dependencies for features that pull in a crate.
A feature that fails to be additive
Suppose someone adds a function guarded the wrong way round:
#[cfg(not(feature = "unicode"))]
pub fn count_chars(text: &str) -> usize {
text.len()
}
#[cfg(feature = "unicode")]
pub fn count_chars(text: &str) -> usize {
text.chars().count()
}With the feature off, count_chars counts bytes. With it on, characters. Both builds compile, and a crate that relied on the byte count breaks the moment another member enables unicode. Cargo cannot warn about this, because it is a design error, not a type error. The fix is the earlier shape: the function exists only when the feature is on, or it always exists and the feature changes nothing observable.
Workspace dependencies keep versions in one place
When several members use tokio or serde, declare the version once at the root:
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", default-features = false }
wordstat-core = { path = "crates/core", version = "0.1.0" }[dependencies]
serde.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
wordstat-core = { workspace = true, features = ["unicode"] }A member can add features on top of the workspace entry, as the tokio line does, but it cannot remove ones the root enabled, and it cannot change the version. The internal crate belongs here too. Listing wordstat-core at the root with both path and version means every member that uses it publishes correctly, and a version bump is a one-line change. More patterns are in workspace dependencies and Cargo workspaces explained.
Profiles belong to the root
Build profiles are read from the workspace root only. A [profile.release] table in a member is ignored, and Cargo says so:
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package: /work/ws/crates/core/Cargo.toml
workspace: /work/ws/Cargo.tomlPut the settings at the root. A release profile that trades build time for a smaller, faster binary looks like this:
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "symbols"lto = "thin" optimizes across crate boundaries cheaply. codegen-units = 1 gives LLVM one unit to reason about, which usually shrinks and speeds the binary at the cost of a slower build. panic = "abort" removes the unwinding machinery, but it also means a panic cannot be caught, and #[should_panic] tests need the default. Measure before keeping any of these; Cargo profiles and release profile settings show how.
Why is this crate in my tree?
When a build is slower than it should be, or cargo audit names a crate you never chose, cargo tree answers the question. The inverted form walks upward from a crate to whoever pulled it in:
cargo tree -i wordstat-core
cargo tree -dThe first prints every path that leads to wordstat-core. The second, -d, lists crates present in more than one version, which is where duplicated compile time hides. See cargo tree for reading the output.
Declare the minimum Rust version
A library promises a lowest compiler with rust-version. Here it is inherited as 1.85 from the root. Cargo checks it on every build and refuses with a clear message when the installed compiler is older:
error: rustc 1.80.0 is not supported by the following package:
[email protected] requires rustc 1.85With resolver 3, Cargo also prefers dependency versions whose own rust-version fits yours, so cargo update will not silently move you onto a release that needs a newer compiler. Test the claim, because a declared MSRV nobody builds against is just a comment: run cargo +1.85 build --workspace in CI. Details are in declaring the MSRV.
Publish with a dry run first
Publishing to crates.io is permanent. A version can be yanked, which stops new projects from selecting it, but it can never be deleted or replaced. So rehearse. cargo publish --dry-run packages the crate, unpacks it into a clean directory and compiles it there, exactly as a downstream user would receive it:
cargo publish --dry-run -p wordstat-corewarning: manifest has no documentation, homepage or repository
Packaging wordstat-core v0.1.0 (/work/ws/crates/core)
Packaged 4 files, 1.4KiB (889B compressed)
Verifying wordstat-core v0.1.0 (/work/ws/crates/core)
Compiling wordstat-core v0.1.0 (/work/ws/target/package/wordstat-core-0.1.0)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Uploading wordstat-core v0.1.0 (/work/ws/crates/core)
warning: aborting upload due to dry runRead the warning. crates.io wants a repository and documentation URL, and a readme, so add them to [workspace.package] and inherit. List what would ship with cargo package --list:
Cargo.lock
Cargo.toml
Cargo.toml.orig
src/lib.rsFour files, and that is the point of looking. If .env, a benchmark data directory or a 40 MB fixture appears here, it will be public forever. Use include or exclude in the manifest to control the list.
The publish that fails on a missing version
Strip version = "0.1.0" from the path dependency in crates/cli/Cargo.toml and the build still works, because the path is enough locally. The dry run is where it breaks:
error: failed to verify manifest at `/work/ws/crates/cli/Cargo.toml`
Caused by:
all dependencies must have a version requirement specified when publishing.
dependency `wordstat-core` does not specify a version
Note: The published dependency will use the version from crates.io,
the `path` specification will be removed from the dependency declaration.The message is exact about the cause. Put the version back, or inherit it from [workspace.dependencies] as shown earlier so nobody can forget it.
Publish order matters
The binary depends on the library, so the library goes first. Publish wordstat before wordstat-core is on the registry and the lookup for wordstat-core = "0.1.0" finds nothing. Publish the leaf crate, then its dependents. Cargo 1.90 and later can do the ordering for you: cargo publish --workspace publishes every member in dependency order, and --exclude skips the ones you do not want on the registry. Run it with --dry-run first, as always. The full checklist, including the token and the yank command, is in publishing a crate to crates.io.
Leaking a token
If a crates.io token or any secret is inside a published package, cargo yank does not remove it. The package stays downloadable, so treat the secret as public: revoke it first, publish a clean version second. The cargo package --list check above is how you avoid ever needing this paragraph.
Keep Cargo.lock under control
Cargo.toml says what you will accept, and Cargo.lock says what you got. Commit the lock file for binaries and for workspaces whose output is an application, so every build resolves the same versions. For a pure library the lock file matters less, because consumers resolve their own, but committing it still makes your CI reproducible. The longer argument is in Cargo.lock: commit or not.
cargo update re-resolves within your requirements and rewrites the lock file. It never edits Cargo.toml. To move one dependency and leave the rest alone, name it:
cargo update -p serde
cargo update -p serde --precise 1.0.210When resolution or linking fails
Two errors account for most of the pain in a growing workspace. When two requirements cannot be satisfied together, Cargo reports it as a failed selection, covered in failed to select a version. When a build links against a C library the machine lacks, you get a linker failure, covered in linking with cc failed. Both are easier to read once you know which crate asked for what, which is again cargo tree.
Testing one member without surprising yourself
Because features unify across the build, the order and scope of what you compile changes which features are on. Build the whole workspace and wordstat-core has unicode enabled, because the binary asked. Build only the library with -p wordstat-core and, under resolver 2 or 3, unicode is off, because nothing in that build asked for it. An integration test in crates/core/tests/ that calls count_chars therefore compiles under cargo test --workspace and fails under cargo test -p wordstat-core:
error[E0425]: cannot find function `count_chars` in crate `wordstat_core`
--> crates/core/tests/chars.rs:4:20
|
4 | assert_eq!(wordstat_core::count_chars("héllo"), 5);
| ^^^^^^^^^^^ not found in `wordstat_core`The fix lives in the test, not the command line. Gate the test on the feature so it only exists when the function does:
#![cfg(feature = "unicode")]
#[test]
fn counts_chars_not_bytes() {
assert_eq!(wordstat_core::count_chars("héllo"), 5);
}Now run the matrix that proves each combination compiles and passes: cargo test -p wordstat-core, cargo test -p wordstat-core --all-features and cargo test --workspace. The cost is three test runs. The benefit is that the number of ways your library can be built is no longer a surprise someone else discovers.
Choosing what a bare cargo command does
By default a bare cargo build at a virtual workspace root builds every member. In a large workspace with a slow crate (a code generator, a GUI) that is not what you want during daily work. default-members narrows it:
[workspace]
members = ["crates/*"]
default-members = ["crates/cli"]
resolver = "3"Now cargo build and cargo run touch only the CLI and what it needs, while cargo test --workspace in CI still covers everything. Use it sparingly: when a command behaves differently depending on the directory, people stop trusting it. Anything that must always include every crate, like a release check, passes --workspace explicitly.
Overriding an inherited value
Inheritance is a default, not a lock. A crate that needs its own number writes it directly:
[package]
name = "wordstat-core"
version = "0.3.2"
edition.workspace = true
license.workspace = trueThis is how crates in one repository version independently while still sharing a license and an edition. The common failure is the opposite: every crate inherits version.workspace = true, and a breaking change in the library forces a major bump on a binary that did not change. Share the version only when the crates are genuinely released as one unit, as serde and serde_derive are.
Lints that every crate obeys
The [workspace.lints] table from the root manifest is the cheapest quality gate in the file. Members opt in with [lints] workspace = true, and from then on a new rule is one edit. A realistic table mixes the compiler's lints with Clippy's:
[workspace.lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[workspace.lints.clippy]
unwrap_used = "warn"
dbg_macro = "deny"
needless_pass_by_value = "warn"A crate cannot partly inherit: it either takes the whole table or none of it. When one crate needs an exception, such as a test helper that unwraps freely, put the exception in the code with #[allow(clippy::unwrap_used)] and a comment saying why, rather than dropping the inheritance and losing every other rule. forbid cannot be overridden by an inner allow, which is exactly why it is the right level for unsafe_code: a dependency of the workspace cannot be talked into it by a stray attribute.
Lints only fire when you run the tool, so CI needs the cargo clippy --workspace --all-targets -- -D warnings line from the checklist below. The -D warnings turns every warn into a failure in CI while leaving them as warnings on a developer machine, which is the balance most teams want.
Where a workspace is the wrong tool
A workspace couples its members: one lock file, one set of unified features, one version of each shared dependency. That is a benefit for crates that ship together and a cost for crates that should move independently. Two unrelated tools that happen to live in one repository, with different release cadences and different dependency needs, are usually better as separate repositories or separate workspaces.
It also does not replace a build system for polyglot repositories. If half of the code is not Rust, cargo owns only its half. And a single-crate project gains nothing from the extra root manifest; add the workspace when the second crate appears, not before.
A checklist to copy into CI
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
- run: cargo build -p wordstat-core --no-default-features
- run: cargo publish --dry-run -p wordstat-coreThose six lines catch formatting drift, lint regressions, failing tests, a feature that is not additive and a crate that cannot be packaged. Add a job pinned to your rust-version and you have covered every trap in this guide.
One habit makes the checklist pay for itself: run the same commands locally before you push, through a justfile or a Makefile target, so a failure costs ten seconds at your desk instead of four minutes in a queue. A workspace that is easy to verify stays healthy; one that is only verified in CI drifts toward whatever the last green build happened to allow.



