Accidental breakage can happen in any codebase. The Rust standard library isn't magically exempt from this β so it too now uses cargo-semver-checks to prevent accidental breakage. Here's why this took months of work by multiple Rustaceans, dozens of pull requests, and 15,000+ lines of code across the Rust repo, cargo-semver-checks, and its component libraries.
In September 2020, an unstable required method was added to a stable std trait.
The seemingly innocuous change broke async-std on nightly, and was promptly reverted.
In June 2021, a generic method was added to core's BuildHasher trait. The method accidentally did not have a where Self: Sized guard, so it made BuildHasher no longer dyn-safe. The problem was discovered during Rust 1.55-beta's crater run, and required a fix to avoid breaking stable Rust.
In July 2022, a soundness fix for iterators like ChunksMut was merged into core. The new implementation accidentally no longer implemented the Send and Sync auto-traits and needed to be patched to avoid breaking stable Rust.
In March 2026, tokio maintainers found that their test suite did not compile in Rust 1.94 on Windows. Another std trait had gained unstable methods, and the breakage was sufficiently painful that a fix was shipped in the Rust 1.94.1 point release.
I could go on. [Sidenote: There are two more instances I've found since 2020. My search was not exhaustive. Likely there are more.]
Humans simply cannot reliably catch accidental breakage. I reviewed each of the breakage-inducing PRs above, and I do not believe I could have spotted the problem on my own. Neither did the much more experienced authors and reviewers who originally participated in those PRs! Our best effort is not enough, so we turn to tooling.
cargo-semver-checks can catch all of these issues today. We have chosen to not let them happen again!
- Stability, breakage, and stability breakage
- Plugging stability info into
cargo-semver-checks - How we got here and what lies ahead
Thanks to Jakub BerΓ‘nek (kobzol), the rustdoc team, the library and library contributors teams, the RustWeek 2026 and Rust All Hands organizers, and the many other Rustaceans who put their time, energy, and goodwill toward accomplishing this goal π¦ cargo-semver-checks stands on the shoulders of giants.
Stability, breakage, and stability breakage
The Rust standard library uses stability as a mechanism to separate APIs usable in regular Rust releases from those that are experimental and only usable in nightly Rust on an opt-in basis.
As the name suggests, unstable APIs offer no stability or SemVer guarantees and may change at any time. Meanwhile, stable APIs behave exactly like the public API of any other Rust library.
To start, applying cargo-semver-checks to the standard library required understanding the difference, lest we frustrate maintainers by making CI complain about API breakage of explicitly-unstable APIs. [Sidenote: Of course, there's a difference between intended breakage of unstable APIs, and unintentional breakage of such APIs. We haven't built this yet so there's room for an even deeper integration here! But generally, breakage of unstable APIs should be reported as "here's what changed, please make sure you intended this" without blocking CI.]
There's another class of breakage too: de-stabilizing a previously-stable API. Sadly, this is also not merely a hypothetical case: this breakage flavor has precedent too. We wanted to catch this too β and we did.
Finally, items' name and existence can be stable but some of their facets, like const or a default value, may not be stable. cargo-semver-checks had to model this as well.
We needed to solve two sets of challenges: exposing stability information in rustdoc JSON so cargo-semver-checks can read it, and making stability fit into the cargo-semver-checks linting data model without needing to rewrite hundreds and hundreds of lints.
Let's discuss stability and rustdoc JSON first.
The straightforward case: item stability
Check out this example:
#[stable(feature = "example", since = "1.0.0")]
pub struct Example {
#[stable(feature = "example", since = "1.0.0")]
pub stable_field: u32,
#[unstable(feature = "example_unstable_field", issue = "none")]
pub unstable_field: u32,
}
As you can see, stable_field can be used on stable Rust, while unstable_field requires nightly Rust and an explicit opt-in with #![feature(example_unstable_field)]. [Sidenote: As a consequence, stable code cannot create a fresh Example from field expressions alone and must use .. in patterns, even though the struct isn't formally #[non_exhaustive]. Functional update syntax like Example { stable_field, ..existing } using an existing Example still works.]
Both removing stable_field and making it #[unstable] would be breaking changes of stable Rust APIs which we need to catch. Changes affecting only unstable_field are permitted, provided they do not alter stable properties of the containing type β for example, by removing a stable auto-trait implementation.
To export this data in rustdoc JSON, this PR added an Item::stability field that can be populated with the #[stable] or #[unstable] attribute data of standard library items.
Partial stability makes everything harder
Item stability isn't the whole story. An item's name and existence can be stable while only some of its capabilities are stable.
Take const functions, for example:
#[stable(feature = "example", since = "1.0.0")]
#[rustc_const_unstable(feature = "example_const", issue = "none")]
pub const fn answer() -> u32 {
42
}
Outside of const contexts, answer() can be called normally on stable Rust.
But calling it inside const is unstable, requiring nightly Rust and an explicit #![feature(example_const)] opt-in.
Removing const from this function therefore isn't a breaking change from the perspective of stable Rust.
To break stable Rust, it would have had to be #[rustc_const_stable] instead. [Sidenote: We also had to account for const trait declarations, const trait impls, and the const behavior of their associated methods β all currently unstable as of Rust 1.97.1. The in-depth research required to discover and properly handle cases like this was part of the challenge of pulling this off.]
Analogously to item stability, we added an Item::const_stability field to rustdoc JSON.
Trait items with provided defaults [Sidenote: This includes functions, associated consts, and associated types. Once again, associated type default values are themselves an unstable Rust feature, making even discovering this edge case part of the challenge here.] have a similar concept of default stability:
#[stable(feature = "example", since = "1.0.0")]
pub trait Example {
#[stable(feature = "example", since = "1.0.0")]
#[rustc_default_body_unstable(
feature = "example_default",
issue = "none"
)]
fn answer_in_trait(&self) -> u32 {
42
}
}
The answer_in_trait() method is stable, but its default implementation isn't.
Implementations of Example in stable Rust must provide their own answer_in_trait() method, while nightly Rust users who opt into #![feature(example_default)] may rely on the default.
Since stable downstream trait implementations cannot rely on an unstable default, removing it isn't a source-breaking change to stable Rust.
To expose default stability to rustdoc JSON, our PR added default_unstable fields to Function, ItemEnum::AssocConst, and ItemEnum::AssocType.
Plugging stability info into cargo-semver-checks
Making stability info available in rustdoc JSON is only half the story. How do we make use of it when linting for breakage?
Rewriting (or worse, duplicating) every lint is completely out of the question! Exponential growth over several years has produced hundreds of them, and we're still adding more!
To find the answer, compare these two cases:
// In a regular crate on crates.io:
pub struct UserExample {
pub visible: u32,
#[doc(hidden)]
pub unstable: u32,
}
// In the Rust standard library:
#[stable(feature = "example", since = "1.0.0")]
pub struct StdlibExample {
#[stable(feature = "example", since = "1.0.0")]
pub visible: u32,
#[unstable(feature = "example_unstable_field", issue = "none")]
pub unstable: u32,
}
How is UserExample::unstable different than StdlibExample::unstable? How is UserExample different than StdlibExample?
Both unstable fields have opted out of being stable public API.
Both structs technically have all-public fields. But neither struct's public API supports initialization with Example { visible, unstable } struct literal syntax because that requires naming the unstable field, which lies outside the SemVer-guaranteed public API.
Breaking unstable APIs is allowed in non-major versions. Breaking #[doc(hidden)] APIs is also allowed in non-major versions.
Stability attributes are another flavor of public API marker! We've already invested substantial effort into handling #[doc(hidden)]. We can reuse almost all of that infrastructure here too π
- Items marked
#[unstable]are considered non-public API, exactly as if they were#[doc(hidden)]. - If an item is const-unstable,
cargo-semver-checksconsiders it non-const. - If a provided default is unstable,
cargo-semver-checkspretends the default isn't provided.
Structurally, this does everything we want: breakage of stable items is reported correctly, destabilizations are considered removals from public API, and unstable items' own breakage is never reported. [Sidenote: A minor bit of UX polishing is due next: destabilizations are reported as additions of #[doc(hidden)] specifically, even though there now are several more attributes that could have caused that. We'll fix that too!]
But here's my favorite part: the lints are blissfully unaware of all this.
The reason cargo-semver-checks has had such a Cambrian explosion of lints is that writing lints remains relatively easy β both in general, and especially when compared to existing precedents in static analysis tooling. Writing a new lint is even the recommended onboarding task for new contributors!
With this approach for handling stability info, that continues to be the case: newly-written lints will just work. In correctly handling #[doc(hidden)], their stability-handling will be correct by construction too. They fall into the pit of success.
How we got here and what lies ahead
At the start of this year I wrote that I'm choosing to reject "number of lints" as a benchmark and instead seek out ways to maximize our positive impact on the Rust ecosystem.
This is a great example of the kind of work I had in mind!
The opportunity presented itself in a series of fortuituous conversations at RustWeek 2026 and the All Hands meetings with folks working on Rust's standard library. What started as an off-hand comment in a hallway chat quickly grew into a sketch of an idea, then into a concrete proposal over the course of several consecutive days of Rust-themed talks, meetings, dinners, and bus rides around the week's events.
Incidentally, this is why having All Hands immediately after a major conference like RustWeek is a phenomenal idea. It maximizes the odds of precisely this sort of lucky coincidence happening β and getting sufficient momentum to make it past all the "reasons it won't work" that often come up when a bold new idea is first born. So many other ideas benefited from this too! My hat is off to the RustWeek and All Hands organizers for a job tremendously well done!
Then it was a matter of implementing everything that had been (broadly speaking) agreed upon in those in-person conversations.
This took a while, and the work is still not fully done β we've only made it to the point where adopting cargo-semver-checks in Rust CI was definitely preferable to the status quo ante. There are still more things to iron out, and we'll keep working on those. [Sidenote: For example: better UX around stability breakage, catching breakage on more platforms and not just x86 Linux, edge cases around how exactly glob imports interact with stability and #[doc(hidden)], etc.]
Even though there's more work to do, we still have much to celebrate!
We've cut down on the amount of accidental breakage Rustaceans might have to suffer, report, triage, and fix.
Every cargo-semver-checks improvement from now on will directly benefit not just the crates.io library ecosystem but Rust itself as well.
The positive impacts of RustWeek and the All Hands continue unabated.
There's never been a better time to write Rust π¦
If you liked this essay, consider subscribing or following me on Mastodon or Bluesky. You can also fund my writing and work on cargo-semver-checks via GitHub Sponsors, for which I'd be most grateful β€