Skip to Content
Match and Pattern Matching

Rust match: Pattern Matching, Exhaustiveness, and Practical Control Flow

A file importer needs different behavior for an empty file, a successful read, and a rejected record. A command-line tool needs to distinguish commands and their arguments. In both cases, the decision depends on the form of a value and sometimes on the data inside it.

Rust’s match expression puts those cases together. It selects one arm using patterns, makes matched data available to that arm, and checks that every possible input has a path through the decision. You can use it to perform an action or calculate a value.

We will start with a command name, then work through enum data, optional settings, conditions, and ownership. The final example combines these ideas in a small CLI parser.

Read Functions, Control Flow, and Enums first if those topics are new. Each Rust block here is a separate program: put it in src/main.rs and run cargo run. The examples use stable Rust and work with the Rust 2024 edition.

Start with a readable decision

Suppose a file-processing tool accepts several operation names. A match can turn each recognized name into an instruction:

src/main.rs
fn main() { let operation = "validate"; let instruction = match operation { "validate" => "Check file contents", "summarize" => "Count records", "export" => "Write a report", _ => "Show command help", }; println!("{instruction}"); }

The input is operation, a string slice. The first three patterns are string literals. The _ pattern accepts any remaining name. With "validate", the first arm produces "Check file contents", which becomes the value of instruction.

Output:

Check file contents

This is why the usual phrase “Rust match statement” can be misleading: match is an expression. The selected arm supplies its value. Here, all arms produce string slices, so the result can be assigned to one variable.

Read the syntax one arm at a time

An arm contains a pattern, an optional condition, and an expression to evaluate:

  • match operation identifies the input to inspect.
  • "validate" is the pattern for one case.
  • => separates that pattern from the arm’s body.
  • "Check file contents" is the expression evaluated when the arm is selected.
  • Commas separate arms. Keeping a trailing comma makes later edits easier.

Use braces around an arm body when it contains several statements. A comma after a block-shaped body is optional, but the examples keep it for consistency.

The semicolon after let instruction = match ...; ends the assignment. It does not remove the value produced by the match on its right.

How Rust selects a match arm

Rust evaluates the input expression once, then considers arms in source order. An arm is selected when its pattern matches and its optional guard succeeds. Only the selected body runs; there is no fallthrough into the next body.

The diagram describes the selection behavior. The compiler can optimize how the decision is implemented. Whether the cases cover all inputs is checked before the program runs.

Return values from arms and functions

A match can be the final expression in a function. This example chooses a retry delay from a response status:

src/main.rs
fn retry_delay_seconds(response_status: u16) -> u64 { match response_status { 429 => { println!("Using the longer retry delay"); 30 }, 500 | 502 | 503 | 504 => 5, _ => 0, } } fn main() { for response_status in [200, 429, 503] { let delay = retry_delay_seconds(response_status); println!("{response_status}: {delay} seconds"); } }

429 matches one integer literal. The | joins alternative patterns: any of those four server status codes selects the same arm. It is pattern syntax here, rather than the boolean || operator.

The block for 429 prints a message and ends with 30. That final expression has no semicolon, so it supplies the block’s value. The function’s final match then supplies the return value. All ordinary arm values resolve to u64, as required by the signature.

Output:

200: 0 seconds Using the longer retry delay 429: 30 seconds 503: 5 seconds

These delays are example application settings. An HTTP client would also need to decide whether retrying the particular request is appropriate.

An explicit return inside an arm exits the surrounding function, not merely the match. Use the final-expression form when the whole function is a straightforward decision.

Catch-all patterns: keep the value or ignore it

The wildcard _ matches any value without introducing a binding. Use it when the remaining cases share behavior and their input is irrelevant.

A fresh identifier is also a catch-all pattern, but it gives the matched value a name. This is useful when the fallback needs to report what it received:

src/main.rs
fn response_description(response_status: u16) -> String { match response_status { 200..=299 => String::from("Response accepted"), 404 => String::from("Resource missing"), unrecognized => format!("Unhandled response status: {unrecognized}"), } } fn main() { for response_status in [204, 404, 418] { println!("{}", response_description(response_status)); } }

200..=299 is an inclusive range pattern: both endpoints are included. The named catch-all unrecognized accepts every status left over and binds it for the error message. A u16 implements Copy, so this binding copies the number.

Output:

Response accepted Resource missing Unhandled response status: 418

This function returns an owned String from every arm. The fixed messages use String::from, while format! constructs a string containing the fallback status.

Put broader patterns after narrower ones

Arm ordering becomes part of the behavior whenever patterns overlap. A specific status such as 204 must appear before 200..=299 if it needs special treatment. Put an unconditional catch-all last.

