How to Use Labeled Loops and break with Values in Rust

Use loop labels to break from nested loops and attach values to break statements to return results from the loop block.

When nested loops get out of hand

You are parsing a configuration file. The outer loop walks through lines. The inner loop splits each line into tokens. You hit a malformed token on line four. You need to stop everything right now and report the error. Breaking the inner loop just drops you back to the outer loop, which keeps chugging along to line five. You need a way to jump out of the nesting entirely and hand the error back to the caller.

Rust gives you two tools for this. Labels let you name a loop so you can target it from deep inside. break with a value lets the loop itself produce a result. Combined, they turn messy flag variables and early returns into clean, direct control flow.

Loops are expressions

In Python or JavaScript, a loop is a statement. It runs some code and then finishes. It does not produce a value. Rust treats loops as expressions. A loop evaluates to a result. That result comes from a break statement.

This changes how you write code. Instead of declaring a mutable variable before the loop and assigning to it inside, you assign the loop directly to a variable. The value flows out of the loop. The compiler enforces that every exit path provides a value of the correct type.

Labels add a layer of targeting. A label is a name attached to a loop block. break without a label exits the innermost loop. break 'label exits the specific loop marked with that label. The label acts as a named exit ramp. You can jump out of multiple levels of nesting in one step.

Minimal example

/// Demonstrates labeled loops and break values.
fn main() {
    // Label the outer loop so we can target it from inside.
    // The label name follows snake_case convention.
    'counting: loop {
        println!("Outer loop iteration");

        // Inner loop runs until it decides to break out.
        loop {
            println!("Inner loop iteration");

            // Break targets the labeled outer loop.
            // Execution jumps to the end of 'counting.
            break 'counting;
        }

        // This code never runs.
        // The inner loop breaks out of the outer loop entirely.
        println!("Unreachable code");
    }

    // Loops are expressions.
    // The value of break becomes the value of the loop block.
    let result = loop {
        println!("Computing result...");
        // Break returns a value.
        // The loop evaluates to that value.
        break 42;
    };

    println!("Result: {}", result);
}

The loop labeled 'counting contains an inner loop. The inner loop calls break 'counting. This jumps execution to the point immediately after the 'counting block. The println!("Unreachable code") is skipped. The compiler knows this code is unreachable and may warn you.

The second loop has no label. It breaks with the value 42. The loop expression evaluates to 42. That value is assigned to result. If you omitted the value in break, the loop would evaluate to (), the unit type. You cannot assign () to a variable expecting an integer.

Convention aside: Label names should describe the loop's purpose, not just its position. Use 'parse_config or 'find_target instead of generic 'outer. The name signals intent to the reader. A descriptive label makes the break point obvious.

How the compiler handles labels

Labels are scoped to the loop they annotate. The label exists only within the loop block. You cannot break to a label from outside the loop. You cannot use a label defined in an inner loop from an outer loop. The scope is strict.

When you use break 'label, the compiler checks that the label exists and is in scope. If you misspell the label, you get a compilation error. The compiler also checks the value type. If the loop is assigned to a variable, the break value must match the variable's type. If you have multiple break points in the same loop, they must all return values of the same type.

The compiler rejects mixed types with E0308 (mismatched types). If one break returns an integer and another returns a string, the loop has no single type. The code fails to compile. This rule ensures type safety. The loop's result is predictable.

Labels also work with continue. continue 'label jumps to the next iteration of the labeled loop. This skips the rest of the current iteration and any code between the continue and the loop condition. For a loop, this jumps to the start of the loop body. For while or for, it jumps to the condition check or the next iterator item.

Use continue 'label when you want to skip the remainder of an inner loop and jump directly to the next iteration of an outer loop. This is useful when you detect a condition that requires restarting the outer loop's logic without finishing the inner loop's current work.

Realistic example: Matrix search

Search algorithms often use nested loops. You need to find a value in a two-dimensional array. When you find it, you want to return the coordinates. If you finish searching without finding it, you return nothing.

A labeled break makes this clean. The loop evaluates to an Option containing the coordinates. The break provides the Some variant. The end of the loop provides None.

/// Searches a matrix for a target value and returns coordinates.
/// Returns Some((row, col)) if found, None otherwise.
fn find_in_matrix(matrix: &[&[i32]], target: i32) -> Option<(usize, usize)> {
    // Label the row loop to break out of both loops at once.
    // The label describes the search scope.
    'row_search: for (row_idx, row) in matrix.iter().enumerate() {
        for (col_idx, &val) in row.iter().enumerate() {
            if val == target {
                // Found the target.
                // Break the outer loop and return the coordinates.
                // The break value becomes the result of the loop expression.
                break 'row_search Some((row_idx, col_idx));
            }
        }
    }
    // If the loops finish without breaking, the loop expression evaluates to this.
    // The type must match the break value type.
    None
}

fn main() {
    let data = [
        [1, 2, 3],
        [4, 5, 6],
        [7, 8, 9],
    ];

    match find_in_matrix(&data, 5) {
        Some((r, c)) => println!("Found at row {}, col {}", r, c),
        None => println!("Not found"),
    }
}

The function returns Option<(usize, usize)>. The loop is the final expression in the function. The loop evaluates to either the break value or the expression after the loop. The break provides Some((row_idx, col_idx)). The expression after the loop provides None. Both have type Option<(usize, usize)>. The types match. The compiler accepts the code.

If you remove the None at the end, the compiler complains. The loop must have a value for every path. Since for loops can finish normally, the code after the loop must provide a value. If you used a loop instead, there is no normal exit. You must break. If you forget to break, the loop is infinite. An infinite loop has type ! (never type), which coerces to any type. But assigning an infinite loop to a variable is usually a logic error.

Convention aside: Using break with a value is preferred over declaring a mutable variable before the loop. The break approach keeps the result immutable. It forces you to consider the loop's return type at the definition site. It reduces the surface area for bugs where a variable is not updated correctly.

Pitfalls and compiler errors

Labeled loops are powerful, but they have constraints. Mixing break types is a common mistake. If you have two break points, they must return the same type. The compiler checks this strictly.

fn bad_example() {
    let result = loop {
        if true {
            break 1;
        } else {
            break "hello";
        }
    };
}

This code fails with E0308. The compiler cannot unify i32 and &str. You must ensure all break values match. If one path breaks with a value and another path falls through to an expression, the expression must match the value type.

Another pitfall is label scope. You cannot break to a label from a closure or a nested function. Labels are lexical. They only apply to the loop block where they are defined. If you need to break out of a loop from inside a closure, you must use a different pattern, such as returning a flag or using a channel.

Labels can also make code harder to read if overused. A function with three labeled loops and breaks jumping between them is difficult to follow. Labels are best for simple nesting. If the logic becomes complex, extract the loop into a helper function. Functions provide a natural boundary for control flow.

Use labels when the nesting is shallow and the break point is clear. Use helper functions when the logic inside the loops requires significant setup or cleanup. Labels are for control flow. Functions are for organization.

Decision matrix

Use a labeled break when you need to exit multiple nested loops at once and return control to the code after the outermost loop.

Use break with a value when you want the loop to compute a result, such as a search index or a parsed token, eliminating the need for a separate mutable variable.

Use a labeled continue when you want to skip the remainder of an inner loop and jump directly to the next iteration of an outer loop.

Reach for a helper function when the logic inside nested loops becomes complex enough that extracting it improves readability; labels are great for control flow, but functions are better for organizing logic.

Pick a flag variable only when you are maintaining legacy code or interfacing with patterns that require explicit state tracking; labels are the idiomatic Rust approach for multi-level breaks.

Where to go next