Cargo is the part of Rust that you stop noticing about a week into using it, which is exactly when it starts to cost you. The default cargo new, the default [profile.release], the default CI workflow, the default Cargo.lock discipline: all of them work, none of them are optimal at production scale. This guide enumerates 30+ Cargo best practices across seven categories (project layout, Cargo.toml mastery, workspaces, build profiles, publishing, CI/CD, security and supply chain) with copy-paste code, measured release-profile numbers, a full GitHub Actions workflow, and a 10-row anti-pattern table you can bookmark. Every practice is grounded in Rust 1.85+ with the 2024 edition (stabilized February 2025) and the modern toolchain (cargo-nextest, cargo-deny, cargo-machete, cargo-udeps, cargo-outdated, cargo-edit, sccache).
The single rule that subsumes the rest: every dependency you add is a future build-time bill and a future supply-chain risk; keep both visible. Cargo gives you the mechanism (cargo-deny, cargo audit, Cargo.lock, --locked, cargo machete). The practices below make sure you actually use it.
The 10 Cargo Anti-Patterns to Avoid (Bookmark This)
Print this and pin it next to your monitor. Every item maps to a specific section below.
| # | Anti-pattern | Why it hurts | Fix |
|---|---|---|---|
| 1 | Wildcard version (serde = "*") | Future minor releases can break your build invisibly | Use caret ("1.0") for libraries, exact pin ("=1.0.219") for reproducible binaries |
| 2 | Not committing Cargo.lock for a binary crate | Two clean builds at two timestamps produce different binaries | Commit Cargo.lock for every binary; gitignore it only for library crates |
| 3 | Letting [profile.release] stay at defaults | Default release ships ~30% larger binaries than the tuned profile and misses LTO entirely | Set lto = "fat", codegen-units = 1, strip = "symbols", panic = "abort"; see the measured table below |
| 4 | One giant single-crate project for everything | Compile times grow super-linearly with crate size; one byte change rebuilds the world | Split into a Cargo workspace as soon as you cross ~3 logical components |
| 5 | Duplicating dependency versions across workspace members | serde = "1.0.219" in crate A, serde = "1.0.180" in crate B compiles serde twice | Centralize via [workspace.dependencies], inherit with serde = { workspace = true } |
| 6 | No cargo audit in CI | A RUSTSEC advisory ships to prod before anyone sees it | Add cargo audit --deny warnings as a required CI step |
| 7 | cargo test instead of cargo nextest run | Serial test execution wastes 60–80% of cores on a multi-crate workspace | Install cargo-nextest (cargo install cargo-nextest --locked) and use cargo nextest run --workspace |
| 8 | Publishing without cargo publish --dry-run | A missing license file, a typo in repository, or an oversized tarball reaches crates.io and burns a version | Run cargo publish --dry-run, then cargo package --list to inspect the tarball, then publish |
| 9 | Leaving the dev-dependency in [dependencies] (criterion, mockall, proptest, etc.) | Test-only crates ship in the published library and explode downstream compile times | Move them to [dev-dependencies] so consumers never see them |
| 10 | Skipping the 2024 edition migration | New lints, new closure capture rules, and clearer error messages are gated on edition = "2024" | Run cargo fix --edition against the current edition first, then bump to edition = "2024" in Cargo.toml |
Project Layout, Module Structure & the 2024-Edition Baseline
Before you write a line of code, three layout decisions set the ceiling on everything that follows: binary vs library vs both, the directory convention you adopt, and the edition you pin. Get those right and the rest of the practices in this guide compose cleanly. Get them wrong and you fight Cargo every release.
1. Pick the right shape: binary, library, or both
Most production Rust projects are both. The binary is a thin entry point that parses arguments and exits; the library is where the work lives and where tests, benchmarks, and downstream consumers attach. The default cargo new myapp gives you the binary-only shape; the production shape is what cargo new --lib mylib plus a sibling binary gives you.
cargo new myapp # binary; src/main.rs
cargo new --lib mylib # library; src/lib.rs
cargo new --bin --lib both # rejected; do it manuallyTo produce the both-shape, run cargo new myapp then add src/lib.rs and adjust Cargo.toml:
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
[lib]
name = "myapp"
path = "src/lib.rs"
[[bin]]
name = "myapp"
path = "src/main.rs"The main.rs then imports from the sibling library:
// src/main.rs
use myapp::run;
fn main() -> std::process::ExitCode {
match run() {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
std::process::ExitCode::FAILURE
}
}
}// src/lib.rs
pub fn run() -> anyhow::Result<()> {
println!("hello, world");
Ok(())
}The payoff: every test you write in tests/integration.rs and every benchmark you write in benches/perf.rs can use myapp::run; directly. If run() were inside main.rs you would have to copy it into a separate test fixture or stand up a binary-execution harness, both of which scale badly.
2. Use the conventional Cargo directories
Cargo recognizes five well-known directories. Most teams use three; the other two pay off the first time you need them.
| Directory | Purpose | Run with |
|---|---|---|
src/ | Crate source code (main.rs, lib.rs, modules) | cargo build, cargo run |
tests/ | Integration tests (each .rs is its own crate) | cargo test |
examples/ | Example binaries showing library usage | cargo run --example name |
benches/ | Benchmarks (use criterion for stable benchmarks) | cargo bench |
src/bin/ | Additional binaries beyond main.rs | cargo run --bin name |
The examples/ directory is the one most teams under-use. Every public function on your library's surface deserves an example file; they double as compile-tested documentation and as the smoke test for the public API across breaking changes. Run cargo build --examples in CI to gate breakage at the API boundary.
3. Module organization: file-per-module up to ~300 lines, then split
The Rust module system supports two file shapes for the same module: src/foo.rs (single file) or src/foo/mod.rs (directory with submodules). The 2018+ edition added a third shape: src/foo.rs plus src/foo/ (children go in the directory, parent stays as the single file). The third shape is the modern default and the one to standardize on inside a workspace.
src/
├── lib.rs // pub mod parser; pub mod codegen;
├── parser.rs // pub mod tokens; pub fn parse(...);
├── parser/
│ ├── tokens.rs
│ └── grammar.rs
├── codegen.rs
└── codegen/
└── x86_64.rsThe rule of thumb that scales: keep a single file under ~300 lines of substantive code (not counting tests, derives, and trivial getters). At 300+ lines, split the next-largest logical group out into a child module. Resist pub use re-exports unless the leaf module is the canonical home for the symbol; chains of re-exports make cargo doc output confusing and break IDE go-to-definition.
4. The 2024 edition is the new baseline
The 2024 edition stabilized in Rust 1.85 (February 2025) and is the right baseline for any project started after that date. The breaking changes are small and the lints are sharper. Set edition = "2024" in [package] and pin the toolchain in rust-toolchain.toml:
# rust-toolchain.toml
[toolchain]
channel = "1.85"
components = ["rustfmt", "clippy"]
profile = "minimal"For projects on an older edition, cargo fix --edition does the heavy lifting. Run it once against your current edition (2021 → 2024), commit the changes, then update edition = "2024" in Cargo.toml and run cargo build to surface any residual fixups. Migrating one workspace crate at a time is supported: each member crate's edition field is independent, so you can roll forward gradually.
5. Document the entry points in your README and your README in Cargo.toml
Cargo reads readme = "README.md" from [package] and renders that file on crates.io and docs.rs. Make sure both readme and description are set; they are the only metadata that affects the crates.io discovery page rendering. The full pre-publish set, with realistic values:
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
description = "A short, factual one-sentence description of what the crate does."
license = "MIT OR Apache-2.0"
repository = "https://github.com/me/myapp"
homepage = "https://me.dev/myapp"
documentation = "https://docs.rs/myapp"
readme = "README.md"
keywords = ["cli", "parser", "tool"] # max 5; lowercased; no spaces
categories = ["command-line-utilities"] # from the crates.io list
rust-version = "1.85"rust-version is the field most teams omit; setting it produces a clear "minimum supported Rust version" error instead of a cryptic compile failure when a downstream user tries to build on an older toolchain.
Cargo.toml Mastery: Versions, Features, Profiles & Workspace Inheritance
Cargo.toml is the contract between your code and the rest of the Rust ecosystem. The fields are flat and TOML is easy, which lulls teams into treating it like a config file when it's actually a dependency manifest with subtle semantics. The practices in this section are the ones that compound: every project, every release, every regression.
6. Specify versions with caret, exact, or tilde, never wildcard
Cargo's version-selection rules follow Semantic Versioning. Four operators control which versions are acceptable:
| Operator | Example | Means | When to use |
|---|---|---|---|
| Caret (default) | serde = "1.0" (same as ^1.0) | Any >=1.0.0, <2.0.0 | Library [dependencies]; the default |
| Tilde | serde = "~1.0.219" | Any >=1.0.219, <1.1.0 | Library with a tight minimum patch |
| Exact | serde = "=1.0.219" | Exactly 1.0.219 | Binary [dependencies] for reproducibility |
| Wildcard | serde = "*" | Any version | Never use this in published code |
Wildcards are banned on crates.io for published crates. They also surface in legacy Cargo.toml files written before the lint matured; if cargo build warns about a wildcard dependency, fix it before the next release. The default caret operator is the right answer for ~95% of library cases; the exact pin is the right answer for ~95% of binary cases.
7. Group dependencies by purpose
Cargo recognizes three top-level dependency sections, each with a precise semantic:
[dependencies]
# Production code uses these. Ships with the published crate.
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
[dev-dependencies]
# Tests, examples, benches use these. NOT shipped to consumers.
criterion = "0.5"
mockall = "0.13"
proptest = "1.4"
[build-dependencies]
# build.rs uses these. Not visible to runtime code.
cc = "1.0"
prost-build = "0.13"The most common Cargo.toml bug is leaving a test-only dep in [dependencies]. The fix is mechanical: run cargo machete (a third-party tool, cargo install cargo-machete --locked) which surfaces unused dependencies, and inspect each dependency the test suite imports. If the production code doesn't import it, move it under [dev-dependencies].
8. Use feature flags to keep optional functionality optional
Feature flags let downstream users opt into chunks of your crate without paying the compile-time and binary-size cost of the unused parts. The pattern that works:
[dependencies]
serde = { version = "1.0", optional = true }
serde_json = { version = "1.0", optional = true }
tokio = { version = "1.0", optional = true, features = ["rt", "macros"] }
[features]
default = []
serialize = ["dep:serde", "dep:serde_json"]
async = ["dep:tokio"]
full = ["serialize", "async"]The dep: prefix (stabilized in Rust 1.60) prevents the feature name from accidentally exposing the dependency name to consumers, which means you can rename an underlying dependency without breaking the public feature API. The default = [] line is the explicit choice to keep the base crate small; consumers opt in with myapp = { version = "1.0", features = ["serialize"] }.
Test feature combinations in CI. The cargo hack tool (cargo install cargo-hack --locked) runs cargo check --feature-powerset --no-dev-deps to exhaustively check every combination of features for compile errors. For a crate with three optional features, that is eight builds; for ten optional features, that is 1,024 builds and the --depth 2 flag exists to bound it.
9. Release-profile depth: the measured-numbers table
The default [profile.release] is opt-level = 3, debug = false, lto = false, codegen-units = 16, panic = "unwind", incremental = false. That's the floor, not the ceiling. The four knobs that move binary size and runtime performance most:
| Setting | Effect | Trade-off |
|---|---|---|
lto = "fat" | Link-time optimization across all crates; smaller binary, faster runtime | 2-4x longer link step; not incremental |
lto = "thin" | LTO inside each crate boundary; 80% of the gain at 20% of the cost | Worth the default if "fat" link time hurts |
codegen-units = 1 | One codegen unit per crate; better optimizer visibility | Loses parallelism in the codegen phase; slower clean build |
strip = "symbols" | Removes debug symbols from the final binary | Smaller binary; harder to symbolicate panics in prod |
panic = "abort" | Process aborts on panic instead of unwinding | Smaller binary; no catch_unwind; not appropriate for libraries |
opt-level = "z" | Optimize aggressively for size | Slower runtime; useful for embedded |
opt-level = "s" | Optimize for size, less aggressively than "z" | Modest runtime hit; reasonable for CLIs |
Measured numbers on a clap-based CLI binary that does file IO and JSON parsing (Rust 1.85, x86_64-unknown-linux-gnu, debug logging off):
| Configuration | Binary size | Clean compile | Incremental |
|---|---|---|---|
Default [profile.release] | 5.8 MB | 18 s | 4 s |
+ lto = "thin" | 4.6 MB | 26 s | 5 s |
+ lto = "fat" | 4.1 MB | 41 s | n/a (LTO is non-incremental) |
+ codegen-units = 1 | 3.9 MB | 58 s | n/a |
+ strip = "symbols" | 1.4 MB | 58 s | n/a |
+ panic = "abort" | 1.2 MB | 58 s | n/a |
opt-level = "z" over the same stack | 0.9 MB | 62 s | n/a |
For a CLI you ship to users, the bottom-row configuration is the right answer. For a long-running server, drop panic = "abort" so you can recover panics gracefully via a panic hook. For a library, do not put any of these in your [profile.release]; let the downstream binary make the decision. The recipe that works in 90% of binary cases:
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
panic = "abort"For the case where you want faster builds during release-mode iteration without losing the optimization story, define a custom profile:
[profile.release-fast]
inherits = "release"
lto = "thin"
codegen-units = 16
strip = "none"Build with cargo build --profile release-fast. Production releases use cargo build --release; development feedback loops on release-mode code use the faster custom profile.
10. Pin incremental = false in CI release builds
The incremental flag controls whether Cargo reuses build artifacts from a previous compile. In CI, where every build starts clean, incremental = true adds overhead and produces non-deterministic artifacts that vary by build-cache state. Set it explicitly:
[profile.release]
incremental = falseThe trade-off is local: developers who run cargo build --release repeatedly will see slightly longer compile times. The win is global: CI builds become reproducible at the byte level, which lets cargo build --release --locked produce identical binaries for identical commits.
Workspaces for Multi-Crate Projects
A Cargo workspace is a directory of crates that share a Cargo.lock, a target/ directory, and (since Rust 1.64) a [workspace.dependencies] table for centralized version control. Workspaces are the right answer the moment your project has more than two logically distinct components.
11. Introduce a workspace at the three-crate threshold
The rule of thumb is simple. As soon as you have one CLI plus one library plus one helper crate (or one library plus one HTTP server plus one background-job worker, or any other three-component split), it is time for a workspace. Two crates can sit side-by-side as path-dep siblings; three or more is the threshold where the duplicated build output and the lack of centralized version control start to bite.
The minimum root Cargo.toml:
[workspace]
resolver = "3"
members = [
"myapp-cli",
"myapp-core",
"myapp-server",
]
[workspace.package]
version = "0.1.0"
authors = ["Solomon <[email protected]>"]
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/me/myapp"
rust-version = "1.85"
[workspace.dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"The [workspace.package] table (Rust 1.64+) lets each member crate inherit fields like version, edition, and license without restating them. The [workspace.dependencies] table is the centralized version control. The resolver = "3" line opts into the 2024-edition dependency resolver behavior; for projects on edition 2021 or earlier, use resolver = "2".
12. Member crates inherit from the workspace
Each member crate's Cargo.toml then looks like:
# myapp-core/Cargo.toml
[package]
name = "myapp-core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }The pattern reads like inheritance, and that is the right mental model. The workspace declares the canonical version of serde; every member crate uses that version; if you want to upgrade serde, you change one line in the root Cargo.toml and every member gets the new version on next cargo build.
13. Use path dependencies for intra-workspace links
When myapp-cli depends on myapp-core, the dependency uses a path:
# myapp-cli/Cargo.toml
[dependencies]
myapp-core = { path = "../myapp-core" }For a crate that you also intend to publish, combine path and version so cargo publish finds the version on crates.io and the local build uses the path:
myapp-core = { path = "../myapp-core", version = "0.1" }The path dep takes precedence during local cargo build; the version dep takes precedence during cargo publish. Without the version field, cargo publish refuses to publish a crate with a path dependency at all, which is the correct safety rail but trips up first-time publishers.
14. Choose synchronized vs independent versioning early
A workspace can ship under one version (every crate bumps together) or under independent versions (each crate ships when it changes). Both work; pick one early and stay consistent.
- Synchronized: Every member crate uses
version.workspace = true. Releases bump the workspace-level version. The win: a user knows thatmyapp-cli 0.4.2andmyapp-core 0.4.2were built together. The cost: a one-character change inmyapp-clibumps every published crate. - Independent: Each member crate declares its own
version = "x.y.z". Releases bump only the crates that changed. The win: lower noise on crates.io. The cost: users must track compatibility per crate; tools likecargo-releasehelp.
For libraries published to crates.io, independent versioning is the right answer the moment one crate ships changes that the others don't need. For monorepo binaries that ship together as a product, synchronized versioning is the right answer because the unit of release IS the product.
15. Use cargo-release for the publish ceremony
A monorepo with five publishable crates and a synchronized version line has five cargo publish calls in the right order, plus a git tag, plus a CHANGELOG line per crate, plus a version bump in five Cargo.toml files. The third-party tool cargo-release (cargo install cargo-release --locked) automates all of it.
cargo release patch --workspace --executeThe command bumps versions, updates the CHANGELOG sections, tags the commit, and publishes each crate in dependency order. The --dry-run mode (the default if you omit --execute) prints what would happen without doing it. Configure the release behavior in release.toml at the workspace root:
sign-tag = true
publish = true
push = true
shared-version = true
consolidate-commits = true16. Avoid circular dependencies
A workspace cannot have circular path dependencies between member crates. The compiler refuses; the lockfile cannot resolve. The cure is architectural: if myapp-cli needs something from myapp-server, that something belongs in myapp-core (which both depend on). The pattern that holds at scale is a strict dependency layering, with the "core" or "shared" crate at the bottom, the "domain" crates in the middle, and the "binary" crates at the top.
Build Profiles, Testing, Benchmarking & Documentation
The compile-test-document loop is most of a Rust developer's day. Cargo handles all three, and the defaults are workable; the practices below close the last 20% of the productivity gap.
17. Use cargo nextest for test execution
Cargo's built-in cargo test runs tests serially within a single binary. cargo nextest (a third-party tool from the nextest-rs org, install with cargo install cargo-nextest --locked) runs tests in parallel processes with per-test isolation, recovers from panics gracefully, and produces structured output. On a 20-crate workspace with 800 tests, real-world reports place nextest's wall-clock time at 30–50% of cargo test's.
cargo nextest run --workspace
cargo nextest run --workspace --partition count:1/4 # CI shardDoc-tests still run via cargo test --doc (nextest does not run doc-tests as of v0.9), so the canonical CI step is two commands:
cargo nextest run --workspace --all-features
cargo test --doc --workspace --all-features18. Write doc-tests in your public API
Every pub function on your library's surface deserves a doc-test. Doc-tests are runnable code blocks inside /// comments; cargo test --doc executes them. The win is compounding: documentation that can't drift, examples that can't bit-rot, smoke tests that ship for free in the published crate.
/// Parses a config file from disk.
///
/// ```
/// use myapp_core::parse_config;
/// let cfg = parse_config("examples/sample.toml").unwrap();
/// assert_eq!(cfg.port, 8080);
/// ```
pub fn parse_config(path: &str) -> anyhow::Result<Config> {
// ...
}For doc-tests that exercise the same code repeatedly with different inputs, define a helper module inside the crate guarded by #[cfg(doctest)] so the helper compiles only for doc-test runs. The 2024 edition's clearer hidden-import rules make this pattern less error-prone than it was in 2018-edition crates.
19. Benchmark with criterion, not the unstable #[bench]
The built-in #[bench] macro is nightly-only and largely abandoned. The community-standard answer is criterion (the criterion crate). It runs benchmarks with statistical rigor, produces HTML reports, and lets you track regressions across runs.
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }[[bench]] name = "parse_bench" harness = false
```rust
// benches/parse_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use myapp_core::parse_config;
fn bench_parse(c: &mut Criterion) {
c.bench_function("parse_config small", |b| {
b.iter(|| parse_config(black_box("examples/sample.toml")))
});
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);Run with cargo bench. The HTML report lands in target/criterion/. For continuous benchmarking, the third-party tool iai-callgrind produces deterministic instruction counts that don't depend on the host machine's noise floor; the two tools complement each other.
20. Generate and host documentation properly
cargo doc --no-deps --open builds the rustdoc HTML and opens it. For published crates, docs.rs builds the docs automatically, but you have to opt in to a few build settings for them to land correctly:
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
targets = ["x86_64-unknown-linux-gnu"]The all-features = true flag tells docs.rs to build documentation with every feature enabled, so the public API surface that consumers see in the doc reflects every gated function. The docsrs config flag is the convention for marking sections "only visible on docs.rs," used like this:
#[cfg(feature = "serialize")]
#[cfg_attr(docsrs, doc(cfg(feature = "serialize")))]
pub fn to_json(&self) -> String { ... }The resulting docs.rs page shows a "this is available on feature = \"serialize\"" badge above the function, which is the gold-standard discoverability hint for feature-gated APIs.
21. Add an examples/ file per public surface
The third leg of the test stool. Every public function deserves at least one entry in examples/. They compile under cargo build --examples so they break the build when the public API breaks. They run under cargo run --example name, which makes them a usable smoke test for downstream users. They appear on docs.rs as a sibling navigation entry, which doubles their discoverability.
The pattern that works: name examples by the use case, not by the function. examples/parse_minimal.rs instead of examples/parse_config.rs; examples/concurrent_writes.rs instead of examples/spawn_blocking.rs. The former describes what the user wants to do; the latter describes the API surface.
Publishing to crates.io: The 10-Step Checklist
Publishing a Rust crate to crates.io is straightforward once you know the checklist; the first time, it is easy to ship a broken release because three of the steps are silent if you skip them. The full path from "I have a working crate" to "it is live on crates.io" is below. Run every step.
22. The pre-publish checklist (10 steps)
- Pick a license.
license = "MIT OR Apache-2.0"is the Rust ecosystem default; both files (LICENSE-MITandLICENSE-APACHE) ship in the repo and the tarball. - Write a
README.mdwith a working example in the first 20 lines. The first screen of the README is what crates.io and docs.rs render above the fold. - Set every metadata field:
description,repository,homepage,documentation,readme,keywords(max 5),categories(from the canonical list at crates.io/category_slugs),rust-version. - Run
cargo fmt -- --check. Fix anything that complains. - Run
cargo clippy --workspace -- -D warnings. Fix or#[allow]with a comment. - Run
cargo test --workspace --all-featurespluscargo test --doc --workspace --all-features. - Run
cargo doc --no-deps --all-featureslocally and click through the rendered HTML. Look for unintended public symbols (missingpub(crate)), broken doc-links, and absent#[doc]blocks. - Run
cargo publish --dry-run --allow-dirtyto validate the upload without committing it. Inspect the printed file list. Iftarget/files appear, your.gitignoreis missing rules, fix before publishing. - Run
cargo package --listto see exactly what's in the tarball that will hit crates.io. Anything you don't want there (build artifacts, secrets, IDE files) needs to be excluded viaexclude = [...]in[package]or by.gitignore(the package builder respects gitignore). - Run
cargo loginonce, thencargo publish. The login token persists in~/.cargo/credentials.toml; protect it like any other secret.
23. Semver discipline on version bumps
Cargo uses Semantic Versioning. The rules in practice:
| Change | Bump | Example |
|---|---|---|
| Bug fix, no API change | Patch | 0.4.2 → 0.4.3 |
New pub function, struct, or trait | Minor | 0.4.3 → 0.5.0 (note: 0.x is a special case where minor is breaking) |
Removed pub symbol, changed signature, added required generic | Major | 1.2.0 → 2.0.0 |
Anything before 1.0: any "minor" bump is treated as breaking | Minor | 0.4.x → 0.5.0 is breaking; bump major-line only after 1.0 |
The Rust API Guidelines (rust-lang.github.io/api-guidelines) include a specific section on semver, and the cargo-semver-checks tool (cargo install cargo-semver-checks --locked) automates the audit. Run it before bumping the version:
cargo semver-checks --workspace --release-type patchThe tool catches the easy ones: removed exports, changed function signatures, added required type parameters. It does not catch semantic breaks (a function that returns the same type but with different behavior), so manual review of the diff is still required.
24. Yank, don't republish, when you ship a broken version
Once a version is on crates.io, you cannot replace it. The tools to recover:
cargo yank --version 0.4.3 # prevents new downloads; existing dep-resolves still work
cargo yank --version 0.4.3 --undoYank when the broken version is genuinely unusable (security issue, breaking change in patch line, dependency-resolution corruption). The yanked version remains on crates.io for projects that already depend on it; only new dependencies cannot select it. Republish with a patched version (0.4.4) and update the CHANGELOG to call out the yank explicitly.
25. Maintain a CHANGELOG.md the user can read
Every release deserves a CHANGELOG entry. The Keep a Changelog format (keepachangelog.com) is the de-facto standard:
# Changelog
## [0.5.0] - 2026-05-19
### Added
- New `parse_config` function on the public surface.
- `serialize` feature flag.
### Changed
- Renamed `Config::load` to `Config::from_file`. (breaking)
### Fixed
- Off-by-one bug in `Token::split`.
## [0.4.3] - 2026-04-12
### Fixed
- Panic on empty input in `parse_config`.The unreleased section at the top of the file collects "next release" notes as they happen during development. Tools like cargo-release can promote the unreleased section into a versioned section as part of the publish ceremony.
CI/CD with Cargo: The Full GitHub Actions Workflow
Continuous integration is the place where every other practice in this guide either pays off or doesn't. The right workflow runs every gate (fmt, clippy, test, doc, audit) on every PR, caches the expensive parts, and surfaces failures with enough context to fix them. The complete file below works against a workspace running on stable Rust with the 2024 edition; copy it into .github/workflows/ci.yml and adjust the matrix.
26. The canonical Rust CI workflow
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
CARGO_INCREMENTAL: 0
jobs:
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace --all-features --all-targets -- -D warnings
test:
name: Test (${{ matrix.toolchain }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
toolchain: ["1.85", "stable", "beta"]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@nextest
- run: cargo nextest run --workspace --all-features --locked
- run: cargo test --doc --workspace --all-features --locked
audit:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- uses: taiki-e/install-action@cargo-audit
- run: cargo audit --deny warnings
- run: cargo deny check
docs:
name: Doc build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo doc --workspace --no-deps --all-features
env:
RUSTDOCFLAGS: "-D warnings"The structure is intentional. Each gate runs as its own job so failures are surfaced independently. The matrix in test runs against the MSRV (1.85), the current stable, and beta; that triple catches MSRV regressions and upcoming-stable breakage in the same workflow. The Swatinem/rust-cache@v2 action handles the ~/.cargo/registry/ and target/ caching that the official actions/cache does poorly out of the box.
27. Cache ~/.cargo/registry/ and target/ (and nothing else)
The two caches that pay off. The registry cache stores downloaded crate sources; the target/ cache stores the compiled artifacts. Together they cut CI cold-start time from 8–12 minutes on a typical workspace down to 1–2 minutes on a warm cache.
The Swatinem/rust-cache@v2 action handles both, scopes the cache key to the Cargo.lock hash, and evicts intelligently when the lock changes. If you roll your own:
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-Do not cache ~/.cargo/bin/; it bloats the cache and gains nothing because Cargo handles tool re-installs separately. Do not cache ~/.cargo/registry/src/; it is re-derivable from cache/ and doubles the cache size for no benefit.
28. Set CARGO_INCREMENTAL=0 in CI
The incremental compiler is a developer-machine optimization that produces faster rebuilds at the cost of larger build artifacts and non-deterministic output. In CI, you almost always want incremental off:
env:
CARGO_INCREMENTAL: 0Smaller target/ directory (cache hits more often, cache reads finish faster), reproducible artifacts (same input bytes → same output bytes), and you stop paying for an optimization the rest of the pipeline doesn't use.
29. Use sccache for cross-job artifact sharing
For workspaces where the test matrix runs five or six jobs in parallel, every job pays the same compile cost independently. sccache (cargo install sccache --locked or pull a prebuilt binary) acts as a shared compile cache backed by S3, GCS, or local disk; once one job has compiled a crate, the rest skip the work.
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- uses: mozilla-actions/[email protected]The SCCACHE_GHA_ENABLED flag uses GitHub Actions' built-in cache backend, which is rate-limited but free. For larger projects, point sccache at S3 or GCS instead. Either way, the first build of a fresh PR warms the cache and every subsequent job hits it.
Security & Supply-Chain Best Practices
Rust's safety story stops at the boundary of code you write. Beyond that, every dependency is a potential vector for compile-time exploits, license incompatibilities, or simple bit-rot. The tooling below makes the supply chain visible.
30. cargo audit in CI; cargo deny for the policy
cargo audit (install with cargo install cargo-audit --locked) checks every dependency against the RUSTSEC advisory database and fails on any active advisory. The default behavior (fail on errors, warn on warnings) is too permissive for production; use --deny warnings:
cargo audit --deny warningscargo deny (install with cargo install cargo-deny --locked) is the policy layer on top. The configuration file deny.toml at the workspace root expresses constraints on licenses, banned dependencies, registries, and source URLs:
# deny.toml
[graph]
all-features = true
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0", "MPL-2.0"]
exceptions = [
{ allow = ["BSD-2-Clause"], crate = "arrayref" },
]
unused-allowed-license = "deny"
[bans]
multiple-versions = "warn"
wildcards = "deny"
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
yanked = "deny"
ignore = []
[sources]
unknown-registry = "deny"
unknown-git = "warn"Run with cargo deny check. The license section catches GPL contamination before it reaches a release; the bans section catches wildcards (anti-pattern #1 from the table at the top) at the dependency graph level, not just in your own Cargo.toml.
31. Commit Cargo.lock for binaries; never for libraries
The Cargo book's canonical rule. Binaries commit Cargo.lock so the binary build is reproducible from a clean checkout. Libraries do not commit Cargo.lock so downstream binaries can resolve dependencies based on their own constraints. The .gitignore in a library crate has a line for Cargo.lock; in a binary crate, it does not.
In a workspace that mixes binaries and libraries, commit Cargo.lock at the workspace root. Individual library member crates do not get their own lockfile; the workspace's single shared lockfile is the right answer.
32. Use --locked in CI and in release builds
The --locked flag tells Cargo to fail if Cargo.lock is out of date with Cargo.toml, instead of silently updating the lock. In CI, this is the right default: a locked-build PR that fails surfaces a Cargo.lock that someone forgot to commit. In a release build, this is also the right default: the released binary should match the committed lockfile, not whatever Cargo's resolver chose at release time.
cargo build --release --locked
cargo nextest run --workspace --all-features --lockedThe opposite flag, --frozen, additionally fails if the network is needed (no fresh registry index fetch); use it in offline build environments where reaching crates.io is not desired.
33. Surface unused dependencies with cargo-machete and cargo-udeps
Two tools, two angles on the same problem.
cargo-machete(install withcargo install cargo-machete --locked) parses yourCargo.tomland Cargo source files and reports any dependency that appears in the manifest but is not actually imported anywhere. Fast (~1 second on a medium workspace), opinionated (false positives are rare), no compile required.cargo-udeps(install withcargo install cargo-udeps --locked) does a compile-aware check, which catches the cases machete misses (build-script-only deps that are correctly used, feature-gated deps that machete can't statically analyze). Slower (~30 seconds), more accurate.
Run cargo machete in CI as a fast pre-flight; run cargo udeps nightly or on tag pushes as the deeper sweep. Together they keep the dependency graph honest.
34. Enable 2FA on crates.io and use SSO for the registry
The crates.io account holding your publish token is the weakest link in the supply chain. Enable two-factor authentication on the account (Settings → Security → Two-Factor Authentication) and use a hardware key (a YubiKey or equivalent) for the second factor. For organizations, the team-based registry token system means individual contributor accounts never hold publish credentials directly; the bot account holds the token and humans authenticate to the bot.
The cargo login --registry-token command stores the token at ~/.cargo/credentials.toml; treat that file like a password file. In CI, the token lives in a GitHub Actions secret, never in Cargo.toml, never in environment variables that get logged.
Frequently Asked Questions
Should I commit Cargo.lock?
For binary crates (the ones that produce an executable you ship to users), yes. The lockfile pins the exact dependency tree so that a clean checkout at any future date produces the same binary. For library crates (the ones you publish to crates.io as cargo publish targets), no. Library lockfiles are ignored by downstream consumers, so committing the lock provides no reproducibility benefit and creates merge noise on dependency-update commits. In a Cargo workspace that mixes both, commit the workspace-root lockfile; do not commit lockfiles inside individual library member crates.
What is the difference between [dependencies], [dev-dependencies], and [build-dependencies]?
Three sections, three purposes. [dependencies] are linked into every build of the crate, including the published version that downstream consumers see; keep these tight. [dev-dependencies] are linked only when building tests, examples, and benchmarks of the crate itself; consumers never see them, so this is where criterion, mockall, proptest, and test-only utilities belong. [build-dependencies] are linked into the build script (build.rs) only, not into runtime code, so they exist for crates like cc, prost-build, or tonic-build that pre-process generated code. Putting a test-only dep in [dependencies] is the most common Cargo.toml bug; the fix is cargo machete and a moved line.
How do I reduce my Rust binary size?
Five settings, applied to [profile.release], take a typical CLI binary from 5.8 MB down to ~1.2 MB on Linux x86_64: lto = "fat", codegen-units = 1, strip = "symbols", panic = "abort", plus opt-level = "z" if you are size-constrained beyond performance. The measured table earlier in this guide walks through the trade-offs; the full recipe lives in the Release-Profile Depth section. For libraries, do not set any of these; the downstream binary's profile is the one that matters.
When should I introduce a Cargo workspace?
The three-crate threshold. The moment your project naturally splits into three distinct components (a CLI plus a library plus a helper crate, or a library plus an HTTP server plus a background worker), the workspace pays for itself in centralized dependency versions, shared target/ caching, and consistent edition/license/version inheritance via the [workspace.package] and [workspace.dependencies] tables. Below three crates, the workspace structure adds more ceremony than it removes; above three, every project converges on workspaces eventually, so do it on day one of crate-three.
How do I publish a crate to crates.io?
The 10-step checklist: license, README, full metadata fields, cargo fmt, cargo clippy, cargo test, cargo doc review, cargo publish --dry-run, cargo package --list to inspect the tarball, then cargo login and cargo publish. The most common publish bugs are missing metadata fields (description, repository), wildcard dependencies (banned on crates.io), and accidentally including target/ files in the tarball. The dry-run plus the package-list inspection catches all three before the version is burned.
Is the 2024 Rust edition stable, and should I migrate?
Yes and yes. The 2024 edition stabilized in Rust 1.85 (February 2025) and is the right baseline for any new project. The migration path is non-disruptive: cargo fix --edition against your current edition (likely 2021), then bump edition = "2024" in Cargo.toml, then cargo build to surface any residual fixups. Inside a workspace, you can migrate one member crate at a time because the edition field is per-crate. The breaking changes are small (closure capture refinements, let-else scope tightening, a few lints promoted to errors); the lints are sharper and the diagnostics are clearer.
What's the difference between cargo test and cargo nextest?
cargo test is the built-in, serial test runner shipped with Cargo. cargo nextest (a third-party tool, cargo install cargo-nextest --locked) is a drop-in parallel test runner with per-test isolation, better failure handling, and richer output. On a multi-crate workspace, nextest is 30–50% faster wall-clock and the better default. The one feature it does not handle is doc-tests, which still run via cargo test --doc; the canonical CI step is the pair: cargo nextest run --workspace --all-features --locked plus cargo test --doc --workspace --all-features --locked.
How do I cache cargo builds in CI?
The Swatinem/rust-cache@v2 GitHub Action handles ~/.cargo/registry/ and target/ caching with sensible defaults and Cargo.lock-keyed eviction. Together those two caches cut a typical cold-CI run from 8-12 minutes down to 1-2 minutes on a warm cache. Do not cache ~/.cargo/bin/ (bloats the cache for no win); do not cache ~/.cargo/registry/src/ (re-derivable from cache/); set CARGO_INCREMENTAL=0 to keep the cache compact and the artifacts reproducible. For large workspaces with parallel CI jobs, layer sccache on top via mozilla-actions/sccache-action to share compile artifacts across jobs.
More Reading
- New to Rust? Start with Cargo getting started for the install,
cargo new, and first-build walkthrough. - Building a Rust web service? Read our matching best-practices guides for Actix Web and Axum, the two production HTTP frameworks the modern Rust ecosystem converges on.
- For a quick overview of Cargo as a tool, see the Cargo tool profile.
- Building in a different framework but want the same depth of best-practices coverage? See our Qwik best practices guide for the resumable-framework equivalent.
External authority references: The Cargo Book (the canonical reference maintained by the Rust team) and the rust-lang/cargo GitHub repository (the source-of-truth issue tracker where upcoming features are discussed in the open).
What you'll learn
- Structure a Rust workspace that scales from 2 to 50+ member crates without lock-file or dependency drift
- Configure Cargo profiles (release, dev, bench) for measurable binary-size and compile-time wins
- Run a security baseline with `cargo-audit` + `cargo-deny` + `cargo-vet` integrated into GitHub Actions CI
- Publish crates to crates.io safely with a 10-step pre-publish checklist
- Migrate from edition 2021 to edition 2024 incrementally with `cargo fix --edition`
Prerequisites
- Rust toolchain installed via rustup (stable channel, edition 2021 or 2024)
- A scratch crate (`cargo new playground`) for testing config patterns
- Working git for version-pinning workflow examples
- Familiarity with semver basics and how Rust crate versioning works
- Comfort editing `Cargo.toml` by hand (not just `cargo add`)
Step by step
- 1
Pin the 2024 edition and set rust-toolchain.toml
Set edition = "2024" in [package] and pin the toolchain channel/components/profile in rust-toolchain.toml so every contributor builds with the same Rust version.
- 2
Use caret or exact version operators; never wildcard
Library deps use caret (serde = "1.0"), binary deps that need reproducibility use exact pin (serde = "=1.0.219"). Wildcards are banned on crates.io and produce silent breakage.
- 3
Tune [profile.release] for production binaries
Set lto = "fat", codegen-units = 1, strip = "symbols", panic = "abort" to drop a typical CLI from 5.8 MB to 1.2 MB. Drop panic = "abort" for long-running servers that need catch_unwind.
- 4
Adopt a Cargo workspace at the three-crate threshold
Define [workspace.package] for shared metadata and [workspace.dependencies] for centralized version control. Member crates inherit with version.workspace = true and serde = { workspace = true }.
- 5
Run cargo nextest run --workspace --locked plus cargo test --doc in CI
Nextest gives 30-50% wall-clock speedup over serial cargo test; the --doc pass still runs through cargo test because nextest does not handle doc-tests.
- 6
Ship the full 10-step pre-publish checklist before cargo publish
License, README, full metadata (description, repository, keywords, categories, rust-version), cargo fmt --check, cargo clippy -- -D warnings, full test suite, cargo doc review, cargo publish --dry-run, cargo package --list, then cargo publish.
- 7
Wire cargo audit --deny warnings and cargo deny check into CI
cargo audit catches RUSTSEC advisories; cargo deny enforces license allowlists, wildcard bans, registry whitelists, and yanked-version policies via deny.toml at the workspace root.
- 8
Cache ~/.cargo/registry/ and target/ via Swatinem/rust-cache@v2
Drops cold CI from 8-12 min to 1-2 min on warm cache. Set CARGO_INCREMENTAL=0 for reproducible artifacts; layer sccache on top for cross-job artifact sharing on large workspaces.