Skip to lesson

learningrust.org / basics / 03-control-flow · lesson 3 of 26

TL;DR

Master Rust control flow with if expressions, loops, and the match statement for conditional logic and pattern matching

Key concepts

  • Rust control flow
  • Rust match statement
  • Rust if expression
  • Rust loops
  • Rust while for loop

Control Flow in Rust

In this lesson, we'll explore how Rust handles program flow control through conditionals, loops, and pattern matching. These are essential tools for writing programs that can make decisions and repeat actions.

What You'll Learn

By the end of this lesson you will write three functions that each choose a different control-flow construct for a different reason: one that grades a temperature into bands with an if expression, one that totals a range with a for loop, and one that searches for an answer it cannot count out in advance with loop and break. The part that catches people is that two of these decisions are made by one character — 1..4 and 1..=4 differ by an equals sign and by exactly one term in the total, and both compile.

The capstone's taskwork is built out of these choices: it walks the tasks it read from disk, decides which ones to show by a condition, and stops early when it has found what it was asked for. You arrive able to bind values and convert between numeric types (Variables and Data Types); what is new is that control flow in Rust is made of expressions that produce values, so a branch or a loop can sit on the right-hand side of a let.

If Expressions

In Rust, if is an expression, which means it can return a value — let result = if condition { a } else { b }; binds whichever branch ran.

That has a consequence you can predict with, and it is the rule the rest of this lesson leans on: every branch of an if used as an expression must produce the same type. There is only one binding on the left, it has one type, and the compiler cannot know in advance which branch will run — so it insists that every path agrees. An if whose branches produce a number and a string is rejected before the program ever runs, and so is an if with no else, because the missing branch has nothing to produce. The same rule explains a subtler case you will meet below: putting a semicolon on a branch turns that branch's value into the unit value while the others still produce something, and the arms stop agreeing.

fn main() {
    let number = 7;

    // Used as a STATEMENT: each branch ends in a println!, which produces
    // nothing, so there is no value to agree on and no else is required.
    if number < 5 {
        println!("number is less than 5");
    } else if number > 5 {
        println!("number is greater than 5");
    } else {
        println!("number is 5");
    }

    // Used as an EXPRESSION: the else is mandatory here, not stylistic.
    // result has to be given some &str whatever condition turns out to be,
    // and both branch bodies are semicolon-free so that each yields one.
    let condition = true;
    let result = if condition {
        "condition was true"
    } else {
        "condition was false"
    };
    println!("Result: {}", result);
}

Loops

Rust provides several ways to repeat code:

Loop

The loop keyword creates an infinite loop that you can break out of:

fn main() {
    let mut counter = 0;

    // loop rather than while, because the stopping condition is not known
    // until we are inside — the break carries the answer out.
    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2; // Returns a value
        }
    };

    println!("The result is {}", result);
}

While Loop

For conditional looping:

fn main() {
    // while rather than loop, because the condition IS knowable up front:
    // it can be tested before each pass and needs no break at all.
    let mut number = 3;

    while number != 0 {
        println!("{}!", number);
        number -= 1;
    }

    println!("LIFTOFF!!!");
}

For Loop

For iterating over collections. The if fence above had a note on each of its two shapes; this one has none, on purpose — two choices in it are worth stating yourself before you read on. Why is the range written 1..=5 rather than 1..5, given that the output runs from 1 to 5? And why does the second loop say colors.iter() rather than just colors?

fn main() {
    for number in 1..=5 {
        println!("{}!", number);
    }

    let colors = ["red", "green", "blue"];
    for color in colors.iter() {
        println!("Color: {}", color);
    }
}

The answers. 1..=5 is written with the = because a range without it stops before its upper bound: 1..5 would print 1 through 4, and the trailing 5 in the output is exactly what the = buys. And .iter() asks the array for references to its elements rather than handing the array itself to the loop; the difference does not show here, because these are string slices, but it is the same borrow-versus-move question the ownership lessons are built on, and writing .iter() is the habit that keeps the collection usable afterwards.

Two details in those loop forms decide answers rather than style, and both are easy to skim past. A range written 1..4 stops before its upper bound while 1..=4 includes it, and break inside a loop can carry a value out the way return carries one out of a function. The program below leans on both. Work out all three numbers before you run it.

Predict

Two of these totals come from ranges that look almost identical, and the third comes from a value carried out by break. Predict all three numbers before running.

fn main() {
  let mut exclusive = 0;
  for n in 1..4 {
      exclusive += n;
  }

  let mut inclusive = 0;
  for n in 1..=4 {
      inclusive += n;
  }

  let mut counter = 0;
  let doubled = loop {
      counter += 1;
      if counter == 4 {
          break counter * 2;
      }
  };

  println!("{} {} {}", exclusive, inclusive, doubled);
}
Continue learning

