Understanding Cargo.toml

The Complete Guide

Cargo.toml is the manifest file that defines your Rust project's metadata, dependencies, and build configuration.

The manifest that runs your project

You write a Rust program that fetches data from an API. You hit run. The compiler rejects your code because it cannot find the reqwest crate. You realize Rust does not have a global package manager that silently injects libraries into your environment. You need a manifest. Cargo.toml is that manifest. It is the single source of truth for your project's identity, its dependencies, and how it builds.

Rust projects live inside a directory managed by Cargo, the build system and package manager. Every Cargo project starts with a Cargo.toml file at the root. The name stands for "Cargo TOML," where TOML is "Tom's Obvious, Minimal Language." TOML is designed to be unambiguous and easy to parse. It uses key-value pairs and sections to define configuration.

Think of Cargo.toml as a project passport. It contains your name, your version, and a list of visas (dependencies) you need to interact with other crates. Without it, Cargo does not know who you are or what you need. The file tells Cargo how to compile your code, which external crates to download, and what features to enable.

Minimal structure and the package section

A bare-bones Cargo.toml defines the package metadata and lists dependencies. The [package] section holds information about your crate itself. The [dependencies] section lists external crates required to build your project.

[package]
name = "api_fetcher"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] }

The name field sets the crate name. This is the identifier used when other projects depend on yours. The version field follows semantic versioning. The edition field specifies which version of the Rust language to use. Rust editions allow the language to evolve without breaking existing code. Setting edition = "2024" enables the latest language features and improvements available in the 2024 edition.

The [dependencies] section maps crate names to version requirements. The simple form reqwest = "0.11" requests version 0.11. The table form serde = { version = "1.0", features = ["derive"] } requests version 1.0 and enables the derive feature. Features toggle optional functionality inside a crate. The derive feature for serde allows you to use macros like #[derive(Serialize, Deserialize)].

Convention aside: The community standard is to order dependencies alphabetically. Tools like cargo sort enforce this automatically. Alphabetical ordering makes diffs readable and merges predictable. When you add a new dependency, place it in the correct alphabetical position.

How Cargo resolves dependencies

When you run cargo build, Cargo reads Cargo.toml and begins dependency resolution. It checks for a Cargo.lock file in the same directory. If the lockfile exists, Cargo uses the exact versions recorded there. If the lockfile is missing, Cargo calculates a set of versions that satisfy all constraints and writes them to Cargo.lock.

The resolution process downloads crates from crates.io, the official Rust registry. It fetches source code, compiles each crate, and links them into your binary. Cargo caches downloaded crates and compiled artifacts to speed up subsequent builds. You do not need to manage the cache manually. Cargo handles it behind the scenes.

The Cargo.lock file is critical for reproducibility. It pins every transitive dependency to a specific version. If you share your project with a teammate, the lockfile ensures they build with the exact same dependency tree. This eliminates "it works on my machine" issues caused by subtle version differences.

Convention aside: Commit Cargo.lock for binary applications. Do not commit it for libraries. Applications need deterministic builds. Libraries allow consumers to resolve their own dependency trees. The community expects this distinction. If you publish a library, add Cargo.lock to your .gitignore. If you build a binary, commit the lockfile.

Version semantics and the semver promise

Rust uses semantic versioning, or semver. The version string MAJOR.MINOR.PATCH encodes compatibility guarantees. A change in the major version indicates breaking changes. A change in the minor version adds functionality in a backward-compatible manner. A change in the patch version fixes bugs without changing the API.

When you write reqwest = "0.11", you are not requesting exactly version 0.11.0. You are requesting >=0.11.0 and <0.12.0. Cargo interprets the version requirement as a range. The shorthand 0.11 expands to the range that includes all compatible updates. This allows your project to receive bug fixes and minor improvements automatically when you run cargo update.

Prerelease versions require explicit opt-in. If a crate publishes 0.11.0-alpha.1, Cargo will not select it unless you specify the prerelease identifier. You must write reqwest = "0.11.0-alpha.1" to use an alpha release. This prevents accidental upgrades to unstable code.

Wildcard versions like * are dangerous. They allow any version, including breaking changes. Using * can cause your build to break unexpectedly when a new major version is released. Avoid wildcards in production code. Use explicit major version bounds instead.

