Seven crates, seven copies of edition = "2024", seven #![forbid(unsafe_code)] attributes that someone forgot in the fourth. Workspaces solve that in two places: [workspace.package] shares metadata fields, and [workspace.lints] shares lint levels. Both are inherited with a .workspace = true opt-in per member. This post sets up each one, shows a lint stopping a build, and covers the three errors you will meet when the wiring is wrong.
Everything ran on Cargo 1.99, edition 2024, with Clippy from the same toolchain. The workspace has two crates: inventory-core (a library) and inventory-cli (a binary).
The root declares, the members opt in
Start with the root. The package table holds fields that are identical across crates; the lint tables hold rules for the compiler and for Clippy:
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.85"
repository = "https://github.com/example/inventory"
[workspace.lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
[workspace.lints.clippy]
dbg_macro = "deny"
todo = "warn"
pedantic = { level = "warn", priority = -1 }A member opts in field by field for package metadata, and with one line for all lints:
[package]
name = "inventory-core"
description = "Stock levels and reorder rules"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = trueThe per-field opt-in is deliberate. A binary that never publishes can skip license, and a crate on a different release train can keep its own version. Inheritable [package] fields are version, edition, authors, description, documentation, homepage, readme, keywords, categories, license, license-file, repository, rust-version, exclude, include and publish. Anything else, notably name, is per crate by definition.
What the lints actually do
A lint set in the table behaves like the equivalent command-line flag, which means it applies to the whole crate and can be seen in the compiler's notes. Add a stray debugging macro to the binary and run Clippy:
fn main() {
let sku = inventory_core::sample_sku();
dbg!(sku);
}error: the `dbg!` macro is intended as a debugging tool
--> crates/cli/src/main.rs:3:5
|
3 | dbg!(sku);
| ^^^^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#dbg_macro
= note: requested on the command line with `-D clippy::dbg-macro`
help: remove the invocation before committing it to a version control system
|
3 - dbg!(sku);
3 + sku;The note says "requested on the command line" because Cargo translates the table into -D clippy::dbg-macro. A deny fails the build, where a warn only prints, and CI that runs cargo clippy -- -D warnings turns every warning into a failure too. Use deny for rules you never want to see violated, and warn for ones you want visible but not blocking.
The compiler's own lints work the same way. Put an unsafe block in a crate that inherited unsafe_code = "forbid":
pub fn f() -> u32 {
unsafe { std::mem::zeroed() }
}error: usage of an `unsafe` block
--> crates/core/src/unsafe_demo.rs:2:5
|
2 | unsafe { std::mem::zeroed() }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: requested on the command line with `-F unsafe-code`That is -F, forbid, which cannot be overridden by an #[allow] further down. The difference from deny matters: a forbidden lint cannot be switched off by a local attribute, so a teammate cannot quietly add #[allow(unsafe_code)] to one module. Choose forbid for a promise about the whole crate and deny where an escape hatch with a justification is acceptable.
Priorities and groups
Lint groups such as clippy::pedantic turn on dozens of rules at once, and you will want to relax a few of them. The priority key sets the order Cargo emits them, since later flags win. A group needs a lower priority than the individual lints you adjust beside it, which is why the example above gives pedantic a priority of -1:
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
must_use_candidate = "allow"Without the negative priority, pedantic and the two allow entries have the same priority, and the order they reach the compiler is alphabetical by name, so the allow lines may be applied before the group re-enables them. Clippy itself warns about this exact mistake, with a message that names the group.
Tightening the rules without a flag day
Turning on a strict lint across a codebase that already violates it produces hundreds of errors, which is how lint tables get reverted. The workable path is to ratchet. Introduce the rule at warn, so nothing fails, and count the warnings once with cargo clippy --workspace --all-targets 2>&1 | grep -c '^warning'. Fix the crates that account for most of them, then raise the rule to deny in the root. A crate that still has violations opts out for now by dropping workspace = true and carrying its own table, which is a visible, greppable debt. When the last one is fixed the override goes away.
The [workspace.lints] table also makes upgrades calmer. A new toolchain adds lints to groups such as clippy::pedantic; because the group is at warn, a toolchain bump produces new warnings, not new errors. Pin the toolchain in rust-toolchain.toml and upgrade it on purpose, in its own pull request, so the new warnings land with a fix rather than as noise in someone's feature branch.
Three ways the wiring fails
First, a member says [lints] workspace = true and the root has no [workspace.lints] table:
error inheriting `lints` from workspace root manifest's `workspace.lints`
Caused by:
`workspace.lints` was not definedSecond, a member wants to inherit the shared lints and add one of its own. Cargo does not merge them:
[lints]
workspace = true
[lints.clippy]
dbg_macro = "warn"Caused by:
cannot override `workspace.lints` in `lints`, either remove the overrides or `lints.workspace = true` and manually specify the lintsThe message offers both exits. Either keep the shared set unchanged, or drop workspace = true and copy the table with your change. For a crate with one exception the second is honest, and the alternative is an #![allow(...)] at the top of that crate's source, which is visible in review and local to the code that needs it.
Third is silence: a member that never wrote [lints] workspace = true gets none of the rules. Clippy on such a crate raised no complaint about dbg! at all, because the table is opt-in. That is the failure with no error message, and the only defence is to check. A one-liner finds the crates that forgot:
grep -L 'workspace = true' crates/*/Cargo.tomlRun it in CI or a pre-commit hook; grep -L prints the files that do not match, so any output is a member out of line. The match is loose because workspace = true also appears for dependencies, so make the pattern ^\[lints\] followed by the key if you have many inherited entries.
When to share and when to leave it local
Share what is a house rule: the edition, the license, the minimum compiler, the lints that encode your standard. Keep local what describes one crate: its name, its description, and any lint exception tied to its specific code. Do not centralise a metric that differs by purpose, such as a stricter unwrap_used = "deny" for a library crate that a binary does not need.
The limits are real. Lint inheritance is all or nothing per crate, as the second failure shows, and only Cargo 1.74 and later read the [lints] table, which sets a floor on your tooling. For per-cfg exceptions, such as allowing unwrap in tests, the table cannot help, and you still write #![cfg_attr(test, allow(clippy::unwrap_used))] in the crate. The field list is in the workspaces chapter, and the lints table is covered in the manifest reference. For the dependency counterpart of this post, see sharing dependency versions.



