A version number on a library is a sentence addressed to every project that depends on it: "you can take this upgrade without touching your code". Semver gives the sentence a grammar, major for breaking, minor for additions, patch for fixes, and Rust makes the grammar unusually checkable because the compiler decides what "breaking" means. If a downstream crate stops compiling, it was breaking. This post lays out the changes that qualify, the ones people believe are safe and are not, and the techniques that keep the surface small enough to evolve. Everything was compiled against Rust 1.99, edition 2024, with the rules from the Cargo semver reference.
The rule is about compilation
Cargo's reference sorts changes into major (breaking), minor (possibly-breaking in rare cases that you may accept) and patch. The test for major is whether a reasonable downstream program, written against the old API, could fail to compile with the new one. Mechanical breaks fall into a short list, and each has a concrete downstream victim.
| Change | Breaks which downstream code |
|---|---|
| Remove or rename a public item | anything that names it |
| Move an item to another module without re-export | every path that used the old location |
| Change a function's parameters or return type | every caller |
Tighten a generic bound (T to T: Send) | callers whose type lacked the bound |
| Add a variant to an exhaustive enum | exhaustive match expressions |
| Add a public field to a struct with all-public fields | struct literals and exhaustive patterns |
| Add a required method to a public trait | every implementor |
| Make a private-looking guarantee observable, then remove it (auto traits) | code needing Send, Sync |
The last row surprises people. A type is Send because its fields are. Swap a field from Arc<T> to Rc<T> inside a struct and the struct silently stops being Send, which breaks every downstream user who spawned it on another thread. No public signature changed, and the compiler error lands in somebody else's project.
See an enum break a match
Version 1.0 of a library exposes an enum, and a downstream crate matches on it:
pub enum Level {
Info,
Warn,
}use mylib::Level;
fn label(l: Level) -> &'static str {
match l {
Level::Info => "info",
Level::Warn => "warn",
}
}
fn main() {
println!("{}", label(Level::Warn));
}Version 1.1 adds Error to the enum, which feels like an addition. The app, which has not changed, no longer compiles:
error[E0004]: non-exhaustive patterns: `Level::Error` not covered
--> src/main.rs:4:11
|
4 | match l {
| ^ pattern `Level::Error` not covered
|
note: `Level` defined here
--> mylib/src/lib.rs:1:1
|
1 | pub enum Level { Info, Warn, Error }
| ^^^^^^^^^^^^^^ ----- not covered
= note: the matched value is of type `Level`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
6 ~ Level::Warn => "warn",
7 ~ Level::Error => todo!(),
|This is a breaking change shipped as a minor release, and every user who ran cargo update had to stop and fix a program they did not touch. The defense is to decide when you write the enum whether it can grow:
#[non_exhaustive]
pub enum Level {
Info,
Warn,
}With the attribute, downstream code must include a wildcard arm, so a new variant does not break it. The cost lands on callers now rather than later: they write _ => ... for cases they cannot name. Use it for enums that genuinely may grow, such as error kinds and event types, and not for closed sets like Ordering where an exhaustive match is a feature. The same attribute works on structs (preventing literal construction outside the crate, so you can add fields) and on individual variants.
Traits are the sharpest edge
Adding a method to a trait is breaking unless it has a default body, because every implementor in the wild now lacks a required item. Even with a default, adding a method can collide with an inherent or other-trait method of the same name on a downstream type and make a call ambiguous, a case the reference lists as a tolerated "minor but possibly breaking". The dependable pattern for traits you do not want others to implement is to seal them:
mod private {
pub trait Sealed {}
}
pub trait Backend: private::Sealed {
fn name(&self) -> &'static str;
}
pub struct Memory;
impl private::Sealed for Memory {}
impl Backend for Memory {
fn name(&self) -> &'static str { "memory" }
}Downstream crates can use Backend but cannot implement it, because they cannot name Sealed. That makes adding methods to Backend a minor change. The price is that users cannot plug in their own backend, so seal only what you intend to own. Blanket implementations are another trap: adding impl<T: Display> MyTrait for T to a crate that previously had none breaks any downstream implementation of MyTrait for a type that is Display, producing E0119 conflicting implementations.
Changes that look breaking and are not
Several things that feel dangerous are fine. Adding a new function, type, module, trait or constant is minor. Adding a default method to a trait is minor. Relaxing a bound (from T: Copy to T) is minor, because every old call still satisfies the looser one. Making a parameter more general (&str to impl AsRef<str>) mostly is, with one exception worth knowing: it can break inference when callers passed a literal that now has several possible types, and breaks code that coerced to a function pointer. Changing private fields and private items, or the order of fields in a struct without #[repr] guarantees, is invisible. Fixing a bug is a patch even if somebody relied on the bug; stating that policy in the README spares you the argument.
Check it mechanically, write the rest down
Do not rely on reviewers to spot these. Run cargo semver-checks on every pull request: it compares rustdoc JSON of your crate with the last release and names the lint that fired. It cannot judge behaviour, which still needs a person: returning an error where you used to return a default, tightening validation, changing the order of results. List those in the changelog under a "Changed" heading and treat the user-visible ones as breaking even though the compiler will not. The release tooling in release-plz and cargo release can automate the version bump, but the policy has to come from you.
Keep the surface small on purpose
The cheapest breaking change is the one you cannot make because you never exposed the item. Keep fields private and offer constructors and builders, so adding a field is invisible. Return impl Iterator or a newtype instead of a concrete internal type, which lets you swap the implementation. Re-export dependency types only when they are part of your contract, because a public serde_json::Value in your API makes every serde_json major release a major release for you too. Public API design expands each of those, and attributes covers #[non_exhaustive] and #[deprecated], the tool for removing things gently over one major cycle.
Cases where semver is not the right frame
For an application, the version describes a release, not a compatibility contract, and none of this applies; a CalVer or build number may say more. For a 0.x library, Cargo treats the minor number as the breaking position, so 0.4 to 0.5 may break while 0.4.1 to 0.4.2 may not, and the discipline here is the same with the numbers shifted. And if you truly need to iterate fast on an unstable API, publish it behind a feature named unstable and document that anything under it is exempt, rather than breaking everybody for the sake of velocity.



