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.PATCH

For example:

1.4.2

At 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.x

So 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.z caret compatibility usually spans the full major line
  • 0.y.z caret 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 line

This 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 family
  • 1.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.1

A 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 update

The 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.toml expresses policy
  • Cargo.lock records 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.x acceptance 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_lab

Add 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 test

Then 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.