Tutorial · Intermediate

Cargo Best Practices for Production Rust in 2026: 30+ Patterns Across Workspace, Profiles, Publishing & CI

A comprehensive guide to cargo best practices: expert recommendations. Follow these steps to get started.

32 min read·Intermediate·8 steps·Updated 2026

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-patternWhy it hurtsFix
1Wildcard version (serde = "*")Future minor releases can break your build invisiblyUse caret ("1.0") for libraries, exact pin ("=1.0.219") for reproducible binaries
2Not committing Cargo.lock for a binary crateTwo clean builds at two timestamps produce different binariesCommit Cargo.lock for every binary; gitignore it only for library crates
3Letting [profile.release] stay at defaultsDefault release ships ~30% larger binaries than the tuned profile and misses LTO entirelySet lto = "fat", codegen-units = 1, strip = "symbols", panic = "abort"; see the measured table below
4One giant single-crate project for everythingCompile times grow super-linearly with crate size; one byte change rebuilds the worldSplit into a Cargo workspace as soon as you cross ~3 logical components
5Duplicating dependency versions across workspace membersserde = "1.0.219" in crate A, serde = "1.0.180" in crate B compiles serde twiceCentralize via [workspace.dependencies], inherit with serde = { workspace = true }
6No cargo audit in CIA RUSTSEC advisory ships to prod before anyone sees itAdd cargo audit --deny warnings as a required CI step
7cargo test instead of cargo nextest runSerial test execution wastes 60–80% of cores on a multi-crate workspaceInstall cargo-nextest (cargo install cargo-nextest --locked) and use cargo nextest run --workspace
8Publishing without cargo publish --dry-runA missing license file, a typo in repository, or an oversized tarball reaches crates.io and burns a versionRun cargo publish --dry-run, then cargo package --list to inspect the tarball, then publish
9Leaving the dev-dependency in [dependencies] (criterion, mockall, proptest, etc.)Test-only crates ship in the published library and explode downstream compile timesMove them to [dev-dependencies] so consumers never see them
10Skipping the 2024 edition migrationNew 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.

bash
cargo new myapp           # binary; src/main.rs
cargo new --lib mylib     # library; src/lib.rs
cargo new --bin --lib both # rejected; do it manually

To produce the both-shape, run cargo new myapp then add src/lib.rs and adjust Cargo.toml:

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:

rust
// 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
        }
    }
}
rust
// 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.

DirectoryPurposeRun 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 usagecargo run --example name
benches/Benchmarks (use criterion for stable benchmarks)cargo bench
src/bin/Additional binaries beyond main.rscargo 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.

text
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.rs

The 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:

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 (20212024), 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:

