C# break, continue, goto: Exiting Nested Loops

C# break, continue and goto on .NET 10: safe infinite input loops, three ways to leave nested loops measured, goto case in switch, and compiler errors.

Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran all four examples on .NET 10.0.14 (C# 14). They all still work, but the input-summing example now produces two nullable warnings (CS8600, CS8604) with today's project template, and it crashed with ArgumentNullException when input ended without "end". Two statements in the text were also inaccurate: that an infinite loop "makes memory grow quickly", and that continue "jumps straight to the condition". Both are corrected below with measurements. We also added a timed comparison of three ways to leave nested loops, and goto case in switch.

This article is for developers learning how to stop or skip loop iterations in C#, and for anyone who wants to leave two nested loops at once and wonders whether goto is acceptable. In short: break leaves the innermost loop or switch, continue skips the rest of the current iteration, and goto jumps to a label. For nested loops, all three common techniques ran at the same speed, so readability decides.

Infinite loops: when they are useful, and what really happens

Put true in the condition and a loop never ends on its own. The classic use is reading input until a sentinel value arrives and then calling break. The 2021 example does exactly that: it adds up numbers until the user types "end". Pasted into a net10.0 project, it builds with two warnings:

Program.cs(11,31): warning CS8600: Converting null literal or possible null value to non-nullable type.
Program.cs(15,34): warning CS8604: Possible null reference argument for parameter 's' in 'int int.Parse(string s)'.

The current dotnet new console template turns on <Nullable>enable</Nullable>, and Console.ReadLine() returns null once input is closed. The warnings point at a real bug. Feeding 457, 1006, 422, 14, 33 and "end" printed 1932, the same as in 2021. But when input ended without "end" (Ctrl+D on macOS/Linux, Ctrl+Z on Windows, or the end of a pipe), it crashed:

Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 's')
   at System.Int32.Parse(String s)

Typing something like 12abc crashed differently: System.FormatException: The input string '12abc' was not in a correct format. Here is a version that handles both:

int sum = 0;
while (true)
{
    Console.Write("Number (end to quit): ");
    string? line = Console.ReadLine();
    if (line is null || line == "end") break;   // null = input closed (Ctrl+D / Ctrl+Z, end of pipe)
    if (!int.TryParse(line, out int n))
    {
        Console.WriteLine($"  '{line}' is not a number, skipped");
        continue;
    }
    sum += n;
}
Console.WriteLine($"Sum: {sum}");

With 457, 1006, 12abc, 422, 14 and 33 piped in, it printed '12abc' is not a number, skipped once and finished with Sum: 1932. No warnings, no crash.

The 2021 text claimed that an accidental infinite loop "makes memory grow quickly and can crash the program". Usually it does not. We built while (true) { n++; } in Release and watched it with ps for ten seconds: CPU stayed at 96–100% of one core, and memory (RSS) stayed flat at about 34 MB. Memory only grows if the loop accumulates something, such as adding to a list on every pass. The typical symptom of a runaway loop is one pegged core and an unresponsive program, not a memory explosion.

Where break and continue actually go

break exits the innermost enclosing loop or switch, and nothing more. The 2021 break example (stop when num reaches 40) printed exactly 40 lines, num: 0 through num: 39.

continue skips the rest of the current iteration. The example that skips odd numbers from 1 to 50 printed exactly 25 lines, i: 2 through i: 50.

Flow diagram of a for loop: initializer, then the condition i < n. If true, the body runs, then the iterator i++, then back to the condition. If false, control goes to the statement after the loop. Dashed arrows from the body: continue goes to i++, break goes to the statement after the loop, and goto Done goes to a label Done placed after the outer loop.
Where each jump statement goes inside a for loop. continue skips the rest of the body but still runs the iterator (i++); break leaves the innermost loop or switch; goto jumps to a label in an enclosing scope, which is the only one of the three that can leave several loops at once.

