You have just spent an entire track learning that a wildcard _ arm is a
trap — that exhaustive matching is Rust’s best refactoring tool and that
switching it off costs you silent bugs later. Now here is the other half of
the story, the half that explains why the standard library forces you to
write a wildcard in some places.
The problem: adding a variant is a breaking change
Suppose you publish a crate with this in it:
pub enum ConnectError {
Timeout,
Refused,
}
Somewhere downstream, a user writes an exhaustive match:
match err {
ConnectError::Timeout => retry(),
ConnectError::Refused => give_up(),
}
Their code compiles. Everyone is happy. Six months later you add
ConnectError::TlsHandshake, because the world changed and you need to
report it. Their code now fails to compile — E0004, non-exhaustive
patterns.
You did not remove anything. You did not change a signature. You added a variant, and every exhaustive match in every downstream crate broke. Under semver that is a major version bump, which for a widely-used library is an enormous cost to pay for one new error case.
💡Before reading on: is that behaviour a bug in Rust's design, or a feature? Argue both sides. click to reveal
It is a feature that has a cost, which is the only honest answer.
For the feature. Exhaustiveness is not a formality. If a library gains a
new error variant, every consumer who was making decisions based on the error
probably does need to think about the new case. retry() versus
give_up() is a real choice, and a TLS handshake failure belongs on the
give_up side. A compiler error is a to-do list; a silent fall-through into
a wildcard is a bug that ships.
Against. The cost is borne by everyone, including the ninety percent of downstream users who genuinely do not care about the distinction and whose wildcard-free match was incidental rather than deliberate. Forcing a major version bump for a purely additive change also distorts the ecosystem: it encourages libraries to hoard variants, to under-report failure detail, or to stringly-type their errors — all worse outcomes than the one the rule was protecting against.
The real answer is that both are true, which is exactly why the language gives you an opt-in attribute instead of picking a side.
The attribute
#[non_exhaustive]
pub enum ConnectError {
Timeout,
Refused,
}
This tells every other crate: “there may be variants you cannot see; do not write an exhaustive match on this.” Downstream code must include a wildcard arm:
match err {
ConnectError::Timeout => retry(),
ConnectError::Refused => give_up(),
_ => give_up(), // required, and now future-proof
}
With that in place, adding a variant later is a minor version bump. Nobody’s build breaks.
It works on structs too, where it means something slightly different: you may
not construct the struct with a literal, and you may not match it without
... Adding a field becomes non-breaking, and the type’s constructor
function becomes the only way in — which is the privacy lesson from the
previous item, expressed as a semver guarantee.
Where you have already met it
std::io::ErrorKind is #[non_exhaustive]. So are std::net::SocketAddr‘s
friends, many Ordering-adjacent types, and a large fraction of the error
enums in the crates you will eventually depend on. Every time you have
written _ => ... on an io::ErrorKind match and felt vaguely guilty about
it, this is why: you were not being lazy, the type was telling you the truth
about its own stability.
That is the payoff for understanding it. A learner who knows about
#[non_exhaustive] reads a forced wildcard as information about the API
rather than as a personal failing.
Why this item is an article and not a problem
Verified on rustc 1.95.0: #[non_exhaustive] is crate-scoped and a
complete no-op within the defining crate.
mod inner {
#[non_exhaustive]
pub enum E { A, B }
}
fn f(e: inner::E) -> u8 {
match e { // exhaustive, no wildcard
inner::E::A => 1,
inner::E::B => 2,
} // compiles. No error. No warning.
}
Putting it in a nested module changes nothing; module boundaries are not crate boundaries. Every submission on this site is compiled as a single crate, so there is no arrangement of one file in which the attribute has an observable effect. A “problem” here could only test whether you had typed the attribute somewhere, which grades spelling rather than understanding, so it stays an article.
(The one place in this course where you will meet the real behaviour is
the io::Error case study in Track 6, which matches against std’s genuinely
external ErrorKind and therefore genuinely requires the wildcard.)
What you can see from inside the crate
One thing is observable: clippy’s manual_non_exhaustive lint. Before
the attribute existed, library authors faked it two ways —
pub enum E {
A,
B,
#[doc(hidden)]
__Nonexhaustive, // a variant nobody is supposed to use
}
pub struct S {
pub a: u32,
_priv: (), // a private field nobody can name
}
Verified: manual_non_exhaustive fires on both forms and tells you to
use the attribute. If you want to see something related to this topic move
in your own single-file submission, that is the lever.
💡Both hacks work. Why is #[non_exhaustive] better than either of them?
click to reveal
Three reasons, in increasing order of importance.
It is honest in the documentation. rustdoc renders
#[non_exhaustive] as a visible marker on the type. The __Nonexhaustive
variant renders as a confusing, apparently-usable variant unless you hide it,
and even then readers see a mysterious gap.
It does not lie to the compiler. A __Nonexhaustive variant is a real
variant. Exhaustiveness checking inside your own crate now includes it, so
every one of your own matches has to handle a case that can never occur.
_priv: () similarly forces your own constructors to mention a field that
means nothing.
It composes correctly with the rest of the language. The attribute is understood by pattern matching, by struct literals, by rustdoc, and by semver-checking tools. The hacks are understood by none of them — they are emergent behaviour, and emergent behaviour breaks in the corners.
The honest counterpoint
#[non_exhaustive] is not free, and applying it reflexively is a mistake.
It pushes the cost onto every downstream matcher, forever. Every consumer writes a wildcard, and now none of them will ever be told about a new variant. You have traded “one loud break at upgrade time” for “silent wrong behaviour, distributed, indefinitely”. For a library where the variants are genuinely a stable, closed set — an HTTP method, a chess piece, a suit of cards — that trade is bad.
It is invisible until it is a problem. A downstream user does not
discover that your enum is non-exhaustive until their exhaustive match is
rejected, at which point the path of least resistance is _ => todo!().
It is nearly irreversible. Removing #[non_exhaustive] later is not a
breaking change technically, but adding it is, so the decision is made
once, at the point when you know least about the type.
The practical rule most library authors converge on:
- Error enums: yes. New failure modes are the most common kind of additive change, and consumers usually branch on a handful of cases and treat the rest generically.
- Configuration structs: yes. New options are the whole business model.
- Domain enums that model a closed real-world set: no. There will never be a sixth suit.
- Application-internal enums: almost never. You want the compiler to find every match site, because you own every match site. The attribute is for crossing a crate boundary you do not control, and inside your own application there is no such boundary.
The middle ground, once more
Even against a non-exhaustive type you can be more precise than _:
match err {
ConnectError::Timeout => retry(),
ConnectError::Refused => give_up(),
other => log_unhandled(&other), // a binding, not a black hole
}
A named binding at least lets you record what you did not understand. And
where the type is yours, Variant { .. } and Variant(..) remain the
right way to ignore a payload without ignoring the variant.
Summary
-
Adding an enum variant is a breaking change for every downstream exhaustive
match.
#[non_exhaustive]opts out of that by requiring a wildcard. - Its effect is entirely across crate boundaries — verified to be a complete no-op inside the defining crate, which is why this item is an article.
- It is the right default for error enums and configuration structs, and the wrong default for closed domain models and for anything internal to your own application.
-
clippy::manual_non_exhaustivecatches the two pre-attribute hacks, and is the one part of this topic you can watch fire in a single file.