How to Use Semantic Versioning for Rust Crates

Set the version in Cargo.toml to MAJOR.MINOR.PATCH and increment the specific number based on whether your changes are breaking, new features, or bug fixes.

When version numbers become contracts

You publish a small utility crate. Three other developers add it to their projects. A week later, you find a bug. You fix it, add a tiny helper function, and push a new version. Suddenly, two of those projects fail to compile. The error points to your crate. You did not change the public API, but something shifted under the floorboards. The fix was not the problem. The version number was.

Semantic Versioning turns version numbers from arbitrary labels into a precise contract. The format is MAJOR.MINOR.PATCH. Each position carries a specific promise about how your code will behave for anyone depending on it.

Think of your crate like a public API for a building. The PATCH number is maintenance. You replace broken tiles, fix a leaky pipe, or repaint a hallway. The layout stays identical. People walking through do not notice a difference, but the experience improves. The MINOR number is an expansion. You add a new wing or open a previously locked door. The old rooms still work exactly as before, but visitors now have access to more space. The MAJOR number is a demolition and rebuild. You move the front door, change the floor plan, and remove the old elevators. Anyone using the previous blueprint will get lost. They need the new map.

Treat the version number as a promise, not a counter.

The three positions and what they promise

Cargo enforces this contract automatically. You declare your version in Cargo.toml.

[package]
name = "my_crate"
version = "1.2.3"
edition = "2024"

The 1 is the major version. The 2 is minor. The 3 is patch. Cargo reads these three digits to decide whether your update is safe to pull in automatically or whether it needs to ask the developer for permission.

The contract is strict. A patch bump means zero breaking changes. A minor bump means backward compatible additions. A major bump means you have intentionally broken the previous contract. Downstream developers rely on these guarantees to update their dependencies without manually auditing every change.

Let Cargo handle the resolution. Your job is to keep the contract honest.

How Cargo reads the contract

When another project depends on my_crate = "1.2.3", Cargo interprets that as ^1.2.3. The caret means compatible with. Cargo will automatically update to 1.2.4, 1.2.99, or 1.3.0. It will stop at 2.0.0. The dependency resolver assumes that anything below the next major version respects the original contract.

This behavior lives in Cargo.lock. That file freezes the exact versions of every transitive dependency in your project. When you run cargo update, Cargo checks the registry for new versions that satisfy the caret requirement. If your crate bumps to 1.3.0, downstream projects get the update on their next cargo update. If you bump to 2.0.0, Cargo refuses to upgrade automatically. The downstream developer must explicitly change their Cargo.toml to my_crate = "2.0.0".

The contract protects both sides. You get to improve your code without breaking strangers builds. They get to trust that cargo update will not silently introduce compile errors. The lock file is the receipt that proves which exact version compiled successfully. Do not commit Cargo.lock for libraries, but always commit it for applications. The distinction keeps library consumers flexible while keeping application builds reproducible.

A realistic upgrade cycle

Consider a crate called color_palette that converts hex strings to RGB tuples.

/// Convert a hex color string to an RGB tuple.
pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), String> {
    // Strip the leading hash if present
    let bytes = hex.strip_prefix('#').unwrap_or(hex).as_bytes();
    // Validate the string length before parsing
    if bytes.len() != 6 {
        return Err("Invalid hex length".to_string());
    }
    // Convert each two character pair into a u8 value
    Ok((
        u8::from_str_radix(&hex[1..3], 16).unwrap(),
        u8::from_str_radix(&hex[3..5], 16).unwrap(),
        u8::from_str_radix(&hex[5..7], 16).unwrap(),
    ))
}

You ship this as 1.0.0. A user adds it and calls hex_to_rgb("#ff00aa"). Everything works.

You find a bug where lowercase hex letters panic. You fix the parser and bump to 1.0.1. Downstream code compiles without changes. The contract held.

Next, you add a convenience function to convert RGB back to hex. You publish 1.1.0. Existing code still works. New users can opt into the extra feature. The contract held.

Then you decide the Result return type is clunky. You replace it with a custom ParseError enum and rename the function to parse_hex. You publish 2.0.0. Downstream code breaks immediately. The compiler complains about missing functions and type mismatches. The developer must read your changelog, update their Cargo.toml, and rewrite their calls. The major bump signaled the demolition.

The compiler will enforce the new rules. Your changelog should explain why they changed.

The hidden breaking changes

The biggest trap is assuming that only pub fn signatures matter. Rust semver rules are stricter than most languages. Adding a new method to a public trait is a breaking change. Implementing a new trait for a public type is a breaking change. Changing the error type of a Result is a breaking change. Even adding a field to a public struct can break code that uses struct update syntax.

The compiler will not catch these for you during development. You need external tools like cargo-semver-checks to scan your crate before publishing. The community treats semver compliance as a baseline expectation. Publishing a breaking change under a minor or patch bump burns trust fast.

You can soften major bumps by using the #[deprecated] attribute. Mark the old function as deprecated in version 1.5.0. Add the new function alongside it. Give users two minor versions to migrate. Then remove the old function in 2.0.0. This pattern gives downstream projects time to adapt without forcing an immediate rewrite.

Run cargo-semver-checks before every publish. The registry never forgives a broken promise.

The zero version exception

There is one major exception to the contract. Versions starting with 0 are explicitly reserved for active development. Cargo treats 0.2.3 and 0.3.0 as incompatible. A minor bump in the zero range is considered breaking. This gives you freedom to iterate rapidly before you declare a stable API.

The 0.y.z range follows a different resolution rule. ^0.2.3 allows updates to 0.2.4 or 0.2.99, but stops at 0.3.0. The caret still applies, but the boundary shifts to the minor version instead of the major version. This matches the reality of early development. You are still figuring out the public interface. Breaking changes are expected.

Once you hit 1.0.0, the contract locks in. The community expects 1.0.0 to mean the API is stable enough for production use. Do not rush to 1.0.0 just to look professional. Wait until you have tested the crate with real users and you are confident the interface will not change drastically.

Bump the right digit. Your users will trust you for it.

When to bump which number

Use a patch bump when you fix a bug or improve internal performance without changing the public interface. Use a minor bump when you add new public functions, types, or trait implementations that do not invalidate existing code. Use a major bump when you remove public items, change function signatures, alter trait bounds, or modify error types in a way that forces callers to rewrite their code. Use the 0.y.z range when your API is still experimental and you expect frequent structural changes.

The Rust community follows a few unwritten rules around versioning. Pre-release versions use hyphens, like 1.0.0-alpha.1 or 2.0.0-beta.3. Cargo treats these as lower than the release version, so 1.0.0 will always win over 1.0.0-rc.1 during resolution. When you publish to crates.io, the version is immutable. You cannot overwrite 1.2.3. If you make a mistake, you must publish 1.2.4. This immutability is why you test your crate locally with cargo publish --dry-run before pushing. It checks for missing files, license headers, and semver violations without touching the registry.

Treat every publish as a permanent record. Test the dry run first.

Where to go next