The three numbers are 6, 10, and 8. 1..4 excludes its upper bound and yields 1, 2, 3 (total 6), while 1..=4 includes it and yields 1, 2, 3, 4 (total 10) — a one-character difference and the most common off-by-one in Rust. The third comes from break counter * 2: inside a loop, break can carry an expression out as the loop's value, which is what lets loop sit on the right-hand side of a let. That value-carrying break works for loop alone, because a while or for may finish without ever reaching a break and so has no value to hand back.

Pattern Matching

One of Rust's most powerful features is pattern matching with match:

fn main() {
    let number = 13;

    match number {
        // Match a single value
        1 => println!("One!"),
        // Match several values
        2 | 3 | 5 | 7 | 11 | 13 => println!("This is a prime number!"),
        // Match a range
        14..=19 => println!("A teen"),
        // Handle the rest of cases
        _ => println!("Not a special number"),
    }
}

If Let

A match has to be exhaustive, so when you care about exactly one pattern you end up writing an arm that does nothing just to satisfy the compiler. if let is the shorthand for that shape: it runs a block when one pattern matches and ignores everything else.

The value below is a Result, which is what .parse() hands back — Ok(n) when the text was a number and Err(e) when it was not. You met .parse() and .unwrap() in Variables and Data Types; here the point is only that a Result is a value carrying something inside it, and a pattern can look at both the shape and the contents at once.

fn main() {
    let parsed = "3".parse::<i32>();

    // Instead of a match with an arm that exists only to be exhaustive:
    match parsed {
        Ok(3) => println!("three"),
        _ => (),
    }

    // You can write:
    if let Ok(3) = parsed {
        println!("three");
    }
}

Both halves print three. The pattern Ok(3) matches only when the parse succeeded and the number inside is exactly 3, so one pattern tests two things at once. The trade is written into the syntax: match forces you to say what happens in every other case, and if let lets you decline to — which is the right choice when there genuinely is nothing to do, and the wrong one when you were supposed to handle the failure. Error Handling comes back to this and shows what a proper Err arm looks like.

Check Yourself

One question reaching back before you build anything. Answer from memory rather than by scrolling.

Recall

Without scrolling up: in What Is Rust you learned what dividing two whole numbers does to the fraction, and in Variables and Data Types you learned what mut permits. A loop here writes let mut total = 0; and then total += n; on each pass, and a later line computes total / 4 where both sides are whole numbers. Which statement gets both facts right?

Continue learning

When the Loop Runs One Time Too Few

Range bounds are the place where a program can be entirely reasonable and entirely wrong. The one below compiles without a warning, runs to completion, and reports a number nobody would look at twice.

Debug

This totals the hours logged across a seven-day stretch: two hours every day, plus one extra on odd-numbered days. Days 1 through 7 have four odd days among them, so the answer should be 18. It reports 15, without a warning and without a crash. Commit a hypothesis about which day never gets counted before you change anything, then fix it so it reports 18.

fn main() {
  let last_day = 7;

  let mut hours = 0;
  for day in 1..last_day {
      hours += day % 2 + 2;
  }

  println!("logged {} hours over {} days", hours, last_day);
}

Expected output: logged 18 hours over 7 days

Continue learning

Assembling a Calculation

Arrange the code

These five lines walk a counter up in steps until it passes a floor, then report where it landed. They have been shuffled. Put them in the order that runs and prints 'first multiple of 7 over 21 is 28' — then answer the question the order is really testing, which is not only about names. One of these four gaps is not enforced by the compiler at all: exactly two arrangements compile and run, and only one of them is right. Find the pair that swaps silently, and say what the program reports instead.

  1. let step = 7;
  2. println!("first multiple of {} over {} is {}", step, floor, candidate);
  3. while candidate <= floor { candidate += step; }
  4. let floor = step * 3;
  5. let mut candidate = floor;
Continue learning

Practice Exercises

Try these exercises in the playground:

  1. Create a program that uses a loop to find the first 5 numbers divisible by both 3 and 5
  2. Write a function that uses pattern matching to convert numbers 1-5 to their text representation
  3. Use a for loop to calculate the sum of all numbers from 1 to 100
  4. Create a nested if-else structure and then refactor it to use match instead

Remember: Rust's control flow features are expressions, meaning they can return values. This leads to more concise and expressive code!

Try It Yourself

Reading about control flow is not the same as reaching for the right construct under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one branches with an if expression, one accumulates over a for range, one searches with loop and break. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.