toml
[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:

OperatorExampleMeansWhen to use
Caret (default)serde = "1.0" (same as ^1.0)Any >=1.0.0, <2.0.0Library [dependencies]; the default
Tildeserde = "~1.0.219"Any >=1.0.219, <1.1.0Library with a tight minimum patch
Exactserde = "=1.0.219"Exactly 1.0.219Binary [dependencies] for reproducibility
Wildcardserde = "*"Any versionNever 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:

toml
[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:

toml
[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:

SettingEffectTrade-off
lto = "fat"Link-time optimization across all crates; smaller binary, faster runtime2-4x longer link step; not incremental
lto = "thin"LTO inside each crate boundary; 80% of the gain at 20% of the costWorth the default if "fat" link time hurts
codegen-units = 1One codegen unit per crate; better optimizer visibilityLoses parallelism in the codegen phase; slower clean build
strip = "symbols"Removes debug symbols from the final binarySmaller binary; harder to symbolicate panics in prod
panic = "abort"Process aborts on panic instead of unwindingSmaller binary; no catch_unwind; not appropriate for libraries
opt-level = "z"Optimize aggressively for sizeSlower 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):

ConfigurationBinary sizeClean compileIncremental
Default [profile.release]5.8 MB18 s4 s
+ lto = "thin"4.6 MB26 s5 s
+ lto = "fat"4.1 MB41 sn/a (LTO is non-incremental)
+ codegen-units = 13.9 MB58 sn/a
+ strip = "symbols"1.4 MB58 sn/a
+ panic = "abort"1.2 MB58 sn/a
opt-level = "z" over the same stack0.9 MB62 sn/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:

toml
[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:

toml
[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:

toml
[profile.release]
incremental = false

The 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:

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:

toml
# 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.

When myapp-cli depends on myapp-core, the dependency uses a path:

toml
# 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:

toml
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 that myapp-cli 0.4.2 and myapp-core 0.4.2 were built together. The cost: a one-character change in myapp-cli bumps 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 like cargo-release help.

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.

bash
cargo release patch --workspace --execute

The 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:

toml
sign-tag = true
publish = true
push = true
shared-version = true
consolidate-commits = true

16. 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.

bash
cargo nextest run --workspace
cargo nextest run --workspace --partition count:1/4   # CI shard

Doc-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:

bash
cargo nextest run --workspace --all-features
cargo test --doc --workspace --all-features

18. 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.

rust
/// 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.

toml
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]] name = "parse_bench" harness = false

text

```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:

toml
[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:

rust
#[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)

  1. Pick a license. license = "MIT OR Apache-2.0" is the Rust ecosystem default; both files (LICENSE-MIT and LICENSE-APACHE) ship in the repo and the tarball.
  2. Write a README.md with 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.
  3. Set every metadata field: description, repository, homepage, documentation, readme, keywords (max 5), categories (from the canonical list at crates.io/category_slugs), rust-version.
  4. Run cargo fmt -- --check. Fix anything that complains.
  5. Run cargo clippy --workspace -- -D warnings. Fix or #[allow] with a comment.
  6. Run cargo test --workspace --all-features plus cargo test --doc --workspace --all-features.
  7. Run cargo doc --no-deps --all-features locally and click through the rendered HTML. Look for unintended public symbols (missing pub(crate)), broken doc-links, and absent #[doc] blocks.
  8. Run cargo publish --dry-run --allow-dirty to validate the upload without committing it. Inspect the printed file list. If target/ files appear, your .gitignore is missing rules, fix before publishing.
  9. Run cargo package --list to 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 via exclude = [...] in [package] or by .gitignore (the package builder respects gitignore).
  10. Run cargo login once, then cargo 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:

ChangeBumpExample
Bug fix, no API changePatch0.4.2 → 0.4.3
New pub function, struct, or traitMinor0.4.3 → 0.5.0 (note: 0.x is a special case where minor is breaking)
Removed pub symbol, changed signature, added required genericMajor1.2.0 → 2.0.0
Anything before 1.0: any "minor" bump is treated as breakingMinor0.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:

bash
cargo semver-checks --workspace --release-type patch

The 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:

bash
cargo yank --version 0.4.3      # prevents new downloads; existing dep-resolves still work
cargo yank --version 0.4.3 --undo

Yank 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:

markdown
# 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

yaml
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:

yaml
- 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:

yaml
env:
  CARGO_INCREMENTAL: 0

Smaller 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.

yaml
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:

bash
cargo audit --deny warnings

cargo 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:

toml
# 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.

bash
cargo build --release --locked
cargo nextest run --workspace --all-features --locked

The 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 with cargo install cargo-machete --locked) parses your Cargo.toml and 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 with cargo 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

What you'll know by the end
  • 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

What you'll need before you start
  • 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. 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. 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. 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. 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. 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. 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. 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. 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.

Common mistakes

Cargo Best Practices: Expert Recommendations FAQ

Common questions about this tutorial

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), 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.
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.
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. For libraries, do not set any of these; the downstream binary's profile is the one that matters.
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 adds more ceremony than it removes; above three, every project converges on workspaces eventually.
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.
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.
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 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/; do not cache ~/.cargo/registry/src/; 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.