Pitfall: If you specify a version range that cannot be satisfied, Cargo fails with a resolution error. This happens when two dependencies require incompatible versions of the same crate. For example, if crate-a requires serde = "1.0" and crate-b requires serde = "2.0", Cargo cannot resolve the conflict. You must update one of the dependencies or find an alternative.

Trust semver. The version range protects you from breaking changes. Read the changelog when you update major versions. The compiler will catch API changes, but the changelog explains the migration path.

Features and optional functionality

Features let crates expose optional functionality without forcing every user to compile everything. A crate might support multiple backends, optional serialization formats, or performance optimizations. Features allow you to enable only what you need.

[dependencies]
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"

The tokio crate uses features to control which parts of the runtime are compiled. The full feature enables all functionality, including I/O, networking, and synchronization primitives. If you only need async tasks, you can enable rt and macros instead. Smaller feature sets reduce compile times and binary size.

You can also define features in your own crate. The [features] section in Cargo.toml maps feature names to lists of dependencies or other features.

[features]
default = ["std"]
std = []
alloc = []

This example defines a default feature that enables std. It also defines std and alloc as optional features. Users can disable default features and enable only what they need by writing my_crate = { version = "0.1", default-features = false, features = ["alloc"] }.

Convention aside: When cloning an Rc, write Rc::clone(&data) instead of data.clone(). The explicit form signals that you are incrementing a reference count, not performing a deep copy. Apply the same clarity to Cargo: use explicit feature lists when you need control, and rely on default features when the crate author has chosen a sensible baseline.

Target-specific dependencies

You can conditionally include dependencies based on the target platform. This is useful when you need platform-specific crates, such as Unix-only libraries or Windows-only APIs.

[target.'cfg(unix)'.dependencies]
nix = "0.27"

[target.'cfg(windows)'.dependencies]
winapi = "0.3"

The cfg(unix) predicate matches Unix-like systems. The cfg(windows) predicate matches Windows. Cargo only downloads and compiles these dependencies when building for the matching target. This keeps your dependency tree small and avoids cross-platform conflicts.

You can also use target triples for precise control. The target triple x86_64-unknown-linux-gnu specifies a specific architecture and OS. Use target triples when you need to distinguish between similar platforms, such as different Linux distributions or embedded targets.

Pitfall: Conditional dependencies can hide issues. If you test only on your development machine, you might miss bugs in platform-specific code. Use CI pipelines to build and test on all supported targets. The compiler cannot catch missing platform coverage.

Pitfalls and compiler interactions

Cargo interacts with the compiler to provide a smooth development experience. When you run cargo check, Cargo compiles your code without linking. This verifies that your code is correct without producing a binary. cargo check is faster than cargo build because it skips the linking step. Use cargo check during development to get rapid feedback.

If you forget to add a dependency, the compiler rejects your code with an error about an unresolved import. The error message points to the missing crate. You add the crate to Cargo.toml and run cargo check again. The cycle is fast and predictable.

If you use a feature that does not exist, Cargo warns you. The warning tells you which features are available. You fix the feature name and retry. Cargo does not silently ignore unknown features.

Convention aside: Use let _ = … to discard a result in Rust code. This signals to readers that you considered the value and chose to drop it. Apply the same discipline to Cargo.toml: if you disable default features, document why. Future maintainers need to know that the omission is intentional, not an oversight.

Pitfall: Edition mismatches can cause subtle errors. If your crate uses edition 2024 features but a dependency uses edition 2015, the dependency might not compile with the same rules. Cargo handles edition transitions gracefully, but be aware that older crates might rely on deprecated behavior. Update dependencies regularly to stay on modern editions.

Decision matrix

Use [dependencies] when your crate requires another crate to compile and run in production.

Use [dev-dependencies] when you need a crate only for tests, benchmarks, or examples, not for the final binary.

Use features when a dependency offers optional functionality that you want to enable without pulling in heavy defaults.

Use [workspace] when you manage multiple related crates in a single repository and want to share dependencies and configuration.

Use edition = "2024" when you want access to the latest language improvements and are ready to update your toolchain.

Use target-specific dependencies when you need platform-specific crates and want to avoid cross-platform compilation issues.

Use explicit version bounds when you need to prevent accidental upgrades to breaking versions.

Use cargo check during development to verify correctness quickly without the overhead of linking.

Treat Cargo.toml as the single source of truth. If it is not in the manifest, it is not in the build.

Where to go next