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.