The checks are assert_eq! calls inside main. A failing assert_eq! panics and prints both values it compared, so the first failure tells you exactly which function is still a stub and what it should have returned. Watch the boundary cases in particular: grade(10) and grade(24) are both "mild", and sum_to(4) must include the 4 — that is the inclusive-range rule from the Predict above, cashed in.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide which control-flow construct that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// TODO 1: return 'cold' below 10, 'mild' from 10 through 24, and 'hot' at 25
//   and above. Write it as an if / else if / else EXPRESSION, with no
//   'return' keyword and no semicolon on the branch values.
//   grade(3) -> 'cold', grade(10) -> 'mild', grade(25) -> 'hot'
fn grade(celsius: i32) -> &'static str {
  let _ = celsius;
  ""
}

// TODO 2: sum every number from 1 through last INCLUSIVE, using a for loop.
//   sum_to(4) -> 10, sum_to(1) -> 1
fn sum_to(last: i32) -> i32 {
  let _ = last;
  0
}

// TODO 3: return the first multiple of step that is strictly greater than
//   floor, using loop and break <value>.
//   first_multiple_over(7, 20) -> 21, first_multiple_over(5, 5) -> 10
fn first_multiple_over(step: i32, floor: i32) -> i32 {
  let _ = step;
  let _ = floor;
  0
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  assert_eq!(grade(3), "cold", "3 degrees should grade as cold");
  assert_eq!(grade(10), "mild", "10 is the FIRST mild reading, not a cold one");
  assert_eq!(grade(24), "mild", "24 is the LAST mild reading");
  assert_eq!(grade(25), "hot", "25 is the first hot reading");

  assert_eq!(sum_to(4), 10, "sum_to(4) is 1+2+3+4 = 10 - the range must include 4");
  assert_eq!(sum_to(1), 1, "sum_to(1) is just 1");

  assert_eq!(first_multiple_over(7, 20), 21, "21 is the first multiple of 7 above 20");
  assert_eq!(first_multiple_over(5, 5), 10, "strictly greater than 5, so 5 itself does not count");

  println!("All checks passed.");
  println!("grade(24) = {}", grade(24));
  println!("sum_to(4) = {}", sum_to(4));
  println!("first_multiple_over(7, 20) = {}", first_multiple_over(7, 20));
}

Expected output: All checks passed. grade(24) = mild sum_to(4) = 10 first_multiple_over(7, 20) = 21

Continue learning

Once it passes, try two variations and predict each before running:

  1. Make the range exclusive. In sum_to, change 1..=last to 1..last. Predict which check fails before running. The sum_to(4) check fails with left: 6, right: 10 — dropping the = drops the final 4 from the range, so the total loses exactly that one term. sum_to(1) fails too, returning 0 from a range that yields nothing at all. One character, two broken checks.
  2. Put a semicolon on a branch of grade. Change the first branch to "cold"; (with a semicolon). Predict what the compiler says before running. It does not compile: error[E0308]: mismatched types, because the semicolon turns that branch into a statement whose value is () while the other branches still produce &str — and every arm of an if expression must produce the same type. The compiler even points at the semicolon and offers to remove it.

Two habits for reading any rustc message, and this lesson gives you two to practise on. The error[E0308] at the front is a stable identifier — it means "mismatched types" in every Rust program ever compiled, rustc --explain E0308 prints the general write-up, and unlike the prose wording it does not drift between compiler versions, so it is the part worth searching for. And the message is laid out in labelled parts, each with a job. This one has three. --> gives where, as file, line and column. The carets and the vertical bars mark the exact span objected to — and notice how the span is drawn here: it opens at the if and closes at the } else, wrapping the whole branch rather than pointing at one character, because it is the branch's value that is wrong, not any single token in it. The shape of the span is itself information. Then help: remove this semicolon to return this value names the fix, attached by a short line to the exact semicolon. Treat a help: as a suggestion rather than an instruction — the change that silences an error is not always the change that fixes your program, though here it happens to be right, because the semicolon really was the mistake. Not every message carries every part: this one has no note: line at all, and a diagnostic with fewer parts is usually one the compiler is more certain about.

Carrying Exhaustiveness Somewhere New

Transfer

Rust's match must be exhaustive: every possible value of the matched type has to be covered by some arm, or the program does not compile. C and JavaScript have a switch statement that looks similar but behaves differently in two ways — a case falls through into the next unless you write break, and omitting a case is never an error. Which statement identifies what actually transfers between the two?

Continue learning

Key Takeaways

  • if in Rust is an expression — it can return a value, enabling let x = if condition { a } else { b }
  • Rust has three loop types: loop (infinite), while (conditional), and for (iterator-based)
  • for loops with ranges (0..10) and iterators (.iter()) are the most idiomatic way to iterate
  • match is exhaustive — the compiler ensures you handle every possible case
  • if let is syntactic sugar for matching a single pattern when you don't need full match

Next Steps

With control flow under your belt, you're ready to learn about functions — how to define them, pass arguments, and return values in Rust.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.