Control Flow

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C#'s statements will look familiar to anyone coming from C, Java or JavaScript. What differs is how much of the work has migrated to expressions — the switch expression in particular — and how strict the compiler is about fall-through and definite assignment.

if and else

static string Describe(int value)
{
    if (value < 0)
    {
        return "negative";
    }
    else if (value == 0)
    {
        return "zero";
    }
    else
    {
        return "positive";
    }
}

The condition must be bool — there is no implicit conversion from a number or a reference, so if (count) and if (name) are compile errors rather than subtle bugs. The house style keeps braces even on single-statement bodies.

An if often reads better as a guard clause that returns early:

static decimal Discount(decimal total, bool isMember)
{
    if (total <= 0)
    {
        return 0;
    }

    if (!isMember)
    {
        return total * 0.05m;
    }

    return total * 0.15m;
}

The switch Statement

static void Handle(int code)
{
    switch (code)
    {
        case 200:
        case 201:                       // stacked labels: legal, shares the body
            Console.WriteLine("success");
            break;

        case 404:
            Console.WriteLine("not found");
            break;

        case >= 500 and < 600:          // relational + logical pattern (C# 9)
            Console.WriteLine("server error");
            break;

        case var other when other < 0:  // case guard
            Console.WriteLine($"invalid: {other}");
            break;

        default:
            Console.WriteLine("unhandled");
            break;
    }
}

C# does not allow implicit fall-through: every non-empty section must end with break, return, throw, goto case or continue. Stacked labels with no statements between them are the only way to share a body. Explicit fall-through uses goto:

static void Fallthrough(int n)
{
    switch (n)
    {
        case 1:
            Console.WriteLine("one");
            goto case 2;             // explicit fall-through
        case 2:
            Console.WriteLine("two");
            break;
        default:
            Console.WriteLine("other");
            break;
    }
}

A switch may switch on any type, not just integers and strings — the case labels are patterns.

The switch Expression

static string Describe(int value) => value switch
{
    < 0  => "negative",
    0    => "zero",
    _    => "positive",
};

static decimal Rate(string plan, int seats) => (plan, seats) switch
{
    ("free", _)              => 0m,
    ("team", <= 10)          => 9.99m,
    ("team", _)              => 7.99m,
    ("enterprise", var n) when n > 100 => 4.99m,
    ("enterprise", _)        => 5.99m,
    _ => throw new ArgumentOutOfRangeException(nameof(plan)),
};

Differences from the statement form, all of which matter:

  • It produces a value, so it can be assigned, returned or passed directly.

  • Arms use and are separated by commas; there is no break.

  • The discard _ replaces default.

  • Arms are evaluated strictly top to bottom; the first match wins, so order later arms from specific to general.

  • The compiler checks exhaustiveness and warns (CS8509) when it cannot prove every input is handled. An unmatched input at run time throws SwitchExpressionException.

