Cargo Workspaces, Features & Profiles
A beginner project is one package with a src/main.rs. A real product soon grows: a core library, a command-line tool, a web service and a few helper programs, all sharing code and dependency versions. Cargo handles this with workspaces. Two more Cargo tools matter as a project grows: features, which let users switch optional parts of a crate on and off, and profiles, which control how the code is compiled.
This lesson builds on Cargo: New, Run, Build & Check and Modules, Crates & Visibility. You will learn the standard layout of a package, build a two-crate workspace step by step, add an optional feature, and tune build profiles for speed and size.
The standard package layout
Cargo finds most files by convention, so you rarely configure paths. A full package can contain:
| Path | What Cargo does with it |
|---|---|
src/main.rs | The default binary, named after the package |
src/lib.rs | The library crate, usable by binaries, tests and other packages |
src/bin/report.rs | An extra binary called report, run with cargo run --bin report |
tests/*.rs | Integration tests, each file compiled as a separate crate |
benches/*.rs | Benchmarks, run with cargo bench |
examples/*.rs | Example programs, run with cargo run --example name |
build.rs | An optional build script that runs before compiling the package |
A package may have at most one library but any number of binaries. The common pattern from the modules lesson, real logic in lib.rs and a thin main.rs, is what makes this layout useful: tests, benchmarks, examples and every binary all reuse the same library.
What a workspace is
A workspace is a set of packages, called members, that are developed together. Members share:
- one
Cargo.lock, so every crate uses exactly the same dependency versions; - one
target/folder, so a dependency such asserdeis compiled once, not once per package; - the
[profile]settings of the rootCargo.toml(profiles in member manifests are ignored).
The root Cargo.toml can be a normal package with an added [workspace] table, or a virtual manifest that has a [workspace] table but no [package]. Most multi-crate projects use a virtual manifest with the members in a crates/ folder.
Step by step: a railway workspace
You will build railway, with a library fares that calculates ticket prices and a binary ticket-cli that uses it. The finished folder looks like this:
railway/
Cargo.toml workspace root (virtual manifest)
Cargo.lock shared by all members
crates/
fares/ library
Cargo.toml
src/lib.rs
ticket-cli/ binary
Cargo.toml
src/main.rs
target/ shared build output- Create the folder
railwayand, inside it, the rootCargo.toml:
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
unwrap_used = "warn"members = ["crates/*"] is a glob: every folder in crates becomes a member. A virtual manifest has no edition from which to guess the dependency resolver, so you must set resolver = "3" yourself (resolver 3 needs Rust 1.84 or newer and prefers dependency versions that support your rust-version). [workspace.package] holds values that members can inherit, [workspace.dependencies] declares each shared dependency and its version once, and [workspace.lints] sets lint levels for the whole team.
- From the
railwayfolder, create the two members:
cargo new crates/fares --lib
cargo new crates/ticket-cli- Replace
crates/fares/Cargo.tomlso it inherits from the workspace:
[package]
name = "fares"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
serde = { workspace = true, optional = true }
[features]
json = ["dep:serde"]
[lints]
workspace = trueversion.workspace = true means "use the value from [workspace.package]". The dependency line says workspace = true instead of a version, so the version and the derive feature come from the root; a member may add only features, optional or default-features next to it. [lints] workspace = true opts this crate into the shared lint levels. The [features] table is explained in the next section.
- Write the library in
crates/fares/src/lib.rs:
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "json", derive(serde::Serialize))]
pub enum Class {
Sleeper,
ChairCar,
ThirdAc,
}
#[derive(Debug)]
#[cfg_attr(feature = "json", derive(serde::Serialize))]
pub struct Quote {
pub class: Class,
pub km: u32,
pub fare: u32,
}
pub fn quote(class: Class, km: u32) -> Quote {
let (per_km, reservation) = match class {
Class::Sleeper => (1, 20),
Class::ChairCar => (2, 40),
Class::ThirdAc => (3, 40),
};
Quote { class, km, fare: per_km * km + reservation }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sleeper_fare_includes_reservation() {
assert_eq!(quote(Class::Sleeper, 300).fare, 320);
}
}#[cfg_attr(feature = "json", derive(serde::Serialize))] means "add this derive only when the json feature is on". Without the feature, serde is not even downloaded or compiled.
- Make
ticket-clidepend on the library with the feature turned on. Incrates/ticket-cli/Cargo.toml, below the same inherited[package]keys, add:
[dependencies]
fares = { path = "../fares", features = ["json"] }
serde_json.workspace = true
[lints]
workspace = true- Write
crates/ticket-cli/src/main.rs:
use fares::{quote, Class};
fn main() {
let trip = quote(Class::ThirdAc, 450);
println!("{trip:?}");
match serde_json::to_string(&trip) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("error: {e}"),
}
}- Build, run and test from the
railwayfolder:
cargo build # builds every member
cargo run -p ticket-cli # -p picks one package
cargo test -p fares # tests only the library
cargo test --workspace # tests every memberQuote { class: ThirdAc, km: 450, fare: 1390 }
{"class":"ThirdAc","km":450,"fare":1390}The fare is 3 x 450 + 40 = 1390. A unit enum variant becomes a JSON string, so class appears as "ThirdAc". Only one Cargo.lock and one target/ folder exist, at the root.
Features in detail
A feature is a named switch defined in a package's [features] table. Code checks it with #[cfg(feature = "name")] or cfg_attr, and Cargo passes the enabled features to the compiler.
- Each feature lists what it turns on: other features, or optional dependencies written as
"dep:name". default = ["a", "b"]lists features that are on unless the user writesdefault-features = false(or passes--no-default-features).- A dependency marked
optional = trueis compiled only when a feature enables it. Writingdep:serdehides the dependency name, so users must use your feature namejson. Thedep:syntax needs Rust 1.60 or newer. "serde/rc"turns on a feature of a dependency (and the dependency itself if it is optional);"serde?/rc"turns it on only if something else already enabledserde.
On the command line, --features json (comma-separated for several), --all-features and --no-default-features change what is built:
cargo build -p fares --features json
cargo test -p fares --all-featuresFeatures are unified: if two crates in the build depend on fares and only one asks for json, fares is compiled once with json on. Because of this, features must be additive. Turning a feature on may add functions or trait impls, but must never remove or change anything another crate relies on. A feature called no_std that removes code is a mistake; the convention is a std feature that adds code.
Unification also explains a surprise in workspaces: cargo build at the root builds fares with json (because ticket-cli asks for it), while cargo test -p fares builds it without. Test your library with --all-features and with no features in your CI so both combinations stay working.
Build profiles
A profile is a set of compiler settings. cargo build uses dev, cargo build --release uses release, and tests and benchmarks use profiles based on those two. The most important defaults are:
| Setting | dev | release | What it controls |
|---|---|---|---|
opt-level | 0 | 3 | How hard the compiler optimises |
debug | true | false | Debug information for debuggers and profilers |
overflow-checks | true | false | Panic on integer overflow |
incremental | true | false | Reuse work between builds |
codegen-units | 256 | 16 | Parallel pieces; more is faster to build, fewer may run faster |
lto | false | false | Link-time optimisation across crates |
You override profiles in the root Cargo.toml. A typical setup for a shipped command-line tool:
[profile.release]
lto = true # optimise across all crates at link time
codegen-units = 1 # slower build, often a faster and smaller program
panic = "abort" # no unwinding code; a panic ends the process
strip = true # remove symbols for a smaller file
[profile.dev.package."*"]
opt-level = 2 # optimise dependencies even in debug builds
[profile.profiling]
inherits = "release"
debug = true # release speed, but with symbols for a profiler[profile.dev.package."*"] applies to every dependency that is not a workspace member. It makes debug builds of programs that do heavy work in libraries (image processing, parsing) much faster to run, while your own code still builds quickly. A custom profile such as profiling must say which profile it inherits from; build it with cargo build --profile profiling, and the output goes to target/profiling/.
Common mistakes
- Putting
[profile]in a member'sCargo.toml. Cargo ignores it there (with a warning). Profiles belong in the workspace root. - Leaving out
resolverin a virtual manifest. Cargo then falls back to the old resolver and warns. Writeresolver = "3". - Writing a version next to
workspace = true. Inherited dependencies may only addfeatures,optionalanddefault-features. - Non-additive features. If enabling a feature removes code, some combination of crates will fail to compile. Features should only add.
- Testing only the default feature set. Run your tests with
--all-featuresand--no-default-featurestoo.
Key points
- Cargo finds binaries, tests, benches and examples by folder convention:
src/bin/,tests/,benches/,examples/. - A workspace shares one
Cargo.lock, onetarget/folder and the root's profiles; select packages with-p nameor--workspace. [workspace.package],[workspace.dependencies]and[workspace.lints]define values once; members inherit them withworkspace = true.- Features are additive switches in
[features]; optional dependencies are enabled withdep:name, and code checks them with#[cfg(feature = "...")]. - Features are unified across the build, so a crate is compiled once with every feature any dependent asked for.
- Profiles (
dev,release, custom ones withinherits) control optimisation, debug info, LTO, panic strategy and stripping.
Exercise
Extend the railway workspace. Add a feature called senior to fares that compiles an extra function pub fn senior_fare(q: &Quote) -> u32, which returns 60 percent of the normal fare (whole rupees, rounded down). Turn the feature on from ticket-cli and print the senior fare for the 450 km Third AC trip. Finally, add a release profile that strips symbols and uses thin LTO, and write the command that builds only ticket-cli in release mode.
Show solution
# crates/fares/Cargo.toml
[features]
json = ["dep:serde"]
senior = []
# crates/ticket-cli/Cargo.toml
[dependencies]
fares = { path = "../fares", features = ["json", "senior"] }
# railway/Cargo.toml (workspace root)
[profile.release]
lto = "thin"
strip = true// crates/fares/src/lib.rs (added)
#[cfg(feature = "senior")]
pub fn senior_fare(q: &Quote) -> u32 {
q.fare * 60 / 100
}
// crates/ticket-cli/src/main.rs
use fares::{quote, senior_fare, Class};
fn main() {
let trip = quote(Class::ThirdAc, 450);
println!("Normal fare: Rs. {}", trip.fare);
println!("Senior fare: Rs. {}", senior_fare(&trip));
}cargo build -p ticket-cli --releaseNormal fare: Rs. 1390 Senior fare: Rs. 834
senior = [] is a feature that enables nothing else; it only switches on the #[cfg(feature = "senior")] code. 1390 x 60 / 100 is 834 in integer arithmetic. The profile goes in the root because members' profiles are ignored.
Quick Quiz
1. What do all members of a Cargo workspace share?
Sharing the lock file and build folder keeps versions consistent and avoids compiling dependencies twice.
2. Two crates depend on fares; one enables the json feature and the other does not. How is fares built when both are in the same build?
Cargo unifies features, building each dependency once with the union of requested features.
3. What does json = ["dep:serde"] in [features] do?
The dep: prefix refers to an optional dependency without creating an implicit feature named after it.
4. Where must [profile.release] settings go in a workspace?
Profiles are honoured only in the root manifest; member profiles are ignored.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
