if and match

How to use if and match as conditional expressions for branching, pattern matching, and decision-making in Rust

Rust offers two primary ways to branch on conditions: if and match. Both are expressions, meaning they evaluate to a value—not just execute statements. This chapter explains how to use if for boolean checks, match for exhaustive pattern matching, and the concise if let syntax for single‑pattern cases.

if Expressions

The if statement in Rust does what you expect: it runs a block of code when a condition is true. Rust’s if can also produce a value, which makes it more versatile than the if in many languages.

Basic if and else

The simplest form checks a boolean condition and executes the corresponding block.

let temperature = 30;
if temperature > 25 {
    println!("It's hot outside.");
} else {
    println!("It's not hot.");
}

Rust evaluates the condition temperature > 25, which returns a bool. If that is true, the first block runs; otherwise, the else block runs. No parentheses around the condition are required, though adding them is harmless—the compiler strips them away.

Boolean Conditions Only:

Rust’s if condition must evaluate to a bool. Using an integer, a string, or any other type that is not bool causes a compile error. Unlike C or JavaScript, there is no implicit truthiness—Rust requires an explicit boolean expression.

Using if as an expression

Because if is an expression, you can place it on the right‑hand side of a let statement to assign a value conditionally.

let status = if temperature > 25 { "hot" } else { "cold" };
println!("The weather is {}.", status);

Both blocks end with an expression without a semicolon—that expression becomes the block’s value. The branches must return the same type. Here, each branch returns a &str, so the variable status is inferred as &str. If you accidentally mixed types, the compiler would refuse with an error about incompatible branch types.

No Ternary Operator:

Rust does not have the ? : ternary operator found in C, Java, or Python. Instead, if itself serves as a conditional expression that can be used inline, just like a ternary.

else if chains

You can test multiple conditions in sequence by combining else and if.

let value = -3;
if value > 0 {
    println!("positive");
} else if value < 0 {
    println!("negative");
} else {
    println!("zero");
}

Only the first block whose condition is true executes; later else if branches are skipped entirely.

Condition must be a bool

Every condition in an if or else if must be a plain bool. You cannot write if x { … } unless x is already a bool. That design eliminates an entire class of errors where a non‑boolean value is accidentally treated as true or false.

let number = 5;
// Compile error: mismatched types, expected `bool`, found integer
// if number {
//     println!("will not compile");
// }

The error message is clear: Rust expects a bool. The fix is an explicit comparison like number != 0.

match Control Flow

When you need to compare a value against many possibilities and you want the compiler to verify you’ve covered every one of them, match is the tool to reach for. It is more powerful than a chain of if/else if statements, and it is one of the most heavily used features in Rust.

Matching literal values

A match expression checks a value against a series of patterns, executing the first arm whose pattern matches.

let number = 3;
match number {
    1 => println!("one"),
    2 => println!("two"),
    3 => println!("three"),
    _ => println!("many"),
}

The value number is compared top to bottom. The first arm, 1 => …, matches only when the value is 1. The underscore _ is a wildcard that catches anything not matched above, similar to a default case. Without _, this example would not compile because Rust requires match to be exhaustive—every possible value must be handled.

Exhaustiveness and the wildcard pattern

If a match does not cover all possible inputs, the compiler refuses to build the program and gives a precise error.

let number = 3;
match number {
    1 => println!("one"),
    2 => println!("two"),
    // missing 3 and all other numbers
}

The error would look like:

error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `3_i32..=i32::MAX` not covered

Exhaustiveness is a deliberate safety net. It prevents you from forgetting to handle a case when, for example, a new enum variant is added later. You can satisfy exhaustiveness by adding a wildcard _ arm, or by writing explicit arms for every concrete possibility.

Multiple patterns and ranges

A single arm can match several patterns using the | operator.

let vowel = 'e';
match vowel {
    'a' | 'e' | 'i' | 'o' | 'u' => println!("vowel"),
    _ => println!("consonant"),
}

Ranges work with ..= for inclusive ranges and .. for exclusive ends.

let score = 85;
match score {
    90..=100 => println!("A"),
    80..=89  => println!("B"),
    70..=79  => println!("C"),
    _        => println!("below C"),
}

When matching ranges, be careful: overlapping ranges cause the first matching arm to be taken, so the order matters.

Destructuring with match

Patterns can unpack tuples, structs, and enums in the same match that selects a branch.

let coord = (0, 5);
match coord {
    (0, y) => println!("On the y-axis at {}", y),
    (x, 0) => println!("On the x-axis at {}", x),
    (x, y) => println!("Point at ({}, {})", x, y),
}