flowchart TD Q{"What do you need
from the switch?"} Q -->|"a value: assign it,
return it, pass it"| E["switch expression
value switch { pattern => result, ... }"] Q -->|"side effects: several
statements per case"| S["switch statement
switch (value) { case p: ...; break; }"] E --> E1["+ exhaustiveness warning"] E --> E2["+ concise, no break"] E --> E3["− one expression per arm"] S --> S1["+ any statements, loops, locals"] S --> S2["+ goto case for fall-through"] S --> S3["− no exhaustiveness check"] style E fill:#eefaf2,stroke:#2f7d51 style S fill:#eef4fb,stroke:#3d6fa5

Loops

while and do

int i = 0;
while (i < 3)
{
    Console.WriteLine(i);
    i++;
}

int j = 10;
do
{
    Console.WriteLine(j);        // runs at least once, even though the condition is false
    j++;
} while (j < 3);

for

for (int k = 0; k < 5; k++)
{
    Console.WriteLine(k);
}

// All three clauses are optional; multiple initialisers and iterators are comma-separated.
for (int lo = 0, hi = 9; lo < hi; lo++, hi--)
{
    Console.WriteLine($"{lo}..{hi}");
}

for (; ; )                       // an infinite loop; `while (true)` reads better
{
    break;
}

foreach

foreach works over anything that offers a suitable GetEnumerator() — it does not require IEnumerable. The compiler pattern-matches the shape:

foreach (int n in new[] { 1, 2, 3 })
{
    Console.WriteLine(n);
}

// Over a span -- no allocation, no interface dispatch.
Span<int> buffer = stackalloc int[3];
buffer[0] = 7; buffer[1] = 8; buffer[2] = 9;
foreach (int n in buffer)
{
    Console.WriteLine(n);
}

// Over a dictionary, deconstructing each pair into a tuple.
var ages = new Dictionary<string, int> { ["Ada"] = 36, ["Alan"] = 41 };
foreach ((string name, int age) in ages)
{
    Console.WriteLine($"{name} is {age}");
}

// Over a custom type: only a public GetEnumerator() with Current and MoveNext() is needed.
public sealed class Countdown
{
    private readonly int _from;
    public Countdown(int from) => _from = from;

    public Enumerator GetEnumerator() => new(_from);

    public struct Enumerator
    {
        private int _current;
        public Enumerator(int from) => _current = from + 1;
        public int Current => _current;
        public bool MoveNext() => --_current > 0;
    }
}

The iteration variable is read-only: you cannot assign to it, and you cannot modify the collection while enumerating it (most BCL collections throw InvalidOperationException if you try). Each iteration gets a fresh variable, which is what makes capturing it in a lambda safe — see Delegates, Lambdas and Events.

await foreach iterates an IAsyncEnumerable<T>; see Async and Await.

break, continue, goto, return

foreach (int n in Enumerable.Range(0, 10))
{
    if (n % 2 == 0)
    {
        continue;       // skip to the next iteration
    }

    if (n > 6)
    {
        break;          // leave the innermost loop
    }

    Console.WriteLine(n);   // 1 3 5
}

break and continue affect only the innermost enclosing loop or switch. To leave several levels at once in C# 14 and earlier you need a label and goto, or a flag, or extracting the loops into a method and using return:

static (int Row, int Column)? Find(int[,] grid, int target)
{
    for (int row = 0; row < grid.GetLength(0); row++)
    {
        for (int column = 0; column < grid.GetLength(1); column++)
        {
            if (grid[row, column] == target)
            {
                return (row, column);      // the cleanest multi-level exit
            }
        }
    }

    return null;
}

static void WithGoto(int[,] grid, int target)
{
    for (int row = 0; row < grid.GetLength(0); row++)
    {
        for (int column = 0; column < grid.GetLength(1); column++)
        {
            if (grid[row, column] == target)
            {
                goto found;
            }
        }
    }

    Console.WriteLine("not found");
    return;

found:
    Console.WriteLine("found");
}

goto is legal but rarely justified outside goto case and the multi-level break above. It cannot jump into a block, into a try, or out of a finally.

Labeled break and continue (C# 15 preview)

Preview feature — C# 15 / .NET 11

The following requires a .NET 11 preview SDK and <LangVersion>preview</LangVersion>. It is not available in C# 14 / .NET 10, may change before release, and the snippet below is written from the published feature specification rather than compiled against a shipped compiler.

C# 15 adds loop labels, so break and continue can target an outer loop directly — the same shape Java has had since 1.0:

outer:
foreach (var row in grid)
{
    foreach (var cell in row)
    {
        if (cell.IsBlocked)
        {
            continue outer;    // next row
        }

        if (cell.IsTarget)
        {
            break outer;       // leave both loops
        }
    }
}

This removes the main remaining use of goto in loop code. Until it ships, prefer extracting the loops into a method and returning.

throw as a Statement and an Expression

static string Require(string? value, string parameterName)
{
    // throw expression on the right of ?? -- C# 7
    return value ?? throw new ArgumentNullException(parameterName);
}

static int Parse(string input) => input switch
{
    null    => throw new ArgumentNullException(nameof(input)),
    ""      => throw new ArgumentException("Must not be empty.", nameof(input)),
    var s   => int.Parse(s, CultureInfo.InvariantCulture),   // throw expression in a switch arm
};

A throw expression is allowed wherever a value is expected: after ??, in a conditional branch, in a switch arm and in an expression-bodied member. See Exceptions and Error Handling.

yield

yield return and yield break make a method an iterator — the compiler rewrites it into a state machine that produces values lazily:

static IEnumerable<int> EvenNumbersUpTo(int max)
{
    for (int i = 0; i <= max; i += 2)
    {
        yield return i;          // suspends here, resumes on the next MoveNext()

        if (i > 100)
        {
            yield break;         // ends the sequence early
        }
    }
}

Nothing in the body runs until the caller starts enumerating. See Collections and Iterators.

See Also