break, continue, and Labels

In Kotlin, break and continue are jump statements that change how a loop runs: break exits the loop immediately, while continue skips the rest of the current iteration and moves on to the next one. On their own they only affect the loop that directly contains them, which becomes a problem once loops are nested — that’s where Kotlin’s labels come in, letting you name a loop and target it explicitly from an inner loop. Together, these three tools give you precise control over iteration without resorting to extra boolean flags or restructured code.

Overview: How break, continue, and Labels Work

break terminates the nearest enclosing loop entirely; execution resumes at the first statement after the loop’s closing brace. continue stops the current iteration and jumps straight to the loop’s next iteration check — for a for loop that means advancing to the next element, and for a while or do-while loop it means re-evaluating the condition. Both statements only make sense inside a real loop construct written with for, while, or do-while; the compiler rejects them anywhere else, including inside the lambda you pass to a higher-order function like forEach (more on that in Common Mistakes).

Under the hood, break and continue have the special Kotlin type Nothing — the type of an expression that never completes normally. That’s the same type throw has, and it lets the compiler use break or continue as one branch of an if expression without breaking type inference, since a branch that never returns doesn’t need to contribute a value to the expression’s type.

By default, break and continue target the innermost loop that contains them. When loops are nested, that’s often not what you want — you may need to exit (or skip an iteration of) an outer loop from deep inside an inner one. Kotlin solves this with labels: you prefix a loop with an identifier followed by @, and then reference that identifier from break@label or continue@label anywhere inside it, no matter how many loops are nested in between. This is more explicit and readable than Java-style boolean “shouldBreak” flags or restructuring code into separate functions just to get an early return.

Syntax

The general form of a labeled loop and its labeled jump statements:

loopLabel@ for (item in collection) {
    if (someCondition) break@loopLabel
    if (otherCondition) continue@loopLabel
}
Form Meaning
break Exits the nearest enclosing loop immediately.
continue Skips to the next iteration of the nearest enclosing loop.
label@ Attaches a name to the loop that immediately follows it.
break@label Exits the loop marked with that label, even from inside nested loops.
continue@label Jumps to the next iteration of the loop marked with that label.
return@label Not a loop jump — returns from a labeled lambda, commonly used to simulate early exit inside forEach and similar functions.

A label can be any valid identifier; by convention it’s written in lowerCamelCase followed directly by @, with no space, immediately before the loop keyword.

Examples

Example 1: break stops the loop entirely

fun main() {
    for (i in 1..10) {
        if (i == 5) {
            break
        }
        println(i)
    }
    println("Done")
}

Output:

1
2
3
4
Done

The loop counts up from 1, but as soon as i reaches 5 the break statement fires and the loop ends immediately — 5 through 10 are never printed, and control moves straight to println("Done") after the loop.

Example 2: continue skips an iteration

fun main() {
    for (i in 1..10) {
        if (i % 2 == 0) {
            continue
        }
        println(i)
    }
}

Output:

1
3
5
7
9

Here the loop still runs all ten iterations, but whenever i is even, continue skips the println(i) call for that iteration and moves straight on to the next value of i. The loop itself never ends early — only individual iterations are skipped.

Example 3: labeled break to exit nested loops

fun main() {
    val grid = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6),
        intArrayOf(7, 8, 9)
    )
    val target = 5
    var found = false

    search@ for (row in grid) {
        for (value in row) {
            if (value == target) {
                found = true
                break@search
            }
        }
    }

    println("Found $target: $found")
}

Output:

Found 5: true

This searches a 2D grid for a target value. found is declared with var because its value changes once a match is located — val would not compile here since it’s reassigned inside the loop. An unlabeled break inside the inner loop would only stop scanning the current row and let the outer loop move on to the next one — wasteful once the value has already been found. Labeling the outer loop search@ lets break@search, called from inside the inner loop, exit both loops in one step as soon as the target is located.

How It Works Step by Step

Walking through Example 3:

  • The outer loop binds row to intArrayOf(1, 2, 3), the first row.
  • The inner loop iterates that row’s values: 1, then 2, then 3. None equals target (5), so the inner loop finishes normally and the outer loop advances.
  • The outer loop binds row to intArrayOf(4, 5, 6). The inner loop checks 4 (no match), then 5 — this matches, so found is set to true and break@search runs.
  • Because the label targets the outer loop, execution doesn’t just exit the inner for; it exits both loops immediately and jumps to the first statement after the outer loop’s closing brace — the println call. The third row, intArrayOf(7, 8, 9), is never even visited.

