The Cargo Guide
Dependency Declaration Fundamentals
What Dependency Declaration Means in Cargo
In Cargo, dependency declaration is the part of Cargo.toml where you describe which other packages your package needs, where they come from, when they are needed, and how strictly they should be versioned.
The most common dependency tables are:
[dependencies][dev-dependencies][build-dependencies]
A useful mental model is:
[dependencies]are needed by your package's normal code[dev-dependencies]are needed only for tests, examples, or benches[build-dependencies]are needed bybuild.rs
Dependency declarations are not just a list of names. They describe source, version policy, optionality, feature behavior, and sometimes platform or workspace inheritance.
The Simplest Dependency from crates.io
The most common case is a dependency from crates.io.
[dependencies]
regex = "1"That means Cargo should resolve regex from the default registry and select a compatible version that satisfies the requirement.
Example Rust code:
use regex::Regex;
pub fn contains_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}For a beginner, the main lesson is that dependencies usually begin with a package name and a version requirement.
The Three Main Dependency Tables
Cargo separates dependencies by role.
Normal dependencies:
[dependencies]
serde = "1"Development-only dependencies:
[dev-dependencies]
pretty_assertions = "1"Build-time dependencies:
[build-dependencies]
cc = "1"These mean different things operationally.
A package might use serde in library code:
use serde::Serialize;
#[derive(Serialize)]
pub struct User {
pub name: String
}A development-only dependency might be used only in tests:
#[test]
fn compares_output() {
pretty_assertions::assert_eq!(2 + 2, 4);
}A build dependency would be used in build.rs:
fn main() {
println!("cargo:rerun-if-changed=build.rs");
}This separation helps keep dependency intent clear.
Understanding Version Requirements
A dependency version string is usually a version requirement rather than a single exact version.
[dependencies]
regex = "1.10.0"That is normally interpreted using Cargo's default caret-compatible behavior.
A strong beginner mental model is:
- writing
"1.10.0"usually means "at least 1.10.0, but still within the compatible 1.x range" - writing
"0.3.5"usually means "at least 0.3.5, but still within the compatible 0.3.x range"
This makes dependency declarations flexible enough to receive compatible updates without requiring a manifest edit every time.
Requirement Operators and Their Meaning
Cargo supports several requirement styles.
Default caret-compatible requirement:
[dependencies]
serde = "1.0.200"Explicit caret form:
[dependencies]
serde = "^1.0.200"Tilde requirement:
[dependencies]
serde = "~1.0.200"Wildcard requirement:
[dependencies]
serde = "1.0.*"Comparison requirement:
[dependencies]
serde = ">=1.0, <2.0"Exact requirement:
[dependencies]
serde = "=1.0.200"A useful practical summary is:
- default or caret form allows compatible updates
- tilde is narrower
- wildcard expresses a pattern range
- comparison operators let you define explicit intervals
- exact version pinning is strict and should be used deliberately
When to Be Flexible and When to Be Strict
For most ordinary dependencies, a compatible range is better than an exact pin.
Reasonable general-use declaration:
[dependencies]
anyhow = "1"Stricter declaration when exact behavior matters:
[dependencies]
my_special_dep = "=2.4.1"A beginner-friendly rule is:
- use compatible ranges by default
- use stricter bounds only when you have a clear compatibility or reproducibility reason
Path Dependencies
A path dependency points to another package on the local filesystem.
[dependencies]
core_utils = { path = "../core_utils" }This is very common in local multi-package development.
Suppose the filesystem looks like this:
projects/
├── app/
│ └── Cargo.toml
└── core_utils/
└── Cargo.tomlThen app/Cargo.toml can depend on ../core_utils.
Example use:
use core_utils::normalize_name;
fn main() {
println!("{}", normalize_name(" Alice "));
}Path dependencies are ideal for local code under active development.
Git Dependencies
A git dependency pulls a package from a git repository.
[dependencies]
my_parser = { git = "https://github.com/example/my_parser.git" }You can also target a branch, tag, or revision.
[dependencies]
my_parser = { git = "https://github.com/example/my_parser.git", branch = "main" }[dependencies]
my_parser = { git = "https://github.com/example/my_parser.git", tag = "v0.4.0" }[dependencies]
my_parser = { git = "https://github.com/example/my_parser.git", rev = "8f2a6b7" }Git dependencies are useful when a package is not yet published, when you need a specific upstream fix, or when you are temporarily testing a branch.
Registry Dependencies
Cargo uses crates.io by default, but dependencies can also come from an alternate registry.
[dependencies]
internal_utils = { version = "1.2.0", registry = "company" }This is most relevant in organizations with private registries.
The conceptual difference is:
- default registry dependency: usually just name and version
- alternate registry dependency: name, version, and registry identity
Workspace Dependencies
In a workspace, dependencies can be defined centrally and inherited by members.
Workspace root manifest:
[workspace]
members = ["app", "core"]
resolver = "3"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.8.5"Member package:
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
serde.workspace = true
[dev-dependencies]
rand.workspace = trueThis keeps dependency declarations consistent across workspace members while still allowing member-specific additive features in some cases.
Optional Dependencies
A dependency can be made optional.
[dependencies]
serde = { version = "1", optional = true }An optional dependency is not enabled by default unless something activates it. This is often used to support optional capabilities like serialization, database integrations, or alternative backends.
A common pattern is to expose a feature that enables the optional dependency:
[dependencies]
serde = { version = "1", optional = true }
[features]
serde_support = ["dep:serde"]Then code can use conditional compilation:
#[cfg(feature = "serde_support")]
use serde::Serialize;
#[cfg_attr(feature = "serde_support", derive(Serialize))]
pub struct User {
pub name: String
}This is a key Cargo pattern because it lets one package support multiple capability levels.
Why Optional Dependencies Matter
Optional dependencies help keep packages lighter and more flexible.
For example, a library may not want to require serde for every user if only some users need serialization.
Without optionality:
[dependencies]
serde = { version = "1", features = ["derive"] }With optionality:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]The deeper design lesson is that dependencies can shape the public capability surface of a crate, not just its internal implementation.
Renamed Dependencies
Cargo lets you depend on a package under a different local name.
[dependencies]
json_core = { package = "serde_json", version = "1" }Then in Rust code you use the local dependency key:
pub fn parse_value() -> json_core::Value {
json_core::from_str("{\"x\":1}").unwrap()
}This is useful when:
- you want a clearer local name
- you need to distinguish multiple sources for similarly named packages
- you are migrating code or avoiding name conflicts
Path, Git, and Registry Dependencies with Renaming
Renaming also works with non-default dependency sources.
Git example:
[dependencies]
parser_git = { git = "https://github.com/example/parser.git", package = "parser" }Alternate registry example:
[dependencies]
parser_internal = { version = "1.2.0", registry = "company", package = "parser" }This can help keep source distinctions obvious in code and manifest structure.
Platform-Specific Dependencies
Cargo supports target-specific dependencies using target tables.
For example, a dependency only for Windows:
[target.'cfg(windows)'.dependencies]
winapi = "0.3"Only for Unix-like systems:
[target.'cfg(unix)'.dependencies]
libc = "0.2"A practical use case is adapting to platform APIs without forcing all users to compile platform-specific support they do not need.
Example Rust code:
#[cfg(windows)]
pub fn platform_name() -> &'static str {
"windows"
}
#[cfg(unix)]
pub fn platform_name() -> &'static str {
"unix"
}Dev Dependencies in Practice
Development dependencies are often used for testing libraries, golden-file comparison tools, or extra diagnostics used only during local development and CI.
[dev-dependencies]
pretty_assertions = "1"Example integration test:
#[test]
fn output_matches() {
pretty_assertions::assert_eq!("hello", "hello");
}The important idea is that a package consumer does not need your dev-only tooling just to use your library.
Build Dependencies in Practice
Build dependencies are compiled for and used by build.rs.
[build-dependencies]
cc = "1"Example build script:
// build.rs
fn main() {
println!("cargo:rerun-if-changed=build.rs");
}These are separate from normal dependencies because they exist for build-time logic, not runtime or library API use.
Combining Dependency Keys in Rich Declarations
A single dependency declaration can combine several properties.
[dependencies]
serde = { version = "1", optional = true, default-features = false, features = ["derive"] }Another example with renaming and path source:
[dependencies]
core_api = { package = "core_utils", path = "../core_utils" }This is one reason Cargo manifests stay compact even when dependency behavior becomes more sophisticated.
Default Features and Dependency Features
Dependencies can enable specific features of the packages they depend on.
[dependencies]
serde = { version = "1", features = ["derive"] }You can also disable a dependency's default features.
[dependencies]
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }This matters because dependency declarations are not only about selecting a package version. They also control which capabilities of that dependency are activated.
Public vs Private Dependency Implications
Cargo's dependency syntax does not force you to label a dependency as public or private in the ordinary manifest tables, but the distinction matters in API design.
A practical private dependency looks like this:
[dependencies]
regex = "1"pub fn contains_number(s: &str) -> bool {
regex::Regex::new(r"\d").unwrap().is_match(s)
}Here, callers do not need to mention regex in their own code. It is an internal implementation detail.
A practical public dependency scenario is when your public API re-exports a type or directly interoperates with that dependency's types.
[dependencies]
serde_json = "1"pub use serde_json::Value;
pub fn parse_json(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}In that case, your users now interact with a type from serde_json, so your dependency choice has stronger compatibility consequences.
A useful design principle is:
- private dependency: internal implementation detail
- public dependency: part of what users must understand, name, or interoperate with
Why Public Dependency Shape Matters
Public dependency choices influence semver flexibility.
If a dependency is purely internal, you often have more freedom to swap or upgrade it without affecting users.
If a dependency appears in your public API, changing it can become much more disruptive.
For example, this ties your public API directly to another crate:
pub use serde_json::Value;
pub fn parse_json(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}Whereas this hides the dependency behind your own type:
pub struct Document {
raw: String
}
pub fn parse_json(s: &str) -> Document {
Document { raw: s.to_string() }
}The dependency declaration may look similar, but the practical compatibility implications differ a great deal.
A Realistic Multi-Dependency Example
Here is a package with several dependency kinds at once.
[package]
name = "reporting_app"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
json_core = { package = "serde_json", version = "1" }
core_utils = { path = "../core_utils" }
[dev-dependencies]
pretty_assertions = "1"
[build-dependencies]
cc = "1"
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[features]
serde_support = ["dep:serde"]Example Rust code:
pub fn normalize_and_wrap(s: &str) -> json_core::Value {
let normalized = core_utils::normalize_name(s);
json_core::json!({ "name": normalized })
}Test:
#[test]
fn wraps_name() {
let value = crate::normalize_and_wrap(" Alice ");
pretty_assertions::assert_eq!(value["name"], "alice");
}This one manifest demonstrates ordinary, optional, renamed, path, dev-only, build-time, and target-specific dependencies together.
Common Beginner Mistakes
Mistake 1: putting test-only crates under [dependencies] instead of [dev-dependencies].
Mistake 2: using exact pins too aggressively when compatible ranges would be better.
Mistake 3: forgetting that optional dependencies usually need a feature strategy to be useful.
Mistake 4: using path dependencies casually without considering whether the package should really be part of a workspace.
Mistake 5: exposing third-party types in the public API without noticing the long-term compatibility cost.
Mistake 6: confusing the package's local dependency name with the published package name when using package = ... renaming.
Hands-On Exercise
Create a small library package and practice several dependency forms.
Start with:
cargo new dep_lab --lib
cd dep_labAdd a normal dependency:
[dependencies]
regex = "1"Use it:
use regex::Regex;
pub fn contains_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}Then add a dev dependency:
[dev-dependencies]
pretty_assertions = "1"Write a test:
#[cfg(test)]
mod tests {
use super::contains_number;
#[test]
fn detects_digits() {
pretty_assertions::assert_eq!(contains_number("abc123"), true);
}
}Then add an optional dependency:
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
serde_support = ["dep:serde"]This exercise helps turn the manifest from a flat list into a structured dependency model.
Mental Model Summary
A strong mental model for Cargo dependency declaration is:
[dependencies]are for normal package code[dev-dependencies]are for tests, examples, and benches[build-dependencies]are for build scripts- version requirements usually describe compatible ranges, not single fixed versions
- dependencies can come from crates.io, paths, git repositories, alternate registries, or workspace inheritance
- dependencies can be optional, renamed, feature-configured, and platform-specific
- some dependencies stay private implementation details, while others effectively become part of your public API surface
Once this model is stable, dependency declarations become much easier to read as design decisions rather than just manifest syntax.
