The missing case that breaks everything
You are building a simple task manager. You define a Status enum to track whether a task is Pending, InProgress, or Done. You write a function that prints a badge for each status. Everything compiles. You ship it.
Two weeks later, a feature request arrives. The team needs a Cancelled state. You add the variant to the enum. You run cargo build. The compiler stops you dead. It points to your print function and complains that Cancelled is missing. You fix the function, add the badge, and the build passes. The bug that would have crashed the UI or printed a blank screen never made it to production.
That is exhaustive pattern matching. It is not a suggestion. It is a compile-time guarantee that your code accounts for every possible shape a value can take. Rust refuses to compile if you leave a gap.
What exhaustive matching actually means
An enum in Rust is a type that can be one of several distinct variants. Each variant can carry different data. When you use a match expression, you are telling the compiler how to handle each variant. Exhaustive matching means your match arms must cover every variant the enum defines. If the enum has three variants, your match needs three arms, or a catch-all that explicitly handles the rest.
Think of a sorting conveyor belt. Packages arrive in different box sizes: small, medium, large. The sorting machine has three chutes. If a new box size appears, the machine halts until an engineer installs a fourth chute. Rust's compiler is that machine. It inspects the type definition, counts the variants, and verifies your match has a path for each one.
This differs from languages like JavaScript or C++. Their switch statements do not require you to handle every case. You can forget a case, skip a break, or fall through silently. Rust makes the gap visible before the program runs.
A minimal example
Start with the enum from the prompt. It tracks IP address families.
/// Represents the family of an IP address.
enum IpAddrKind {
V4,
V6,
}
/// Routes traffic based on the IP family.
fn route(ip_kind: IpAddrKind) {
match ip_kind {
IpAddrKind::V4 => println!("Routing IPv4"),
IpAddrKind::V6 => println!("Routing IPv6"),
}
}
The match keyword takes a value. Each arm uses the => arrow to point to the code that runs when the pattern matches. The compiler checks IpAddrKind, sees two variants, and verifies both appear in the arms. The code compiles.
Now imagine you accidentally drop the V6 arm.
fn route_broken(ip_kind: IpAddrKind) {
match ip_kind {
IpAddrKind::V4 => println!("Routing IPv4"),
}
}
The compiler rejects this immediately. You get error[E0004]: non-exhaustive patterns: 'IpAddrKind::V6' not covered. The build fails. You cannot ship code that ignores a known state.
How the compiler checks your work
Pattern matching happens at compile time, not runtime. The compiler does not generate a series of if statements and hope for the best. It builds a decision tree based on the type's definition. It walks through every possible value the type can hold and verifies that at least one arm matches it.
This process catches more than missing variants. It also catches impossible arms. If you add a fourth arm for a variant that does not exist, the compiler warns you with E0002: unreachable pattern. If you reorder arms so a later arm can never trigger, the same warning appears. The compiler treats dead code as a signal that your logic is misaligned.
The check is structural. It does not care about the logic inside the arms. It only cares that every variant has a path. This separation keeps the safety guarantee strict. You can write buggy code inside an arm, but you cannot write code that silently ignores a variant.
Real-world patterns and conventions
Real enums carry data. You rarely match on unit variants alone. You destructure the payload directly in the pattern.
/// A message that can arrive from different channels.
enum Message {
Email(String),
Sms(String),
Push(String),
}
/// Logs the message content regardless of channel.
fn log_message(msg: Message) {
match msg {
Message::Email(address) => println!("Email to: {address}"),
Message::Sms(number) => println!("SMS to: {number}"),
Message::Push(token) => println!("Push to: {token}"),
}
}
The pattern Message::Email(address) matches the variant and binds the inner String to the name address. You avoid calling .unwrap() or indexing. The data flows straight into your scope.
You will often encounter cases where you only care about one variant. Writing three arms just to ignore two feels verbose. Rust provides the _ wildcard pattern.
/// Checks if a message is an email.
fn is_email(msg: &Message) -> bool {
match msg {
Message::Email(_) => true,
_ => false,
}
}
The _ arm catches everything else. It satisfies the exhaustiveness check without naming the variants. Convention dictates placing _ last. It acts as a safety net. If you add a new variant later, the wildcard absorbs it. You avoid a compiler error, but you also lose the explicit reminder to handle the new case. Use _ only when you genuinely do not care about the other variants.
When you need to both match a variant and keep the original value, use the @ binding operator.
/// Returns the full message if it is an email, otherwise returns a default.
fn get_email_or_default(msg: Message) -> String {
match msg {
Message::Email(ref addr) @ full_msg => addr.clone(),
_ => "No email".to_string(),
}
}
The @ operator binds the entire matched value to a name while still destructuring it. This is useful when you need to pass the original enum to another function after inspecting it.
Convention aside: the community prefers if let for single-arm matches. It reads cleaner and avoids the wildcard noise.
/// Checks if a message is an email using if let.
fn is_email_clean(msg: &Message) -> bool {
if let Message::Email(_) = msg {
true
} else {
false
}
}
if let is syntactic sugar for a match with one arm and a _ fallback. It does not require exhaustiveness because the else branch implicitly handles the rest. Use it when you only care about one shape. Use match when you need to handle multiple shapes or when the fallback logic is non-trivial.
Pitfalls and compiler signals
The most common pitfall is adding a variant and forgetting to update every match in the codebase. The compiler catches this, but large projects can have dozens of match expressions. You will see a wall of E0004 errors. Treat each error as a design checkpoint. Ask whether the new variant changes the logic in that function. If it does not, add a _ arm or delegate to a helper. If it does, write the new arm.
Another pitfall is overusing _ to silence the compiler. If you add a variant and immediately slap _ on every match, you defeat the purpose of exhaustive checking. The compiler stops warning you about unhandled states. You trade compile-time safety for short-term convenience. Reserve _ for truly irrelevant cases.
You will also encounter refutable versus irrefutable patterns. A pattern is irrefutable if it matches every possible value of the type. Enum variants are usually refutable because the value might be a different variant. You cannot use refutable patterns in let bindings. The compiler rejects them with E0005: refutable pattern. You must use if let or match instead.
/// This fails to compile because the pattern is refutable.
fn broken_let(msg: Message) {
let Message::Email(addr) = msg; // E0005: refutable pattern
println!("{addr}");
}
The fix is straightforward. Switch to if let when you are guessing the shape. Switch to match when you know the shape must be one of several options. The compiler will guide you.
When to match, when to skip it
Use match when you need to handle multiple variants and each requires distinct logic. Use match when the fallback behavior is complex enough to deserve its own arm. Use match when you want the compiler to force you to update the function after adding a new variant. Use if let when you only care about one variant and the rest can share a simple fallback. Use if let when you are checking for an optional value or a specific enum variant in a control flow. Use while let when you are draining a collection or iterating over a stream that yields Option or Result values. Use _ when you intentionally ignore remaining variants and accept that future additions will not trigger a compiler warning. Use @ when you need to bind the entire matched value while still destructuring its inner fields.
Trust the borrow checker. It usually has a point.