The same mechanics apply to continue@label: instead of abandoning the labeled loop entirely, it abandons only the current iteration of that labeled loop and jumps to its next iteration check, skipping any remaining nested loop work in between.

Common Mistakes

Mistake 1: assuming an unlabeled break exits every loop

It’s easy to write a nested loop expecting break to stop everything, when it actually only stops the innermost loop:

fun main() {
    for (i in 1..3) {
        for (j in 1..3) {
            if (j == 2) {
                break
            }
            println("i=$i, j=$j")
        }
    }
}

Output:

i=1, j=1
i=2, j=1
i=3, j=1

The break only ends the inner j loop each time j reaches 2 — the outer i loop keeps running for all three values of i, restarting the inner loop each time. If the intent was to stop everything the first time j == 2 happens, label the outer loop:

fun main() {
    outer@ for (i in 1..3) {
        for (j in 1..3) {
            if (j == 2) {
                break@outer
            }
            println("i=$i, j=$j")
        }
    }
}

Output:

i=1, j=1

Now break@outer exits both loops the very first time the condition is met.

Mistake 2: trying to use break or continue inside forEach

forEach looks like a loop, but it’s actually a regular inline function call that takes a lambda — it isn’t a for, while, or do-while construct, so the compiler flatly rejects break and continue inside its lambda:

fun main() {
    listOf(1, 2, 3, 4).forEach {
        if (it == 3) break
        println(it)
    }
}

Output:

Compiler error: 'break' and 'continue' are not allowed here, only within a loop

To skip an item, use a labeled return targeting the implicit lambda label, which shares its name with the function:

fun main() {
    listOf(1, 2, 3, 4).forEach {
        if (it == 3) return@forEach
        println(it)
    }
}

Output:

1
2
4

return@forEach only returns from the current lambda invocation, so it behaves like continue, not break — the loop still visits every remaining element (3 is skipped but 4 is still printed). There’s no direct equivalent that truly stops forEach early; if you need real break behavior, use a plain for loop, or reach for a purpose-built function like takeWhile, firstOrNull, or indexOfFirst.

Mistake 3: referencing a label that was never declared

fun main() {
    for (i in 1..3) {
        if (i == 2) break@outer
        println(i)
    }
}

Output:

Compiler error: target label 'outer' does not exist

The loop was never labeled outer@, so break@outer has nothing to bind to. The label name in the jump statement must exactly match a label written directly before an enclosing loop.

Best Practices

  • Reach for a label only when you actually need to affect an outer loop from an inner one — for a single loop, plain break/continue is clearer.
  • Give labels descriptive names (search@, rows@) rather than single letters, since they’re read far from where they’re declared.
  • Remember that break/continue cannot cross into or out of a lambda passed to functions like forEach, map, or filter — use return@label, or switch to a real for/while loop when you need a genuine early exit.
  • Prefer expressive standard-library functions (any, none, first, indexOfFirst, takeWhile) over manual loops with flags and break when the intent is a simple search or filter — they read as clearly as English and avoid mutable state.
  • In a do-while loop, remember continue jumps to the condition check at the bottom, not back to the top of the body — the condition may reference variables set earlier in the same iteration.
  • Avoid deeply nested labeled loops as a substitute for extracting a small helper function; a function with a normal return is often more readable than three levels of labels.

Practice Exercises

  • Loop over the numbers 1 through 20. Use continue to skip any multiple of 3, and use break to stop the loop entirely once a number greater than 15 is reached. Print each number that isn’t skipped.
  • Given a list of rows, where each row is a list of strings, use a labeled for loop and break@label to find the first row and column index where a target string appears, then print those indices.
  • Take a list of integers and try to stop at the first negative number using forEach with break — confirm it fails to compile, then rewrite the same logic two ways: once using return@forEach (note how its behavior differs from a real break), and once using a plain for loop with an unlabeled break that truly stops early.

Summary

  • break exits the nearest enclosing loop immediately; continue skips to that loop’s next iteration.
  • Both statements only work inside real for, while, or do-while loops — never inside a lambda like the one passed to forEach.
  • break and continue have the type Nothing, meaning they never complete normally, which is why the compiler can use them inside expression branches.
  • A label (name@) placed before a loop lets break@name or continue@name target that specific loop from anywhere inside it, even through nested loops.
  • Inside forEach and similar higher-order functions, use return@functionName to skip the current element — it behaves like continue, not break, since it can’t stop the function from visiting the rest of the collection.
  • Referencing an undeclared label, or expecting an unlabeled break to exit more than the innermost loop, are the two most common mistakes with this feature.