The original text said continue "moves straight to the condition check". In a for loop it runs the iterator (i++) first, then checks the condition. That difference explains a common while bug:

int i = 0, spins = 0;
while (i < 5)
{
    if (++spins > 20) { Console.WriteLine($"stuck at i={i} after {spins - 1} iterations"); break; }
    if (i == 2) continue;   // skips the i++ below, forever
    Console.WriteLine($"i={i}");
    i++;
}
for (int k = 0; k < 5; k++)
{
    if (k == 2) continue;   // in a for loop, k++ still runs
    Console.Write($"k={k} ");
}
i=0
i=1
stuck at i=2 after 20 iterations
k=0 k=1 k=3 k=4

The while version spun at i=2 because continue skipped i++ (the spins counter is only a safety net for the demo). The for version skipped 2 and carried on. When you use continue in a while loop, put the increment above it.

The other classic mistake is break inside a switch inside a loop. That break leaves the switch, not the loop:

string[] cmds = { "a", "quit", "b" };
foreach (string c in cmds)
{
    switch (c)
    {
        case "quit":
            Console.WriteLine("quit -> break");
            break;          // leaves the switch only
        default:
            Console.WriteLine($"cmd {c}");
            break;
    }
}
Console.WriteLine("---");
foreach (string c in cmds)
{
    if (c == "quit") break;   // leaves the loop
    Console.WriteLine($"cmd {c}");
}
cmd a
quit -> break
cmd b
---
cmd a

The first loop went on to process b after quit; the second stopped at quit.

goto and the rules for labels

goto Label; jumps to a label in the same method. The 2021 example had goto and EXIT; split across lines with a blank line in between. C# ignores whitespace, so it compiled as is and printed 탈출! ("Escaped!") after num: 39. Its comment still said "exits because of break", left over from the break example it was copied from.

The compiler enforces a few rules about labels. These are the messages we got:

Code Result
goto Inside; to a label inside a for block, from outside it error CS0159: No such label 'Inside' within the scope of the goto statement
A label End: as the last line of a block, then } error CS1525: Invalid expression term '}' (plus CS1002)
A label nobody jumps to warning CS0164: This label has not been referenced
A statement right after goto warning CS0162: Unreachable code detected

So goto can jump out of blocks but never into one, and a label must be followed by a statement. To put it at the end of a block, use an empty statement: End: ;. These limits make C#'s goto much tamer than the C version, and in practice it is used for two things: leaving several loops at once, and moving to another case in a switch.

Leaving nested loops: goto vs flag vs local function

Three ways to stop both loops as soon as a value is found in a jagged array:

static (int, int) FindGoto(int[][] g, int target)
{
    (int, int) found = (-1, -1);
    for (int i = 0; i < g.Length; i++)
        for (int j = 0; j < g[i].Length; j++)
            if (g[i][j] == target) { found = (i, j); goto Done; }
    Done:
    return found;
}

static (int, int) FindFlag(int[][] g, int target)
{
    (int, int) found = (-1, -1);
    bool stop = false;
    for (int i = 0; i < g.Length && !stop; i++)
        for (int j = 0; j < g[i].Length; j++)
            if (g[i][j] == target) { found = (i, j); stop = true; break; }
    return found;
}

static (int, int) FindLocal(int[][] g, int target)
{
    return Search();

    (int, int) Search()   // local function: one return ends both loops
    {
        for (int i = 0; i < g.Length; i++)
            for (int j = 0; j < g[i].Length; j++)
                if (g[i][j] == target) return (i, j);
        return (-1, -1);
    }
}

On [[3, 8, 1], [4, 7, 2], [9, 5, 6]], all three returned (1, 1) for 7 and (-1, -1) for 42. To compare speed, we put the target in the last cell of a 2,000 × 2,000 jagged array, so every call scans all 4 million cells, and timed 100 calls (Release, .NET 10.0.14, Apple Silicon Mac, after warm-up):

