Cargo.toml Explained

22 min read
On this page

Cargo.toml is the build contract for a package: it declares which targets to build, where to obtain dependencies, which optional features to enable, and what build configuration to use. Cargo.lock, on the other hand, records the result of dependency resolution; the two cannot replace each other.

A package can contain one library target and multiple binary, test, or example targets. A crate is a compilation unit in Rust; do not treat package, crate, and workspace as synonyms.

Cargo.tomlDeclares dependency sources, version ranges, features, and targets1Cargo Resolves Dependency GraphMerges applicable features, selects package versions satisfying constraints2Cargo.lockRecords the versions, sources, and checksums used in this resolution3Compilation and Linking4Build Reproducibility: Constraints, Resolution Results, and Environment Are All IndispensableAlso depends on toolchain, target, native libraries, and build script inputsLock files pin dependency choices; they do not automatically lock the entire OS or toolchain.

Version Strings Are Usually Ranges

SyntaxAllowed Common Stable Version Range
"1.2.3" or "^1.2.3">=1.2.3 and <2.0.0
"=1.2.3"Exactly 1.2.3
"~1.2.3">=1.2.3 and <1.3.0
"0.2.3">=0.2.3 and <0.3.0
"0.0.3">=0.0.3 and <0.0.4

Therefore, "1.2.3" does not mean a pinned version. Pre-release versions have additional matching rules; do not directly apply this stable version table to all -alpha/-beta cases. Dependency Version Syntax

Version constraints following SemVer ranges do not mean Cargo checks whether every upstream API is truly compatible. If an existing version in the lock file still satisfies the constraints, a normal build will generally continue using it; updating dependencies should involve explicitly checking resolution changes, rather than deleting the lock file and hoping for the best.

Cargo.lock can help application and library repositories reproduce their CI results. The current Cargo guide recommends committing the lock file when in doubt; "never commit lock files for all libraries" is no longer an appropriate general rule. However, when a downstream project uses a library, it is resolved by the downstream dependency graph and is not strictly bound by the library repository's own lock file. Manifest vs Lock

Version, Source, and Import Name Are Three Dimensions

[dependencies]
json_codec = { package = "serde_json", version = "1" }

Here, the dependency package is still serde_json, but the Rust code uses the path json_codec. The package field locates the package name, while the key in the table determines the dependency's name in the current code; this is also why exported macros and procedural macros cannot arbitrarily hardcode the user's dependency names.

Dependency SourceWhat Is Pinned During ResolutionWhat Must Still Be Maintained
registry + version rangePublished versions satisfying requirementsActual selection and checksums in the lock file
git + revSpecified repository commitAbility to fetch the source and build environment for that commit
git + branchBranch requirement; lock records the resolved commitSubsequent moves of the branch do not automatically update existing lock files
pathLocal package location and manifestLocal source file contents; lock files do not save content snapshots for them

path and version can be combined: use paths for local development while verifying that the local package version satisfies constraints, and provide version requirements for dependency relationships after publishing to the registry. It is not a universal mechanism for "automatically falling back to the registry for local builds at any time." Even if Cargo.lock hasn't changed, the artifacts may change after modifying the source files of a path dependency. Multiple Locations for Dependencies

Dependency Types Determine Where They Are Available

ConfigurationPrimary Usage Location
dependenciesNormal library/program code
dev-dependenciesTests, examples, benchmarks, and other development targets
build-dependenciesbuild.rs running on the host machine
target.'cfg(...)'.dependenciesNormal dependencies when target conditions are met

When the same dependency appears in both build and normal dependencies, it may need to be compiled separately for the host and the target. Do not assume they share all features or build artifacts just because they have the same name. Target conditions should not be used to replace Cargo's optional dependency mechanism with features.

Features Are Composable Capabilities

Below is a complete small project's Cargo.toml:

[package]
name = "feature-demo"
version = "0.1.0"
edition = "2024"

[features]
default = []
json = ["dep:serde_json"]

[dependencies]
serde_json = { version = "1", optional = true }

src/main.rs:

#[cfg(feature = "json")]
fn output() -> String {
    serde_json::json!({"ready": true}).to_string()
}

#[cfg(not(feature = "json"))]
fn output() -> String {
    "ready=true".into()
}

fn main() { println!("{}", output()); }
cargo run --no-default-features
cargo run --features json
cargo tree -e features --features json

dep:serde_json explicitly activates the optional dependency, avoiding the automatic exposure of a feature with the same name as the dependency solely based on the dependency's name. The additivity of features is primarily a principle of dependency resolution and API design: applicable requests for the same package are usually merged into a union. It does not mean that cfg(not(feature=...)) syntactically prohibits removing a piece of code. Cargo Features

Dependency A disabling default features does not mean dependency B cannot re-enable the default features for the same package. Public library features should be as composable as possible; while mutually exclusive features can be checked with compile_error!, it may prevent normal dependency merging from using this library. The resolver version and build context determine which feature sets are resolved separately; this cannot be generalized into "the entire workspace unconditionally merges everything."

Dependent Adefault-features = falseDependent BEnables default, which includes stdDisabling default features on your own edge does not negate another dependency edgeShared in the same applicable resolution contextThe final feature union still includes default and stdThe diagram targets the same package instance; the resolver distinguishes contexts based on version, dependency type, target, etc.

Trace "Who Enabled It" Along Dependency Edges

If unexpected std or a specific backend appears during the build, do not only check the current package's default-features. First, use cargo tree -e features -i <package-name> to view reverse sources, then confirm if another normal dependency, development target, or build-side context is making a request.