Placing unrecognized or _ first would accept every input, leaving later arms unreachable. Rust normally reports this with the unreachable_patterns warning. A match does not automatically prefer the more specific pattern.

An underscore prefix still creates a binding

_ and _unused have different meanings. The first ignores the value without binding, copying, moving, or borrowing it. The second creates a real binding whose name suppresses the unused-variable warning.

If a by-value _unused binding receives a String, it can move that string. Choose _ when you mean to ignore data. Choose a descriptive name when you need to use it.

Match enum variants and extract their data

Literals work well for external inputs such as status codes. Within an application, enums give cases names and attach the data each case needs.

A file reader might produce an empty result, a row count, or a rejection reason:

src/main.rs
enum FileOutcome { Empty, Loaded { rows: usize }, Rejected(String), } fn file_report(outcome: FileOutcome) -> String { match outcome { FileOutcome::Empty => String::from("No records to import"), FileOutcome::Loaded { rows } => format!("Read {rows} records"), FileOutcome::Rejected(reason) => format!("Import rejected: {reason}"), } } fn main() { let outcomes = [ FileOutcome::Empty, FileOutcome::Loaded { rows: 82 }, FileOutcome::Rejected(String::from("Required header is missing")), ]; for outcome in outcomes { println!("{}", file_report(outcome)); } }

Each pattern identifies a variant. Loaded { rows } also binds its named field, while Rejected(reason) binds the positional field inside the tuple variant. Those names are available only inside their arm.

Output:

No records to import Read 82 records Import rejected: Required header is missing

rows is shorthand for the field pattern rows: rows. You can rename the local binding with rows: record_count when that reads better. For a variant with several fields, .. can ignore the remaining fields; _ ignores one field or one entire matched value.

This function accepts ownership of outcome. The rejection arm takes ownership of its String through reason. Later, we will borrow an input when the caller should retain its data.

Exhaustiveness makes missing cases visible

A match is exhaustive. The three arms above cover every variant of FileOutcome, so no wildcard is needed.

If you add a fourth variant such as FileOutcome::Skipped, the compiler rejects this function until you handle it. The usual diagnostic is E0004: non-exhaustive patterns.

That check matters when a model changes. The compiler identifies decisions that still reflect the old set of states. Each maintainer can decide what the new case should mean at that point in the application.

A wildcard would also make this enum match exhaustive, but it would automatically accept future variants. List variants explicitly when each new case deserves a deliberate decision. Use a fallback when grouping all remaining cases is the intended behavior.

For integers or strings, handling a handful of literals leaves many possible values uncovered. A catch-all provides a clear policy for those remaining inputs.

Handle optional configuration with Some and None

An optional setting represents two situations: a value was supplied, or it was absent. Rust models that with Option<T>. The enums lesson introduces the type; here we will give its cases different application behavior.

Suppose an importer accepts an optional worker count. Missing configuration uses four workers, while an explicitly supplied zero is normalized to one:

src/main.rs
fn choose_worker_count(configured_workers: Option<usize>) -> usize { match configured_workers { Some(0) => 1, Some(configured) => configured, None => 4, } } fn main() { for configured_workers in [Some(0), Some(6), None] { println!("Workers: {}", choose_worker_count(configured_workers)); } }

Some(0) checks both the outer variant and its contained number. Some(configured) accepts any remaining present number and gives it a name. None handles absence without trying to read a number that is not there.

Output:

Workers: 1 Workers: 6 Workers: 4

The order of the two Some arms matters: the general binding would also match zero. Keeping the literal case first preserves the intended normalization.

None differs from Some(0), an empty string, or another placeholder value. Absence and an explicitly supplied value can therefore receive separate policies.

If your only requirement is a default for absence, configured_workers.unwrap_or(4) is shorter. The extra zero case makes match useful here. The standard library’s Option API  documents other transformations you can use when their behavior fits.

Use a guard for a condition that depends on another value

Patterns describe structure and supported literal values. A match guard adds a condition after a pattern, using if. The condition can inspect a binding and other values in scope.

Here, the permitted worker count is an input to the function rather than a constant:

src/main.rs
fn worker_policy(requested_workers: u8, worker_limit: u8) -> &'static str { match requested_workers { 0 => "Use automatic settings", requested if requested <= worker_limit => "Accept requested count", _ => "Reject request above limit", } } fn main() { for requested_workers in [0, 3, 12] { println!("{requested_workers}: {}", worker_policy(requested_workers, 8)); } }

The literal zero arm runs first. For another number, requested binds the input and the guard compares it with worker_limit. If the condition is false, Rust continues to the fallback.

Output:

0: Use automatic settings 3: Accept requested count 12: Reject request above limit

&'static str is the function’s return type because these messages are string literals. See string slices for how borrowed text works.

