Control Flow and Pattern Matching
if as an expression, loop, while and for, and the match expression that the compiler checks for every possible case — with patterns, guards, if let, let-else and let chains.
Expressions and statements
Rust is an
expression-oriented language: almost everything that does something also produces a value. An
if with an else has a value, and so does a
block in braces: the value of its last expression, written without a
semicolon. Statements, such as a let or an expression
followed by ;, produce nothing. The general idea of
selection
and
iteration
is the same as in other languages; this page is about how Rust
expresses it.
fn main() {
let hour = 23;
let night = hour >= 23 || hour < 7;
// Kestrel's day/night tariff: 3120 by day, 2240 at night (ten-thousandths of a euro per kWh)
let rate = if night { 2_240 } else { 3_120 };
let label = {
let kind = if night { "night" } else { "day" };
format!("{kind} rate") // no semicolon: this is the block's value
};
println!("{hour}:00 -> {label}: {rate}");
}
23:00 -> night rate: 2240
This replaces C's and Java's ternary operator
night ? 2240 : 3120, which Rust does not have, and
Python's
conditional expression. Both branches must have the same type: an if that
gives a number in one branch and text in the other does not compile.
The semicolon matters. A function returns the value of its last
expression, so a stray ; at the end turns the value
into a statement, and the function returns nothing. The compiler
catches it:
fn cost_cents(kwh: u64) -> u64 {
(kwh * 2_700 + 50) / 100;
}
fn main() {
println!("{}", cost_cents(350));
}
example.rs:1:28: error[E0308]: mismatched types: expected `u64`, found `()`
error[E0308]: mismatched types
--> example.rs:1:28
|
1 | fn cost_cents(kwh: u64) -> u64 {
| ---------- ^^^ expected `u64`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
2 | (kwh * 2_700 + 50) / 100;
| - help: remove this semicolon to return this value
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
(), pronounced "unit", is the type of "no value", the
result of a statement. The fix is in the last line of the message:
remove the semicolon. More on functions and return values is on
Functions, Modules and Crates.
if and else
if, else if and else work as
in most languages, with two differences: the condition needs no
parentheses (the compiler even warns about them), and the braces are
always required, even for a single line. That rules out C's
dangling else
and the classic "indented but not inside the if" bug.
The condition must be a bool. A number is not true or
false by itself:
fn main() {
let missing_readings = 3;
if missing_readings {
println!("{missing_readings} readings missing");
}
}
example.rs:3:8: error[E0308]: mismatched types: expected `bool`, found integer
Write the comparison you mean:
if missing_readings > 0. The same rule catches the C
classic of writing = where == was meant,
because an assignment has the type (), not
bool:
fn main() {
let tariff_code = 2;
if tariff_code = 3 {
println!("dynamic");
}
}
example.rs:3:8: error[E0308]: mismatched types: expected `bool`, found `()`
error[E0308]: mismatched types
--> example.rs:3:8
|
3 | if tariff_code = 3 {
| ^^^^^^^^^^^^^^^ expected `bool`, found `()`
|
help: you might have meant to compare for equality
|
3 | if tariff_code == 3 {
| +
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
Loops
Rust has three loops. loop repeats until a
break, and that break can hand a value
back, which makes loop an expression too. A reconnect
with a growing delay is a typical use:
// In this example the broker answers on the fourth attempt.
fn broker_answers(attempt: u32) -> bool {
attempt == 4
}
fn main() {
let mut attempt = 0;
let mut delay = 5; // seconds; doubles after each failure
let mut waited = 0;
let total = loop {
attempt += 1;
if broker_answers(attempt) {
break waited; // the loop's value
}
waited += delay;
delay *= 2;
};
println!("connected on attempt {attempt} after {total} s");
}
connected on attempt 4 after 35 s
while repeats as long as a condition holds, and
for runs once for every element of something you can
iterate over: a range, an array, a list. A range
0..3 stops before its end (0, 1, 2);
22..=23 includes it. .rev() walks
backward:
fn main() {
let mut battery = 100; // percent
let mut hours = 0;
while battery > 20 {
battery -= 15;
hours += 1;
}
println!("{hours} hours until below 20% ({battery}% left)");
for hour in 0..3 { // 0, 1, 2: the end is excluded
println!("hour {hour}");
}
for hour in (22..=23).rev() { // 23, 22: ..= includes the end
println!("hour {hour}");
}
let week = [11, 9, 12, 10, 13, 15, 14];
let mut total = 0;
for kwh in week { // every element, no index needed
total += kwh;
}
println!("week: {total} kWh");
}
6 hours until below 20% (10% left) hour 0 hour 1 hour 2 hour 23 hour 22 week: 84 kWh
There is no C-style for (i = 0; i < n; i++). Loop
over the elements directly, as above; when you need the position
too, for (i, kwh) in week.iter().enumerate() gives
both. That leaves no index to get wrong, which removes a whole
family of
off-by-one errors. How for works underneath, with iterators, is on
Iterators and Closures.
Breaking out of nested loops
break and continue act on the innermost
loop. To leave an outer loop, give it a label, a name that starts
with an apostrophe, and name it in the break:
fn main() {
// Hourly readings of three homes; find the first hour above 3 kWh
let homes = ["h0007", "h0017", "h0042"];
let readings = [[1, 2, 2, 1], [2, 4, 3, 1], [5, 1, 1, 1]];
let mut found = None;
'homes: for (h, hours) in readings.iter().enumerate() {
for (hour, &kwh) in hours.iter().enumerate() {
if kwh > 3 {
found = Some((homes[h], hour));
break 'homes; // leaves both loops
}
}
}
println!("{found:?}");
}
Some(("h0017", 1))
Labels are the structured replacement for C's goto out
of nested loops. Often a small function with an early
return reads even better.
match
A
match expression
compares a value against a list of patterns, top to bottom, and runs
the arm of the first pattern that fits. Like if, it is
an expression: all arms produce a value of the same type. A
pattern describes the shape a
value must have: a literal (0), a range
(1..=99), alternatives joined with |, the
wildcard _ that matches anything, or a structure to
take apart, as later sections show.
fn describe(watts: u32) -> &'static str {
match watts {
0 => "nothing",
1..=99 => "standby",
100..=2_999 => "normal use",
3_000..=9_999 => "heavy use",
_ => "check the meter",
}
}
fn main() {
for watts in [0, 45, 1_234, 3_000, 12_000] {
println!("{watts:>6} W: {}", describe(watts));
}
}
0 W: nothing
45 W: standby
1234 W: normal use
3000 W: heavy use
12000 W: check the meter
Each arm is pattern => expression, separated by
commas. There is no fall-through from one arm to the next, so there
is no break to forget, unlike C's
switch.
&'static str is the type of a fixed piece of text
that lives as long as the program; it is explained on
Lifetimes.
Every case must be covered
A match must handle every value its input can have. The
compiler proves this, a check called
exhaustiveness checking, and refuses the program when a case is missing. The value of that
shows with an enum, a type with a fixed list of variants
(see
Structs, Enums and Methods). Kestrel's contracts have four tariff types: fixed, day_night,
dynamic and variable. A function that forgets one does not compile:
enum Tariff {
Fixed,
DayNight,
Dynamic,
Variable,
}
fn label(tariff: Tariff) -> &'static str {
match tariff {
Tariff::Fixed => "fixed",
Tariff::DayNight => "day_night",
Tariff::Dynamic => "dynamic",
}
}
fn main() {
println!("{}", label(Tariff::Variable));
}
example.rs:9:11: error[E0004]: non-exhaustive patterns: `Tariff::Variable` not covered: pattern `Tariff::Variable` not covered
error[E0004]: non-exhaustive patterns: `Tariff::Variable` not covered
--> example.rs:9:11
|
9 | match tariff {
| ^^^^^^ pattern `Tariff::Variable` not covered
|
note: `Tariff` defined here
--> example.rs:1:6
|
1 | enum Tariff {
| ^^^^^^
...
5 | Variable,
| -------- not covered
= note: the matched value is of type `Tariff`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
12 ~ Tariff::Dynamic => "dynamic",
13 ~ Tariff::Variable => todo!(),
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0004`.
The message names the missing variant, shows where it was defined,
and proposes the arm to add (todo!() is a placeholder
that panics if it is ever reached). This is the check that makes
enums safe to change: when a fifth tariff type is added to the enum,
every match that does not handle it stops compiling,
and the compiler lists them all. In C, a switch without
the new case still compiles; GCC's -Wall warns about
it, but only when the switch has no
default (see
switching on an enum
in C).
The same check applies to numbers. A u32 has more
values than the hours of a day:
fn is_night(hour: u32) -> bool {
match hour {
23 | 0..=6 => true,
7..=22 => false,
}
}
fn main() {
println!("{}", is_night(3));
}
example.rs:2:11: error[E0004]: non-exhaustive patterns: `24_u32..=u32::MAX` not covered: pattern `24_u32..=u32::MAX` not covered
The compiler even works out the exact missing range. Add an arm for
it, such as _ => panic!("no such hour: {hour}"), or
better, validate the hour once when it enters the program and keep
it in a type that cannot hold 24.
Destructuring and guards
Patterns can take values apart. A tuple pattern matches several
values at once, which turns a table of rules into code. A
match guard, an
if condition after a pattern, adds a test the pattern
alone cannot express; the arm only matches when the guard is true.
The night hours follow Kestrel's rule (night before 07:00 and from
23:00); the prices are those of Kestrel's tariff table, except the
cap on the dynamic price and the market prices, which are example
values:
#[derive(Debug)]
enum Tariff {
Fixed,
DayNight,
Dynamic,
Variable,
}
// Price in ten-thousandths of a euro per kWh, from Kestrel's tariff table
// (fixed €0.2700, day €0.3120, night €0.2240, variable €0.2600);
// the cap and the market prices are examples.
fn rate(tariff: &Tariff, hour: u32, market: u32) -> u32 {
match (tariff, hour) {
(Tariff::Fixed, _) => 2_700,
(Tariff::DayNight, 23 | 0..=6) => 2_240,
(Tariff::DayNight, _) => 3_120,
(Tariff::Dynamic, _) if market > 5_000 => 5_000, // a guard: capped
(Tariff::Dynamic, _) => market,
(Tariff::Variable, _) => 2_600,
}
}
fn main() {
let cases = [
(Tariff::Fixed, 14, 0),
(Tariff::DayNight, 23, 0),
(Tariff::DayNight, 14, 0),
(Tariff::Dynamic, 18, 6_120),
(Tariff::Dynamic, 3, 1_850),
(Tariff::Variable, 10, 0),
];
for (tariff, hour, market) in &cases {
let name = format!("{tariff:?}");
println!("{name:<9} {hour:>2}:00 {}", rate(tariff, *hour, *market));
}
}
Fixed 14:00 2700 DayNight 23:00 2240 DayNight 14:00 3120 Dynamic 18:00 5000 Dynamic 3:00 1850 Variable 10:00 2600
Order matters: the first matching arm wins, so the specific
(Tariff::DayNight, 23 | 0..=6) comes before the general
(Tariff::DayNight, _). The compiler warns about an arm
that can never be reached because an earlier one catches everything
it would. A guard does not count for exhaustiveness: the compiler
cannot know when market > 5_000 is true, so the
unguarded Dynamic arm is still needed. (#[derive(Debug)]
gives the enum a Debug form for {:?}; the
* in *hour reads a value through a
reference, see
Borrowing and References.)
Struct patterns name fields, and name @ pattern binds a
value to a name while also testing it:
struct Reading {
meter: &'static str,
watts: u32,
}
fn main() {
let readings = [
Reading { meter: "M-30004", watts: 0 },
Reading { meter: "M-30007", watts: 45 },
Reading { meter: "M-30011", watts: 1_234 },
];
for reading in &readings {
match reading {
Reading { meter, watts: 0 } => println!("{meter}: no power"),
Reading { meter, watts: w @ 1..=99 } => println!("{meter}: standby ({w} W)"),
Reading { meter, watts } => println!("{meter}: {watts} W"),
}
}
}
M-30004: no power M-30007: standby (45 W) M-30011: 1234 W
Reading { meter, watts: 0 } matches only readings with
zero watts and binds the field meter to a variable of
the same name. The last arm binds both fields and matches every
remaining reading, which makes the match exhaustive
without a wildcard.
if let and while let
Often only one pattern is interesting. An
if let runs a block when a value
matches one pattern and binds its parts, and skips it otherwise. It
is the usual way to work with an Option, Rust's
replacement for null: Some(value) when there is a
value, None when there is not.
while let repeats as long as the pattern matches:
fn main() {
let latest: Option<u32> = Some(1_234); // watts, if the meter sent a value
if let Some(watts) = latest {
println!("now using {watts} W");
}
// the same with match needs an arm for None
match latest {
Some(watts) => println!("now using {watts} W"),
None => {}
}
// while let: keep going as long as the pattern matches
let mut queue = vec!["h0007", "h0017", "h0042"];
while let Some(home) = queue.pop() {
println!("sending to {home}");
}
}
now using 1234 W now using 1234 W sending to h0042 sending to h0017 sending to h0007
queue.pop() returns the last element as
Some(…), or None once the list is empty,
which ends the loop. An if let may have an
else for the case where the pattern does not match.
if let gives up exhaustiveness on purpose: use it when
the other cases need no action, and match when they do.
Let chains
A pattern and a condition often belong together: "if there is a
reading and it is above the alert level". Since Rust 1.88 (June
2025), edition 2024 allows chaining them with
&& in one if:
fn main() {
let latest: Option<u32> = Some(4_120); // watts
let alert_from = 3_500;
// a let chain: a pattern and a condition in one if (edition 2024)
if let Some(watts) = latest && watts > alert_from {
println!("high use: {watts} W");
}
let tariff = "day_night";
let hour = 23;
println!("{}", matches!(tariff, "day_night" | "dynamic"));
println!("{}", matches!(hour, 23 | 0..=6));
}
high use: 4120 W true true
The matches! macro answers a yes-or-no question with a
pattern, without binding anything. Let chains only work in edition
2024; the same program compiled as edition 2021 is refused:
fn main() {
let latest: Option<u32> = Some(4_120); // watts
let alert_from = 3_500;
// a let chain: a pattern and a condition in one if (edition 2024)
if let Some(watts) = latest && watts > alert_from {
println!("high use: {watts} W");
}
}
example.rs:6:8: error: let chains are only allowed in Rust 2024 or later
let-else
A plain let only accepts patterns that always match,
such as a tuple. A pattern that can fail is refused, and the
compiler suggests the alternative:
fn main() {
let latest: Option<u32> = Some(1_234);
let Some(watts) = latest;
println!("{watts} W");
}
example.rs:3:9: error[E0005]: refutable pattern in local binding: pattern `None` not covered
help: you might want to use `let...else` to handle the variant that isn't matched
|
3 | let Some(watts) = latest else { todo!() };
| ++++++++++++++++
A let-else statement (stable since
Rust 1.65, November 2022) binds the pattern if it matches; if not,
it runs the else block, which must leave: with
return, break, continue or a
panic. After the statement the variables are simply there, without
an extra level of indentation. That makes it the natural tool for
checking input step by step, such as a line of a readings file in
the form meter;kwh;tariff:
// One line of a readings file: "meter;kwh;tariff"
fn kwh_of(line: &str) -> Option<u32> {
let Some((meter, rest)) = line.split_once(';') else {
println!("{line:?}: no separator");
return None;
};
let Some((kwh, _tariff)) = rest.split_once(';') else {
println!("{meter}: no tariff field");
return None;
};
let Ok(kwh) = kwh.parse::<u32>() else {
println!("{meter}: {kwh:?} is not a whole number");
return None;
};
Some(kwh)
}
fn main() {
let lines = ["M-30004;350;fixed", "M-30007;41O;dynamic", "M-30011;120", "garbage"];
let mut total = 0;
for line in lines {
if let Some(kwh) = kwh_of(line) {
total += kwh;
}
}
println!("total of valid lines: {total} kWh");
}
M-30007: "41O" is not a whole number M-30011: no tariff field "garbage": no separator total of valid lines: 350 kWh
split_once(';') returns
Some((before, after)) or None;
parse returns Ok or Err. Each
let … else handles one way the line can be wrong and
leaves the function, so the happy path reads straight down.
(Printing from a parser is only for the demonstration; real code
returns an error that says what was wrong, see
Error Handling.) An
else block that does not leave is a compile error:
fn main() {
let latest: Option<u32> = None;
let Some(watts) = latest else {
println!("no reading");
};
println!("{watts} W");
}
example.rs:3:35: error[E0308]: `else` clause of `let...else` does not diverge: expected `!`, found `()`
! is the "never" type of code that does not continue,
such as return or panic!. Without it,
watts would have no value on the next line.
What the compiler checks
Most control-flow bugs that C compiles and Python runs are compile errors in Rust:
| Mistake | C | Python | Rust |
|---|---|---|---|
if (x = 3) instead of == |
Compiles (a warning with -Wall) |
Syntax error | E0308: expected bool |
| A number or list as a condition | Allowed | Allowed (truthiness) | E0308: expected bool |
A forgotten break in a switch |
Falls through | No fall-through | No fall-through |
| A case missing from a switch or match | Compiles (warning only for enums without default) | Runs; nothing matches | E0004: not covered |
| A loop index one past the end | Undefined behavior | IndexError while running |
Panic while running; rare with for over
elements
|
| Using a value that may be missing | Null pointer dereference | AttributeError or TypeError while running | Compile error until None is handled |
None of this replaces thinking about the rules themselves: a
match that covers every tariff type can still apply the
wrong price. That is what tests are for; see
Testing, Docs and Tooling
and, for boundaries such as 06:59 and 07:00,
Test Design Techniques.