With structs you can extract fields by name.

struct Point { x: i32, y: i32 }
let p = Point { x: 0, y: 7 };
match p {
    Point { x, y: 0 } => println!("On the x-axis at {}", x),
    Point { x: 0, y } => println!("On the y-axis at {}", y),
    Point { x, y }     => println!("({}, {})", x, y),
}

Enums become even cleaner. Each variant can be matched directly, and data inside variants can be destructured.

enum Direction { North, South, East, West }
let dir = Direction::North;
match dir {
    Direction::North => println!("Going up"),
    Direction::South => println!("Going down"),
    Direction::East  => println!("Going right"),
    Direction::West  => println!("Going left"),
}

Match guards

A match guard adds an extra if condition after a pattern, letting you refine when an arm fires.

let num = 7;
match num {
    n if n % 2 == 0 => println!("{} is even", n),
    _               => println!("{} is odd", num),
}

The guard if n % 2 == 0 binds n to the matched value and only fires when the condition is true. Guards are evaluated at runtime, so the compiler still requires a catch‑all arm (or exhaustive concrete patterns) because it cannot statically prove the guard covers all cases.

Using match as an expression

A match can produce a value, just like if. Every arm must evaluate to the same type (or diverge with return, break, continue, or panic!).

let description = match number {
    1 => "one",
    2 => "two",
    3 => "three",
    _ => "many",
};
println!("{}", description);

Here each arm yields a &str, so description is a string slice.

Returning from a Function Prematurely:

Inside a match arm, a return statement exits the entire enclosing function—not just the match. If you intend to assign the arm’s value to a variable, omit return and let the arm evaluate to the value directly. Writing return Some(...) inside a match arm will return from the surrounding function immediately.

Type Safety Across Arms:

When the compiler accepts a match expression, you can be confident that all arms produce the same type. This unification catches errors where, for example, one arm returns a string and another returns an integer—the compiler will stop you before the program ever runs.

Binding with @ and ignoring values

The @ operator lets you bind a matched value to a variable while also applying a pattern test.

match 5 {
    n @ 1..=10 => println!("got {n} in range 1–10"),
    _          => println!("out of range"),
}

n holds the actual value 5 while the range pattern checks that it falls inside 1..=10.

Use _ to ignore a single field and .. to ignore all remaining fields in a struct or tuple.

struct Config { debug: bool, max_conn: u32, log_level: u32 }
let cfg = Config { debug: true, max_conn: 10, log_level: 2 };
match cfg {
    Config { debug: true, .. }                => println!("debug mode on"),
    Config { debug: false, log_level, .. }    => println!("release, log level {log_level}"),
}

Matching references

When you iterate over a collection by reference, the pattern must account for the reference.

let values = vec![1, 2, 3, 4];
for val in &values {
    match val {
        1 => println!("one"),
        &n if n > 3 => println!("greater than 3: {}", n),
        n => println!("other: {}", n),
    }
}

The loop yields &i32, so the pattern 1 matches a reference that points to 1. The second arm uses &n to destructure the reference and bind the integer. The third arm n catches the remaining references and prints the integer by auto‑dereferencing.

if let Syntax

Sometimes you only care about one pattern and want to ignore everything else. if let offers a shorter syntax for that exact scenario.

Simplifying single‑pattern matches

Compare a full match with if let:

let optional = Some(5);
match optional {
    Some(value) => println!("value is {}", value),
    _           => (),
}
// The same logic, written with if let:
if let Some(value) = optional {
    println!("value is {}", value);
}

if let is syntactic sugar for a match with a single arm and a wildcard that does nothing. It reads more naturally when you only want to act on one particular case.

if let with else

An else branch runs when the pattern does not match.

if let Some(v) = optional {
    println!("got {}", v);
} else {
    println!("nothing");
}

while let loops

The while let construct keeps looping as long as a pattern matches, which is especially useful for draining collections.

let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
    println!("popped {}", top);
}

Each iteration pops the top element; the loop stops when pop() returns None.

Summary

if and match are the two fundamental branching tools in Rust. if is for straightforward boolean decisions and can act as an expression, removing the need for a separate ternary operator. match goes much further—it forces you to consider every possible input, and its pattern‑matching capabilities let you destructure complex data right at the decision point. if let gives you a concise escape hatch when you need only one pattern.

The same pattern‑matching engine that powers match appears throughout the language: in function arguments, in let destructuring, and in closure parameters.