Guards do not establish coverage for exhaustiveness checking. Keep an unguarded pattern that covers cases a guard might reject. Even apparently complementary conditions such as requested <= worker_limit and requested > worker_limit do not replace that coverage.

Keep guards easy to read and avoid side effects in them. A guard can be evaluated while Rust searches for a selected arm; it is a condition, not the place to perform the arm’s work.

Borrow data when matching should leave it available

Pattern bindings follow the same ownership rules as other Rust code. Binding a non-Copy field by value can move it. When you only need to inspect an existing value, match a reference.

This program displays an optional profile name and then uses the original option again:

src/main.rs
fn display_profile(profile_name: &Option<String>) { match profile_name { Some(label) => println!("Profile: {label}"), None => println!("Profile: default"), } } fn main() { let profile_name = Some(String::from("nightly-import")); display_profile(&profile_name); println!("Still available: {profile_name:?}"); }

The function accepts &Option<String>, a shared reference. Rust’s pattern binding rules make label a &String here. Printing through that reference keeps the caller’s string in place.

Output:

Profile: nightly-import Still available: Some("nightly-import")

If you instead matched an owned Option<String> and bound its present value by value, the binding would move the string. Borrow for inspection; take ownership when the operation needs to consume or store the data.

Matching a mutable reference can bind mutable references to fields, letting the selected arm update them. That still follows the usual borrowing restrictions. You do not need to clone a value merely to choose which case it belongs to.

Read nested values with one pattern

An optional text setting can fail in two distinct ways: it may be absent, or it may contain text that cannot be parsed. Those situations can be represented together as Option<Result<usize, std::num::ParseIntError>>.

Option describes whether the setting exists. Result describes whether parsing succeeded, using Ok(number) and Err(problem). A nested pattern can inspect both layers at once:

src/main.rs
fn batch_size_message(raw_limit: Option<&str>) -> String { let parsed_limit = raw_limit.map(|text| text.parse::<usize>()); match parsed_limit { Some(Ok(0)) => String::from("Batch size must be greater than zero"), Some(Ok(batch_size)) => format!("Batch size: {batch_size}"), Some(Err(_)) => String::from("Batch size must be a whole number"), None => String::from("Using default batch size"), } } fn main() { for raw_limit in [Some("48"), Some("0"), Some("many"), None] { println!("{}", batch_size_message(raw_limit)); } }

map calls the closure only when the option contains text. The closure’s parse::<usize>() produces a Result, so the resulting option contains a parsing outcome.

The patterns then separate four cases: a successfully parsed zero, another valid number, a parsing error, and an absent setting. Zero parses successfully; rejecting it is an application rule.

Output:

Batch size: 48 Batch size must be greater than zero Batch size must be a whole number Using default batch size

Some(Err(_)) ignores the error’s details while still requiring the outer Some and inner Err variants. Bind the error to a name if the arm needs to log or explain its details.

Nested patterns also work with tuples, structs, and enums containing other enums. Use them while the cases remain easy to scan. Extract a helper function when deeply nested patterns make the decision hard to understand.

Choose between match, if, and if let

Choose the form that makes the decision visible:

DecisionUsually a good fitReason
Every variant needs behaviormatchExplicit cases and exhaustiveness checking
One boolean condition decides the branchif / elseDirectly expresses a yes-or-no test
Several unrelated conditions are checkedif / else ifDoes not force them into patterns for one input
Only one pattern needs attentionif letAvoids a match arm whose only job is to do nothing
An option only needs a fallback valueunwrap_or or unwrap_or_elseNames the defaulting operation directly

For example, writing an optional report can use if let when absence intentionally requires no action:

src/main.rs
fn main() { let report_path = Some("summary.txt"); if let Some(path) = report_path { println!("Write summary to {path}"); } }

The body runs only for Some. With None, execution simply continues after the if let. There is no separate fallback policy to express in this example.

Output:

Write summary to summary.txt

if let can have an else block, but it does not require you to name every variant. Use match when those cases deserve explicit treatment or when a future enum change should prompt a review. The control-flow tutorial covers conditional expressions in more detail.

Practical example: parse a small command-line interface

A CLI parser needs to consider both argument values and argument count. Slice patterns let a match inspect that structure directly. This example recognizes help, status, and inspect <path>, then represents the request with an enum:

src/main.rs
use std::process::ExitCode; enum CliRequest { ShowHelp, ShowStatus, Inspect(String), } fn parse_cli(arguments: &[String]) -> Result<CliRequest, String> { match arguments { [] => Ok(CliRequest::ShowHelp), [command] if command == "help" => Ok(CliRequest::ShowHelp), [command] if command == "status" => Ok(CliRequest::ShowStatus), [command, path] if command == "inspect" => { Ok(CliRequest::Inspect(path.clone())) }, [command, ..] => Err(format!("Unsupported command or arguments: {command}")), } } fn main() -> ExitCode { let arguments: Vec<String> = std::env::args().skip(1).collect(); match parse_cli(&arguments) { Ok(CliRequest::ShowHelp) => { println!("Usage: help | status | inspect <path>"); }, Ok(CliRequest::ShowStatus) => { println!("No inspections are queued"); }, Ok(CliRequest::Inspect(path)) => { println!("Inspection requested for {path}"); }, Err(problem) => { eprintln!("{problem}"); return ExitCode::from(2); }, } ExitCode::SUCCESS }

std::env::args().skip(1) skips the executable’s name. The collected vector is borrowed as a slice for the parser. [] accepts no arguments, [command] accepts exactly one, and [command, path] accepts exactly two.

The guards compare the borrowed command with a recognized name. [command, ..] accepts any remaining nonempty slice, regardless of how many trailing arguments it has. Together with the empty pattern, this unguarded fallback provides exhaustive coverage.

The parser borrows its arguments, while CliRequest::Inspect owns a path. Cloning that one string constructs the owned request. This choice lets the request outlive the borrow of the argument slice.

The second match handles the parsing result and the request variants together. Its error arm returns exit code 2 from main; successful arms finish before the function returns ExitCode::SUCCESS.

Run an inspection request with:

cargo run -- inspect records.csv

Application output:

Inspection requested for records.csv

Try cargo run -- status, cargo run -- help, and cargo run -- inspect. The last command reports an error because the path is missing. The example parses and reports requests; opening and inspecting the file would belong in the request handler.

Common mistakes with Rust match

Treating a binding as a comparison with an existing variable

In a pattern, a fresh name normally creates a binding. If an outer variable is named desired_workers, writing an arm with the same identifier does not compare against that variable: it introduces a new binding and accepts the input.

Use a guard such as observed if observed == desired_workers for a comparison with an ordinary variable. Literal patterns and named constants can express fixed values directly.

Leaving an input case uncovered

Handling only selected enum variants, or handling Some without None, leaves valid inputs without behavior. Rust reports E0004. Read the missing patterns in the diagnostic and decide how the application should handle them.

Do not automatically add _ => {} to silence the error. Ignoring a new failure or state should be an explicit application decision.

Returning incompatible values from arms

The arms need to resolve to a common result type. One arm producing a number and another producing a string usually results in E0308.

A trailing semicolon can cause the same problem. A block ending in 30; produces the unit value (), whereas a block ending in 30 produces the number. Check the final expression in each body. An arm that exits the function early can fit alongside value-producing arms because it never reaches the result of the match.

Making later arms unreachable

An unconditional binding or wildcard before a specific pattern consumes that case first. Overlapping ranges can hide later cases too. Read arms from top to bottom and make sure each one can still receive an input.

Moving a value that you still need

A by-value binding can take ownership of a contained String or another non-Copy value. Reusing the moved data can produce E0382. Match a shared reference when the operation only inspects it, as in the profile example.

Keep matches maintainable

  • List enum variants explicitly when each one has its own meaning. Let new variants prompt a review.
  • Use literal, range, and structural patterns for fixed cases; use guards for conditions involving other values.
  • Give bound data names that explain its role in the selected arm.
  • Keep broad fallbacks last and make their intended behavior clear.
  • Return a value directly when the decision computes one. Avoid a mutable result variable assigned separately in every arm.
  • Move long operations into functions so the match still reads as a list of cases.
  • Borrow inputs for inspection and take ownership deliberately when constructing an owned result.

Exercises

  1. Extend the file outcome. Add Skipped { path: String } to FileOutcome. Update file_report and call it with a skipped file. Before adding the arm, observe the compiler’s exhaustiveness error.
  2. Validate the batch size. Extend batch_size_message so values above 1,000 receive a separate message. Use a guard, preserve the zero case, and test valid text, invalid text, and absence.
  3. Add a CLI command. Support inspect-all <directory> with a new request variant and a parser arm. Verify that both a missing directory and extra arguments are rejected.
  4. Change ownership deliberately. Rewrite file_report to take &FileOutcome, then report the same outcome twice. Check which pattern bindings now refer to borrowed data.

Summary

match expresses a decision in terms of patterns and produces the selected arm’s value. Patterns can check literals, identify enum variants, and bind nested data. Arms are considered in order, guards can reject a patterned case, and execution runs one selected body.

Exhaustiveness gives every possible input a defined path. Use _ to ignore remaining values, a named catch-all to retain them, and explicit enum arms when changes should require attention. Choose if let for one case with intentionally unhandled alternatives, and keep ownership visible when binding fields.

For further practice, revisit Enums to design the cases your matches handle, or References and Borrowing to control how matched data is accessed.

Last updated on