Method Process 1 (run 2 / run 3) Process 2 (run 2 / run 3)
goto Done 198.0 / 195.2 ms 192.7 / 191.5 ms
bool flag 201.8 / 197.1 ms 197.1 / 190.1 ms
local function + return 195.4 / 195.1 ms 190.4 / 192.5 ms

The first measured run in each process was about 260 ms for all three alike, apparently before the JIT had finished optimizing. After that the spread was under 2%, so performance is not a reason to choose. The local function captures g and target, but because it is never turned into a delegate the captures live in a struct: 1,000 calls allocated 0 bytes.

Decision diagram. First question: how many loops do you need to leave? One loop: use break. Several: second question, does the search produce a result you can return? Yes: put the loops in a method or local function and return. No, the loops change many locals of the current method: use one forward goto to a label right after the loops, or a bool flag checked in each loop condition.
Choosing how to leave nested loops. All three options ran at the same speed in our measurement, so the choice is about readability.

Our rule of thumb: when the loops search for something, a local function (or a separate method) with return reads best and is the easiest to test. When the loops update several locals of the current method and are hard to pull out, one forward goto to a label right after the loops is clearer than a flag. The flag version is our last choice because it is easy to forget the && !stop in the outer condition. Local functions are covered in more detail in C# methods.

goto case and goto default in switch

A C# switch does not fall through implicitly. A case 1: body without break at the end is a compile error:

error CS0163: Control cannot fall through from one case label ('case 1:') to another

To continue into another case, say so explicitly with goto case. Here, each plan switches on its own feature and then everything the plan below it includes:

foreach (string plan in new[] { "pro", "plus", "free", "trial" })
{
    Console.Write($"{plan,-5}: ");
    switch (plan)
    {
        case "pro":
            Console.Write("priority-support ");
            goto case "plus";
        case "plus":
            Console.Write("no-ads ");
            goto case "free";
        case "free":
            Console.Write("basic");
            break;
        default:
            Console.Write("unknown -> ");
            goto case "free";
    }
    Console.WriteLine();
}
pro  : priority-support no-ads basic
plus : no-ads basic
free : basic
trial: unknown -> basic

goto case 5; when there is no case 5: gives error CS0159: No such label 'case 5:' within the scope of the goto statement. Inside a pattern case such as case int n when n > 2:, jumping to a constant case with goto case 0; worked (output: big, then zero-ish). Because goto case only accepts a constant, keep shared handling in a constant case or in default.

Summary

You want to Use
End one loop when a condition is met break
Skip just this iteration continue (in while, put the increment above it)
End several nested loops at once local function + return, or a forward goto
Run another case in a switch goto case value; / goto default;
Repeat until input runs out while (true) + a null check + break

How while, for and foreach themselves behave, including a for-vs-foreach measurement, is in the C# loops article. Whether to catch an exception inside a loop and carry on, or let it end the loop, is covered in C# exception handling.

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14 (the net10.0 default), macOS on an Apple Silicon Mac (M1 Pro).

FAQ

How do I break out of nested loops in C#?

break only leaves the innermost loop. Wrap the loops in a local function or method and return, jump with goto to a label right after the loops, or add a bool flag to the outer condition. Scanning a 2,000 x 2,000 grid 100 times took about 190 to 200 ms with all three on .NET 10, so pick the one that reads best.

How do I fall through to the next case in a C# switch?

C# does not allow implicit fall-through between cases; it reports error CS0163. To run another case, write goto case "value"; or goto default; explicitly. Jumping to a case that does not exist gives error CS0159.

Why does continue cause an infinite loop in my while loop?

If continue comes before the i++ at the bottom of a while loop, it skips the increment and the loop repeats with the same value forever. A for loop runs its iterator after continue, so it does not have this problem. In our test the while version was stuck at i=2, while the for version skipped 2 and printed 0, 1, 3, 4.

  • #C#
  • #break
  • #continue
  • #goto
  • #loops
  • #switch

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