The Cargo Guide
Version Requirements and SemVer
Why Version Requirements Matter
In Cargo, a dependency declaration does not usually name one exact version. It usually describes a set of acceptable versions.
That means version requirements are part of package design, not just package syntax.
They affect:
- which releases Cargo is allowed to choose
- how much automatic updating can happen
- whether downstream users get bug fixes without manifest edits
- how sensitive your package is to upstream breaking changes
- how stable your public API expectations remain over time
A useful mental model is:
- the manifest states the acceptable version range
- the lockfile records the concrete versions actually chosen for a build
SemVer as the Compatibility Language
Semantic Versioning, usually called SemVer, is the compatibility language Cargo uses when interpreting version requirements.
A version has three main numeric parts:
MAJOR.MINOR.PATCHFor example:
1.4.2At a practical level, the usual expectation is:
- patch releases fix bugs without breaking compatible use
- minor releases add compatible functionality
- major releases may include breaking changes
Cargo's version requirement system is built around these compatibility expectations, although there are important details for 0.x versions that learners need to understand.
The Simplest Requirement Syntax
The most common Cargo dependency declaration looks like this:
[dependencies]
serde = "1.0.200"Beginners often assume this means "exactly version 1.0.200." In Cargo, it usually does not.
This is normally interpreted as a caret-compatible requirement, which means Cargo may choose later compatible versions too.
So this declaration is best understood as something closer to:
[dependencies]
serde = "^1.0.200"That difference is central to understanding Cargo version behavior.
Caret Requirements
Caret requirements are the default and the most common form in Cargo.
Explicit caret form:
[dependencies]
serde = "^1.0.200"Implicit caret form:
[dependencies]
serde = "1.0.200"For a 1.x.y dependency, a caret requirement generally means:
- allow updates that stay within the same major version
- reject the next major version
Conceptually:
^1.0.200 -> compatible within 1.xSo Cargo may choose something like 1.0.201, 1.1.0, or 1.9.7, but not 2.0.0.
This is why caret requirements are a strong default for most normal dependencies.
Caret Behavior for 0.x Versions
Version 0.x is special in SemVer practice because it signals that the package is still pre-1.0 and may evolve more aggressively.
This affects how compatibility is interpreted.
For example:
[dependencies]
my_dep = "0.3.5"Conceptually, this behaves like a caret requirement tied to the compatible 0.3.x line rather than the whole 0.x line.
A useful mental model is:
1.y.zcaret compatibility usually spans the full major line0.y.zcaret compatibility is much narrower
That means pre-1.0 dependencies often require more careful thinking because their compatibility surface is smaller and often more volatile.
Tilde Requirements
Tilde requirements are narrower than typical caret requirements.
[dependencies]
serde = "~1.0.200"A practical mental model is:
- caret usually allows a wider compatible range
- tilde usually sticks more closely to the specified minor line
Conceptually:
~1.0.200 -> updates within the 1.0.x lineThis can be useful when you want more controlled updates than a normal caret requirement, while still allowing patch-level movement.
Wildcard Requirements
Wildcard requirements let you specify a pattern.
Examples:
[dependencies]
serde = "1.*"[dependencies]
serde = "1.0.*"A practical way to read these is:
1.*means some version in the 1.x family1.0.*means some version in the 1.0.x line
Wildcard requirements can be expressive, but many developers prefer caret or explicit interval syntax because they communicate intent more clearly.
Exact Versions
An exact requirement pins the dependency to one exact version.
[dependencies]
serde = "=1.0.200"This means Cargo should not move to a different version unless the manifest changes.
Exact pins are useful when:
- you need very strict reproducibility constraints in the manifest itself
- you are temporarily working around a known upstream issue
- a specific version has behavior you must preserve exactly
But exact pins are often too restrictive for ordinary library development because they reduce flexibility and may block compatible bug-fix updates.
Comparison Ranges
Cargo also supports interval-style version ranges.
[dependencies]
serde = ">=1.0, <2.0"This is often conceptually similar to a broad caret-compatible 1.x policy, but some developers prefer it when they want the compatibility interval to be visually explicit.
You can also write narrower intervals:
[dependencies]
serde = ">=1.0.180, <1.1.0"This can be useful when you need to communicate a precise allowed band rather than relying on shorthand operators.
Pre-release Versions
Pre-release versions include additional labels after the normal numeric version.
Examples:
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1A dependency declaration might look like this:
[dependencies]
next_parser = "=1.0.0-beta.2"Pre-releases signal that the version is not yet a normal stable release. They are often used for testing, staged rollout, or early adoption.
A useful mental model is:
- stable releases describe ordinary published compatibility points
- pre-releases describe explicitly provisional release points
Beginners should usually avoid depending casually on pre-release versions unless they intentionally want that risk or are participating in upstream testing.
Compatibility Expectations in Practice
SemVer is not only syntax. It is a social and engineering contract.
If a package claims a new version is compatible, users expect that ordinary code will continue to work.
For example, suppose your package depends on:
[dependencies]
text_utils = "1.4"Then you are expressing trust that compatible 1.x updates will not break ordinary use.
This matters because Cargo can only automate version selection effectively if package maintainers follow reasonable compatibility discipline.
What Cargo Treats as Compatible
Cargo does not inspect your source code semantically to decide whether an upstream change is safe. Instead, it uses version requirement rules to decide what versions are allowed.
That means compatibility is operationally determined by:
- the requirement syntax in your manifest
- the versions available in the dependency source
- Cargo's resolution rules
For example:
[dependencies]
rand = "0.8.5"Cargo treats this as allowing compatible updates according to its version requirement rules for 0.8.x, not as a promise that every possible future version will be behaviorally identical in every subtle way.
That distinction is important: Cargo automates version compatibility policy, not semantic certainty.
The Lockfile and Concrete Resolution
The manifest says what versions are allowed. The lockfile records which exact versions were actually selected.
Example manifest:
[dependencies]
regex = "1"Conceptual lockfile effect:
regex resolved to 1.10.4 for this build```
A key mental model is:
- manifest requirement: a range of acceptable versions
- lockfile entry: the exact chosen version at a given moment
This is why a package can allow many compatible versions while still producing reproducible builds once a lockfile has recorded a specific resolution.Why the Lockfile Matters with Flexible Requirements
Suppose your manifest says:
[dependencies]
serde = "1"That is intentionally flexible. Without a lockfile, different resolution moments could choose different compatible 1.x releases depending on what exists at the time.
With a lockfile, the chosen exact version is recorded, which stabilizes the build until the lockfile is updated.
This gives Cargo an important balance:
- flexible compatibility in the manifest
- concrete reproducibility in the lockfile
How Version Updates Usually Happen
With a compatible requirement in the manifest, the exact version used by your build usually changes only when resolution is updated.
Typical workflow:
cargo build
cargo test
cargo updateThe important conceptual idea is that widening or narrowing compatibility happens in Cargo.toml, while moving to a newly available allowed version often happens through normal lockfile update behavior.
This means you should think about both files together:
Cargo.tomlexpresses policyCargo.lockrecords the current choice
SemVer Pressure from MSRV
MSRV means Minimum Supported Rust Version. Even when an upstream dependency remains semver-compatible at the API level, a newer release may raise the minimum Rust compiler version it needs.
Your package may therefore face pressure like this:
- dependency API is still compatible
- but the newer compatible release no longer builds on your supported compiler floor
Manifest example:
[package]
name = "msrv_demo"
version = "0.1.0"
edition = "2024"
rust-version = "1.82"
[dependencies]
some_dep = "1"This creates a design question: is the dependency range too wide for the MSRV promise you are trying to keep?
That is why version requirements are not only about API compatibility. They also interact with compiler compatibility.
Choosing Requirements with MSRV in Mind
If your package promises support for an older compiler, you may need more conservative dependency choices.
Broad requirement:
[dependencies]
some_dep = "1"Narrower requirement to stay within a known compatible line:
[dependencies]
some_dep = "~1.4.2"Or an explicit interval:
[dependencies]
some_dep = ">=1.4.2, <1.5.0"The deeper lesson is that dependency version policy is partly about runtime and API behavior, but also partly about toolchain policy.
SemVer Guidance for Consumers
As a consumer of dependencies, you usually want to choose requirements that are:
- flexible enough to receive compatible fixes
- narrow enough to avoid unintended ecosystem churn
- realistic about your MSRV and support window
For many ordinary dependencies, this is a reasonable starting point:
[dependencies]
anyhow = "1"
serde = "1"For more volatile or toolchain-sensitive dependencies, you may want tighter control.
The main idea is that requirement choice should reflect risk, not habit alone.
SemVer Guidance for Maintainers
As a maintainer, your version numbers communicate promises to downstream users.
If you publish:
1.4.0 -> 1.4.1```
users usually expect a compatible patch-level fix.
If you publish:
```sh
1.4.0 -> 1.5.0```
users usually expect new functionality without breaking ordinary existing code.
If you publish:
```sh
1.4.0 -> 2.0.0```
users expect that compatibility may be broken.
This matters because Cargo consumers structure their manifests around those expectations.How Public API Design Changes the Cost of Version Movement
If your crate exposes types from a dependency directly, then upstream semver changes can matter more to your users.
Example:
pub use serde_json::Value;
pub fn parse_value(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}Here, serde_json is more than an internal implementation detail. It becomes part of the effective public surface.
That means dependency version movement may have stronger downstream consequences than in a crate where the dependency stays fully internal.
Version Requirements in a Real Manifest
Here is a realistic mix of requirement styles:
[package]
name = "report_parser"
version = "0.1.0"
edition = "2024"
rust-version = "1.82"
[dependencies]
serde = "1"
serde_json = "1.0"
regex = "~1.10.0"
smallvec = ">=1.13, <2.0"
preview_parser = "=0.9.0"This manifest communicates several policies at once:
- broad compatible
1.xacceptance for stable widely used crates - narrower patch-line tracking for
regex - explicit range for
smallvec - exact pin for
preview_parser
That is much more expressive than a flat list of package names.
A Small Code Example That Depends on Stable Contracts
Suppose a package relies on regex and serde_json.
use regex::Regex;
use serde_json::Value;
pub fn parse_number_field(input: &str) -> Option<Value> {
let re = Regex::new(r"\d+").unwrap();
let m = re.find(input)?;
serde_json::from_str(m.as_str()).ok()
}The code itself does not show the version policy. That policy lives in the manifest.
That is why version requirements are a design layer above the source code.
When Exact Pins Are a Bad Habit
Beginners sometimes overuse exact pins because they feel safer.
For example:
[dependencies]
serde = "=1.0.200"
regex = "=1.10.4"
anyhow = "=1.0.86"This can create unnecessary friction:
- compatible bug fixes are blocked
- ecosystem integration becomes harder
- downstream resolution becomes less flexible
Exact pinning is sometimes correct, but it should usually be a deliberate exception rather than a default reflex.
When Broad Requirements Can Also Be Risky
Overly broad requirements can create their own problems.
Example:
[dependencies]
some_toolchain_sensitive_dep = "1"This may be reasonable, but if the dependency frequently raises MSRV or introduces subtle behavioral changes across minor releases, you may want a tighter interval.
The practical lesson is that there is no single perfect operator for every crate. Good version policy is contextual.
Common Beginner Mistakes
Mistake 1: assuming "1.2.3" means exactly 1.2.3.
In Cargo it usually means a caret-compatible range.
Mistake 2: ignoring the special practical behavior of pre-1.0 versions.
0.x lines are usually narrower in compatibility.
Mistake 3: treating the manifest and lockfile as if they were interchangeable.
They serve different purposes.
Mistake 4: choosing version requirements without considering MSRV.
API compatibility and compiler compatibility are not the same thing.
Mistake 5: publishing semver signals that do not match actual compatibility behavior.
That makes life harder for every downstream consumer.
Hands-On Exercise
Create a small package and practice reading requirement policies.
Start with:
cargo new version_lab --lib
cd version_labAdd dependencies with different requirement styles:
[dependencies]
serde = "1"
regex = "~1.10.0"
smallvec = ">=1.13, <2.0"Then add some code:
use regex::Regex;
pub fn has_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}Run:
cargo build
cargo testThen inspect the manifest and lockfile side by side and ask:
- which versions are allowed by policy?
- which exact versions were selected for this build?
That contrast is the fastest way to internalize the difference between version requirements and resolved versions.
Mental Model Summary
A strong mental model for Cargo version requirements and SemVer is:
- version requirements describe acceptable ranges, not usually exact versions
- caret requirements are the common default
- tilde, wildcard, exact, and interval syntax let you express narrower or more explicit policies
- pre-1.0 dependencies often require more caution because compatibility ranges are effectively tighter
- pre-release versions signal provisional release points
- Cargo uses manifest rules to decide what is compatible enough to select
- the lockfile records the exact chosen versions for a build
- MSRV can put extra pressure on how wide your dependency ranges should be
- maintainers publish version numbers as compatibility signals, and consumers rely on those signals when writing manifests
Once this model is stable, version declarations stop looking like punctuation and start looking like policy.
