Cargo.toml
everything cargo.toml can contain, what each field does, and when to use it.
- 1. what cargo.toml actually is
- 2.
[package]— crate identity - 3.
[dependencies]— external crates - 4.
[features]— conditional compilation - 5. target tables — bins, libs, examples, tests, benches
- 6.
[profile.*]— how cargo compiles - 7.
[workspace]— multi-crate projects - 8.
[patch]— override dependencies - 9.
[lints]— crate-level lint config - 10.
[package.metadata.*]— custom tool namespaces - 11. full annotated example
- 12. things that will burn you
1. what cargo.toml actually is
Cargo.toml is the manifest — cargo’s source of truth for your entire crate or workspace. it lives at the root of every Rust project and controls:
- crate identity and metadata (for crates.io publishing)
- what gets compiled and when
- which external crates you depend on
- which features exist and what they enable
- how cargo optimizes your code per-profile
- how a multi-crate workspace fits together
cargo also generates Cargo.lock automatically — a pinned snapshot of every resolved dependency version. never edit .lock by hand. commit it for binaries, .gitignore it for libraries.
2. [package] — crate identity
every Cargo.toml must start with this. tells cargo and crates.io what this crate is.
[package]
name = "my-crate" # crate name — must match ^[a-zA-Z][a-zA-Z0-9_-]*$
version = "0.1.0" # semver — MAJOR.MINOR.PATCH
edition = "2021" # Rust edition: "2015", "2018", "2021"
all optional fields
[package]
authors = ["Alice <alice@example.com>", "Bob"]
description = "A short one-liner for crates.io search"
license = "MIT OR Apache-2.0" # SPDX identifier
license-file = "LICENSE.txt" # use this if license is non-standard
repository = "https://github.com/user/repo"
homepage = "https://myproject.io"
documentation = "https://docs.rs/my-crate"
readme = "README.md" # shown on crates.io page
keywords = ["parser", "cli"] # max 5, drives crates.io search
categories = ["command-line-utilities"] # must be from crates.io taxonomy list
publish = true # false = `cargo publish` is blocked forever
rust-version = "1.70" # MSRV — minimum supported Rust version
build = "build.rs" # path to build script (default: build.rs if it exists)
links = "openssl" # name of native library this crate links to
include = ["src/**", "Cargo.toml", "README.md"] # whitelist — only these go in the .crate
exclude = ["tests/fixtures/**", "benches/**"] # blacklist — include takes priority if both set
workspace = ".." # path to workspace root if not the default
autobins = true # auto-discover binaries in src/bin/
autoexamples = true # auto-discover examples in examples/
autotests = true # auto-discover integration tests in tests/
autobenches = true # auto-discover benchmarks in benches/
edition controls which Rust language rules apply. 2021 is the right default for new projects. each edition is backwards compatible — old crates still build fine.
rust-version causes cargo to error early if the toolchain is too old, instead of failing mid-compile with a cryptic error. set it to the oldest Rust version your CI tests against.
3. [dependencies] — external crates
three dependency tables exist. identical syntax, different compile-time behavior:
| table | compiled when |
|---|---|
[dependencies] |
always — linked into your final artifact |
[dev-dependencies] |
only during cargo test and cargo bench |
[build-dependencies] |
only when running build.rs |
version strings
serde = "1" # ≥1.0.0, <2.0.0 — caret (most common, implied)
serde = "^1.0.50" # ≥1.0.50, <2.0.0 — explicit caret
serde = "~1.0" # ≥1.0.0, <1.1.0 — tilde, minor locked
serde = "=1.0.100" # exact pin
serde = "*" # any version — avoid in libraries
serde = ">=1, <2" # explicit range
all dependency keys
[dependencies]
# simplest: just a version
rand = "0.8"
# with features and feature control
serde = { version = "1", features = ["derive"], default-features = false }
# local crate on disk — version not needed
my-utils = { path = "../my-utils" }
# from a git repo
my-fork = { git = "https://github.com/user/repo" }
my-fork = { git = "https://github.com/user/repo", branch = "fix-bug" }
my-fork = { git = "https://github.com/user/repo", tag = "v1.2.3" }
my-fork = { git = "https://github.com/user/repo", rev = "abc1234" }
# optional — only compiled when a feature enables it
serde_json = { version = "1", optional = true }
# rename if crate name conflicts with a keyword or existing dep
rand_core_ = { package = "rand_core", version = "0.6" }
default-features = false disables whatever the upstream crate lists in its default feature. then features = [...] adds back exactly what you need. useful for stripping tokio down to just the runtime, or serde without the proc-macro.
platform-specific dependencies
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[target.'cfg(unix)'.dependencies]
nix = "0.26"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
any cfg() expression works. cargo evaluates it at resolve time.
4. [features] — conditional compilation
features are additive boolean flags. they’re never mutually exclusive — enabling A and B simultaneously is always valid. this is the thing people get wrong most often.
[features]
default = ["json"] # these are on unless the user opts out with default-features=false
json = ["dep:serde_json"] # enabling this feature enables the serde_json dep
async = ["dep:tokio"] # enabling this enables tokio
full = ["json", "async"] # meta-feature: enables other features, not code directly
tls = ["dep:rustls", "json"] # can enable multiple things at once
dep: syntax (Rust 1.60+)
before 1.60, every optional dep automatically created a same-named feature. dep: breaks that coupling:
[dependencies]
serde = { version = "1", optional = true }
[features]
# old way — creates a feature literally named "serde" (pollutes namespace)
serialization = ["serde"]
# new way — enables serde the dep, doesn't create a "serde" feature
serialization = ["dep:serde"]
use dep: in new code. it’s cleaner and avoids surprises.
weak dependency features
you can enable a feature on a dep only if that dep is already being used:
[features]
serde = ["serde_json?/preserve_order"]
# enables preserve_order on serde_json ONLY if something else already pulled in serde_json
# if serde_json isn't in the dep graph, this silently does nothing
using features in code
#[cfg(feature = "json")]
pub mod json {
// only compiled when the "json" feature is enabled
}
#[cfg(not(feature = "async"))]
fn fallback_sync() { ... }
// at runtime — avoid this, prefer cfg() at compile time
if cfg!(feature = "json") { ... }
[!note] features from
[dev-dependencies]don’t exist for the library itself — only for test/bench builds. a feature that activates a dev-only dep must still live in[features], but the dep goes in[dev-dependencies].
5. target tables — bins, libs, examples, tests, benches
these describe what gets built. cargo auto-discovers most of them from conventional paths — you only need explicit entries to override defaults or set special options.
[lib] — one library per crate
[lib]
name = "mylib" # defaults to package name (with - replaced by _)
path = "src/lib.rs" # default — rarely override
crate-type = ["rlib"] # see table below
test = true # included in cargo test
bench = true
doc = true
doctest = true
crate-type values
| value | what it produces | when to use |
|---|---|---|
rlib |
Rust static lib | default — used when linking Rust-to-Rust |
lib |
alias for rlib | same as above |
dylib |
Rust dynamic lib | rarely, for plugin systems |
cdylib |
C-compatible dynamic lib | FFI, WASM — use this for wasm-pack |
staticlib |
C-compatible static lib | embedding Rust in C/C++ |
proc-macro |
procedural macro | derive macros, attribute macros |
[bin](bin/) — one entry per executable
double brackets = array of tables. one [bin](bin/) block per binary.
[bin](bin/)
name = "mycli"
path = "src/main.rs" # default if only one bin
test = true
bench = false
required-features = ["cli"] # only built if "cli" feature is enabled
anything in src/bin/*.rs is auto-discovered as a separate binary named after the file.
[example](example/)
[example](example/)
name = "basic"
path = "examples/basic.rs"
required-features = ["async"] # gate example behind a feature
crate-type = ["cdylib"] # if your example needs to be a shared lib
run with cargo run --example basic.
[test](test/)
files in tests/*.rs are auto-discovered as integration tests. override behavior with explicit entries:
[test](test/)
name = "integration"
path = "tests/integration.rs"
harness = false # use a custom test runner instead of libtest (e.g. for criterion, nextest)
[bench](bench/)
[bench](bench/)
name = "throughput"
path = "benches/throughput.rs"
harness = false # criterion.rs requires this — it provides its own main()
6. [profile.*] — how cargo compiles
profiles control compiler flags. four built-in profiles, each maps to a usage:
| profile | activated by | purpose |
|---|---|---|
dev |
cargo build / cargo run |
fast compiles, debuggable |
release |
cargo build --release |
slow compile, fast binary |
test |
cargo test |
dev settings + debug |
bench |
cargo bench |
release settings |
all profile keys
[profile.dev]
opt-level = 0 # 0 = no optimization, 1-3 = increasing, "s" = size, "z" = min size
debug = true # true = full, false = none, 0-2 = levels, "line-tables-only"
debug-assertions = true # enables debug_assert!()
overflow-checks = true # panic on integer overflow
lto = false # link-time optimization: false, true ("fat"), "thin", "off"
panic = "unwind" # "unwind" (default) or "abort" (smaller binary, no stack traces)
incremental = true # cache for faster incremental rebuilds
codegen-units = 256 # parallel codegen: more = faster compile, less = more optimized
rpath = false # embed runtime path in binary (unix only)
strip = "none" # "none", "debuginfo", "symbols"
split-debuginfo = "unpacked" # how debug info is stored: "off", "packed", "unpacked"
[profile.release]
opt-level = 3
debug = false
lto = true # fat LTO — slow compile, maximum runtime speed
codegen-units = 1 # 1 = maximum optimization
panic = "abort" # smaller binary, unrecoverable panics
strip = "symbols"
overflow-checks = false
custom profiles
[profile.profiling]
inherits = "release" # start from release, override specific keys
debug = true # add debug info for profilers like perf/instruments
strip = "none" # don't strip, profiler needs symbols
activate with cargo build --profile profiling.
per-dependency profile overrides
you can set a different opt-level for specific deps — useful to keep your crate fast to compile in dev while making a slow dep (like image codecs) still run fast:
[profile.dev.package.image]
opt-level = 3 # always optimize this dep even in dev
[profile.dev.package."*"]
opt-level = 1 # optimize all deps in dev, just not your own code
7. [workspace] — multi-crate projects
a workspace is a collection of crates that share one Cargo.lock and one target/ directory.
# root Cargo.toml (the workspace manifest)
[workspace]
members = [
"crates/core",
"crates/cli",
"crates/server",
"tools/*", # glob expansion works
]
exclude = ["tools/codegen"] # exclude from glob match
resolver = "2" # feature resolver version — always use "2" for new workspaces
[workspace.dependencies] — shared dep definitions
define a dep once in the workspace root, reference it everywhere:
# in workspace Cargo.toml
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
my-core = { path = "crates/core" }
# in a member crate's Cargo.toml
[dependencies]
serde = { workspace = true } # inherits version + features from workspace
tokio = { workspace = true, optional = true } # can still add optional = true
my-core = { workspace = true }
workspace = true means “use exactly what the workspace defines”. you can add optional, features on top but not override version.
[workspace.package]
share package metadata across members:
[workspace.package]
version = "0.5.0"
authors = ["Alice"]
license = "MIT"
edition = "2021"
# in a member crate:
[package]
name = "my-crate"
version = { workspace = true } # inherits "0.5.0"
8. [patch] — override dependencies
[patch] lets you replace a crate from the registry with a local path or git version — without modifying every crate that depends on it. useful for testing a fork.
[patch.crates-io]
serde = { path = "../serde-fork" } # use local fork of serde
tokio = { git = "https://github.com/tokio-rs/tokio", branch = "fix" }
# patch from a private registry
[patch."https://my-registry.com"]
my-crate = { path = "../my-crate" }
the patch applies transitively — every crate in the dep tree that uses serde gets the patched version, not just direct dependents.
[!warning]
[replace]does the same thing but is deprecated. use[patch]in all new code.
9. [lints] — crate-level lint config
control rustc and clippy lints at the crate level without #![deny(...)] littered in source files.
[lints.rust]
unsafe_code = "forbid" # levels: "forbid", "deny", "warn", "allow"
unused_imports = "warn"
dead_code = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
unwrap_used = "deny"
expect_used = "warn"
workspace lint inheritance
# in workspace Cargo.toml
[workspace.lints.rust]
unsafe_code = "forbid"
# in a member crate
[lints]
workspace = true # inherits workspace lint config
10. [package.metadata.*] — custom tool namespaces
cargo ignores any key under [package.metadata]. tools use this to store their own config in Cargo.toml instead of adding yet another config file:
[package.metadata.docs.rs]
features = ["full"] # features to enable when docs.rs builds your docs
all-features = false
default-target = "x86_64-unknown-linux-gnu"
[package.metadata.deb]
maintainer = "Alice"
depends = "$auto"
section = "utils"
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-mutable-globals"]
similarly [workspace.metadata.*] for workspace-level tool config.
11. full annotated example
[package]
name = "myapp"
version = "1.2.0"
edition = "2021"
rust-version = "1.70"
description = "A fast, async HTTP scraper"
license = "MIT OR Apache-2.0"
repository = "https://github.com/user/myapp"
[features]
default = ["tls"]
tls = ["dep:rustls"]
json = ["dep:serde_json", "dep:serde"]
full = ["tls", "json"]
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
rustls = { version = "0.21", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["test-util"] }
mockito = "1.0"
tempfile = "3"
[build-dependencies]
cc = "1.0"
[bin](bin/)
name = "myapp"
path = "src/main.rs"
[bin](bin/)
name = "myapp-admin"
path = "src/admin.rs"
required-features = ["full"]
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 4
panic = "abort"
strip = "symbols"
[profile.dev.package."*"]
opt-level = 1 # optimize deps in dev, not your own code
[package.metadata.docs.rs]
features = ["full"]
12. things that will burn you
[!warning]
Cargo.lockfor libraries: don’t commit it if you’re publishing a library to crates.io. the lock file is meaningless for library consumers — they get their own resolution. only commit.lockfor binary crates (applications).
[!warning]
default-features = falseis not transitive. if crate A depends on serde withdefault-features = false, and crate B depends on serde with defaults on, Cargo enables defaults. features are union’d across the entire dep graph. you can never globally disable a feature that someone else enables.
[!warning]
[patch]leaks into the whole workspace. if you patch serde in the workspace root, every member crate gets the patch whether they want it or not. this is usually fine but surprises people the first time.
[!note]
=pins are notCargo.lock. pinningserde = "=1.0.100"inCargo.tomlforces that exact version during resolution, but the resolved version is then recorded inCargo.lockanyway. for libraries, don’t use exact pins unless you really mean it — it makes it impossible for downstream users to upgrade serde independently.