--no-default-features does not mean "disable default features for the entire dependency graph." Each dependency's default features are still determined by their respective declarations and merge rules. To support true minimal builds, you need to compile separately with minimal feature combinations, rather than just running --all-features and assuming it covers all configurations.

Who re-enabled the default feature?

Unioning two applicable requests explains why std can remain after you disable default. Keep B’s default enabled and toggle A; std disappears only after B stops requesting it too. In real debugging, first identify the version and resolver context, then trace reverse feature edges with cargo tree.

Preparing the visual
Who re-enabled the default feature?

A disabling defaults does not veto B’s request; applicable requests combine by union.

Profiles Control Trade-offs, Not Optimal Solution Guarantees

dev defaults to debugging and incremental builds; release defaults to opt-level=3. The current default for release is lto=false, which is not an explicit cross-crate lto="thin"; false still allows local ThinLTO for the current crate under some conditions, which is different from off. Profiles

If you want to compare another set of optimizations, creating an independent profile is easier to reproduce:

[profile.release-lto]
inherits = "release"
lto = "thin"
codegen-units = 1
debug = 1

Run cargo build --profile release-lto. Reducing codegen-units or enabling LTO may increase optimization opportunities, but it may also increase time and memory usage, and may not yield the smallest and fastest artifacts simultaneously. Optimization for size (s/z) should also be measured; do not treat opt-level=3 as the option for the smallest size.

panic="abort" changes exception handling semantics, and catch_unwind can no longer catch such panics; it is not a harmless switch that only affects size. debug-assertions and overflow-checks also affect checking behavior; users should know the differences between their test and release configurations.

build.rs Is a Program from Build Inputs to Outputs

Scripts run on the host, but the compilation target might be another platform. To query the target, read TARGET or CARGO_CFG_*; do not use cfg!(target_os=...) in build.rs to judge cross-compilation targets, as that sees the host configuration of the script itself.

Output files should be placed in OUT_DIR, and dependencies on files and environments should be declared via rerun-if-changed and rerun-if-env-changed. OUT_DIR may persist across rebuilds; scripts should not assume it is always an empty directory. Changes to the script itself or build dependencies will also trigger rebuilds; it is not the case that outputting a single rerun instruction leaves only one trigger condition. Build Scripts

For a complete example of compiling C and generating bindings, see bindgen. build.rs cannot add new Cargo dependencies to itself via rustc-cfg after the dependency graph has been resolved; custom cfgs and Cargo features are two different mechanisms.

A build.rs That Shows Regeneration Results

Add label.txt to the root of the previous feature-demo project, with the content service-a. Then add the following build.rs; Cargo will automatically recognize this default filename. The script reads the configuration, encodes it as a Rust string literal, and writes it into OUT_DIR.

use std::{env, fs, path::PathBuf};
fn main() {
    println!("cargo::rerun-if-changed=label.txt");
    let label = fs::read_to_string("label.txt").expect("read label.txt");
    let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    fs::write(output.join("build_info.rs"),
              format!("pub const LABEL: &str = {:?};\n", label.trim()))
        .expect("write build_info.rs");
}

This step replaces src/main.rs with:

include!(concat!(env!("OUT_DIR"), "/build_info.rs"));
fn main() {
    assert!(!LABEL.is_empty());
    println!("{LABEL}");
}

The first cargo run prints service-a; after changing label.txt to service-b and running again, it should regenerate and print service-b. Here, {:?} is used to encode the string into a Rust literal with quotes and necessary escapes; you cannot directly concatenate unescaped external text into the source code. trim is the configuration strategy for this example; it removes leading and trailing whitespace. If the label allows these characters, a different parsing method should be used.

rerun-if-changed declares that changes to this file require the script to re-run, and OUT_DIR provides the generation directory for the current build instance. Do not manually concatenate hash directory names under target/debug/build in another workspace member; changes in target, profile, or feature context will make such path assumptions fragile.

This example does not treat label as a Cargo feature, nor does it change the dependency graph. If the input is missing, expect causes the build to fail rather than silently generating default content; real-world tools can output more helpful build diagnostics as needed.

Verify if the Configuration Achieves the Expected Outcome

cargo metadata displays packages and targets, cargo tree -e features explains feature sources, and cargo tree -d helps find duplicate versions. cargo build --locked rejects resolution changes that require modifying the lock file, but does not prohibit network access; --offline restricts the network and may fail due to insufficient local index and cache.

When a reproducible environment is needed, you must also record rust-toolchain, target, native dependencies, and external inputs for build scripts. Pinning crate versions is just one step.

Separate Verification of "Locked, Offline, Reproducible"

OptionConstraintCommon Failure Explanation
--lockedDoes not allow modifying the lock fileManifest and existing resolution are inconsistent, or the required lock does not yet exist
--offlineNo network accessInsufficient local index or source cache, which may also change the set of optional versions
--frozenRequires both locked and offlineCannot re-resolve and write the lock, nor fetch dependencies

The same lock plus the same source code does not guarantee byte-for-byte identical artifacts: toolchain, native compiler, target configuration, environment paths, and build script inputs can all affect the output. When pursuing reproducible builds, you should explicitly clarify whether you are comparing dependency versions, test results, program behavior, or final artifact hashes.

Discover Masked Problems with a Minimal Build Matrix

For the feature-demo above, at least check default/no-default features and the json feature separately. For public libraries, you should also check the declared minimum Rust version, supported targets, and necessary development targets; building only once on the latest toolchain does not prove the validity of the rust-version declaration.

cargo check is usually sufficient to quickly find type and configuration errors, but it does not replace final linking and running; issues with native libraries, exported symbols, or runtime initialization may appear later. Choose verification stages based on the content of modifications, and avoid treating "manifest can be parsed" as "all configurations can be delivered."