C# selection, iteration and jump statements, how foreach is lowered for arrays, List<T> and IEnumerable<T>, and the classic loop bugs: closure capture, modifying during enumeration, off-by-one and multiple enumeration.
C# has selection statements (if, switch), iteration (for, foreach, while, do) and jumps (break, continue, return, goto, throw, yield). foreach over an array compiles to an index loop; over anything else it calls GetEnumerator, loops on MoveNext/Current, and disposes the enumerator in a finally. The bugs I watch for are modifying a list while iterating (it throws), closures capturing a for loop variable (they all see the last value), off-by-one bounds, and enumerating a lazy LINQ query twice.
I cover the constructs quickly, then spend most of the time on two senior topics: what foreach actually compiles to (pattern-based, struct enumerators, disposal) and the loop bugs that reach production — modifying collections while iterating, closures, off-by-one, and multiple enumeration.
if / else if / else — conditions must be bool; there is no implicit conversion from int, so if (count) does not compile.switch statement — works on any type with patterns (not just constants). C# forbids implicit fall-through between non-empty sections: each section must end with break, return, throw, continue or goto case. Empty sections can be stacked: case 1: case 2:.| Loop | Use when | Note |
|---|---|---|
for |
You need the index, a custom step, or reverse iteration | Condition evaluated each iteration |
foreach |
Iterating any sequence | Pattern-based; read-only iteration variable |
while |
Unknown number of iterations, condition first | May run zero times |
do...while |
Body must run at least once (retry, menu loop) | Condition after the body |
await foreach |
IAsyncEnumerable<T> (streams, EF Core async queries) |
Supports cancellation via WithCancellation |
break — exits the innermost loop or switch only. To exit nested loops, extract a method and return, or use a flag (goto exists but is rarely justified).continue — skips to the next iteration (in for, the iterator expression still runs).return — exits the method; finally blocks still run.yield return / yield break — produce values from an iterator lazily.foreach compiles toforeach is pattern-based, not interface-based. The compiler looks for a public GetEnumerator() with MoveNext() and Current:
// foreach (var item in list) Console.WriteLine(item);
List<int>.Enumerator e = list.GetEnumerator(); // struct enumerator
try
{
while (e.MoveNext())
{
int item = e.Current; // fresh variable per iteration (C# 5+)
Console.WriteLine(item);
}
}
finally
{
e.Dispose();
}
for loop with no enumerator at all.List<T> returns a struct enumerator, so foreach over a List<T> variable allocates nothing. Iterating the same list through IEnumerable<T> boxes the enumerator and uses interface calls — measurably slower in hot paths.finally, which is how iterator methods run their finally/using blocks when you break early.foreach → InvalidOperationException: Collection was modified. List<T> tracks a _version field that the enumerator checks on every MoveNext.foreach in C# 5, but still a bug for for loops: lambdas capture the single i variable.i <= list.Count, or forgetting the last batch when chunking.IEnumerable<T> — calling .Count() then foreach re-runs a LINQ pipeline or database query.while loops whose exit condition depends on state that never changes, or retry loops without a cap or backoff.await per item where bounded concurrency (Parallel.ForEachAsync) is appropriate, or the opposite: unbounded Task.WhenAll over 50,000 items.A nightly job removed expired sessions with foreach (var s in sessions) if (s.Expired) sessions.Remove(s); — it worked in testing because the test data had a single expired session, then threw "Collection was modified" in production. The fix was sessions.RemoveAll(s => s.Expired), which is also O(n) instead of O(n²). In the same codebase, a retry loop while (!sent) { sent = TrySend(); } with no cap and no delay pinned a CPU core and hammered a downstream API during an outage; we replaced it with a Polly (Microsoft.Extensions.Resilience) retry policy with exponential backoff and a maximum attempt count.
This checks that the candidate can debug common loop failures and understands what the compiler generates.
Green flags
foreach lowering and the dispose in finally.foreach capture change and that for still shares the variable.Red flags
ToList() everywhere without understanding why.switch cases fall through like C.foreach is duck-typedThe compiler's rules for foreach (V v in x):
x is an array or span, emit an index loop (for (int i = 0; i < x.Length; i++)). The JIT can then remove bounds checks.GetEnumerator() whose return type has bool MoveNext() and a Current property. Use that — no interface required.IEnumerable<T> / IEnumerable.IDisposable (or, for ref struct enumerators, has a Dispose method), wrap the loop in try/finally and dispose it.This duck typing is why List<T>, Dictionary<TKey,TValue>, HashSet<T> and ImmutableArray<T> expose public struct enumerators: foreach over the concrete type avoids a heap allocation and allows the JIT to inline MoveNext. When the same collection is typed as IEnumerable<T>, GetEnumerator() returns IEnumerator<T>, the struct is boxed, and every MoveNext/Current is an interface call (though dynamic PGO in .NET 8+ can often guarded-devirtualise them).
// Simplified from List<T>
public void Add(T item) { _version++; ... }
public bool MoveNext()
{
if (_version != _list._version) ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion();
...
}
Every mutating method increments _version; the enumerator captured the version at creation and checks it. It is a best-effort check for single-threaded misuse, not a thread-safety mechanism — concurrent writers can still corrupt the list without throwing. Safe alternatives:
list.RemoveAll(predicate) — single O(n) compaction pass.for when removing by index.foreach (var x in list.ToArray()).Dictionary<TKey,TValue>.Remove during enumeration is allowed since .NET Core 3.0 (it does not bump the version), but Add is not.Before C# 5, the foreach variable was declared outside the loop, so lambdas created in the loop all captured the same variable. C# 5 changed the spec so each iteration gets a fresh variable — one of the few breaking changes in C# history.
The for loop was deliberately not changed, because its variable is genuinely shared and mutated by the iterator expression:
var actions = new List<Action>();
for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i));
foreach (var a in actions) a(); // prints 333
The compiler hoists i into a closure class (a "display class") shared by all lambdas; by the time they run, i is 3. Fix: copy to a local inside the body (int copy = i;).
An iterator method (yield return) compiles into a state-machine class implementing IEnumerable<T> and IEnumerator<T>. try/finally and using inside the iterator become part of the state machine's Dispose. If the consumer breaks out of foreach, the compiler-generated finally calls Dispose, which runs the iterator's pending finally blocks — so a using var reader = ... inside an iterator is closed correctly. If you enumerate manually with GetEnumerator() and forget Dispose, those resources leak.
Iterators are lazy and re-runnable: each foreach calls GetEnumerator() again and re-executes the method from the start. That is the root of the multiple-enumeration bug.
for over an array or span with i < arr.Length lets the JIT eliminate bounds checks. Caching the length in a local can prevent that optimisation for arrays in older runtimes — write the idiomatic form.foreach over a List<T> is as fast as for in modern .NET; CollectionsMarshal.AsSpan(list) gives a span when you need the absolute fastest loop (and promise not to resize the list meanwhile).DateTime.Now, LINQ Count() in the condition).while (true) with a clear break is fine; what matters is a guaranteed exit and, for retries, a cap plus backoff with jitter.switch statement detailswhen guards, and order matters.goto case X; and goto default; are the explicit fall-through mechanism.switch on dense integers or enums compiles to a jump table (switch IL opcode); on strings, the compiler computes a hash and then compares (or, in newer compilers, switches on length and characters) to avoid sequential comparisons.foreach over List<int> and over IEnumerable<int> into SharpLab and compare the lowered code and allocations.[MemoryDiagnoser].try/finally that logs, break after the first item, and observe the log.for-loop closure bug and fix it.namespace Basics;
public sealed record Session(string Id, DateTimeOffset ExpiresAt);
public static class Loops
{
public static void Main()
{
// 1. Closure over a for variable
var actions = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
int copy = i; // fix: per-iteration copy
actions.Add(() => copy);
}
Console.WriteLine(string.Join(",", actions.Select(a => a()))); // 0,1,2
// 2. Modifying during foreach: use RemoveAll instead
var now = DateTimeOffset.UtcNow;
var sessions = new List<Session>
{
new("a", now.AddMinutes(-5)),
new("b", now.AddMinutes(10)),
new("c", now.AddMinutes(-1))
};
int removed = sessions.RemoveAll(s => s.ExpiresAt < now);
Console.WriteLine($"Removed {removed}, left {sessions.Count}");
// 3. Reverse loop when you need the index
var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
for (int i = numbers.Count - 1; i >= 0; i--)
{
if (numbers[i] % 2 == 0) numbers.RemoveAt(i);
}
// 4. Breaking out of nested loops: extract a method
Console.WriteLine(FindPair([1, 4, 6, 9], target: 10));
// 5. Multiple enumeration: materialise once
IEnumerable<int> expensive = Generate();
var list = expensive.ToList(); // runs once
Console.WriteLine($"{list.Count} items, first {list[0]}");
}
private static (int, int)? FindPair(int[] values, int target)
{
for (int i = 0; i < values.Length; i++)
for (int j = i + 1; j < values.Length; j++)
if (values[i] + values[j] == target)
return (values[i], values[j]); // exits both loops
return null;
}
private static IEnumerable<int> Generate()
{
Console.WriteLine("Generating...");
for (int i = 0; i < 3; i++) yield return i * 10;
}
}
foreachWhat happens: foreach (var x in list) if (cond) list.Remove(x);
Why it is wrong: the enumerator's version check throws InvalidOperationException; even with a for loop, removing forward skips the next element, and repeated Remove is O(n²).
Do instead: list.RemoveAll(cond), iterate backwards with for, or build a new list with LINQ Where.
for loop variable in a lambdaWhat happens: tasks or callbacks created in a for loop all see the final value of i.
Why it is wrong: for has one shared variable; the C# 5 fix only applies to foreach.
Do instead: copy to a local inside the loop, or use foreach over Enumerable.Range.
What happens: for (int i = 0; i <= items.Count; i++) or chunking code that drops the final partial batch.
Why it is wrong: throws ArgumentOutOfRangeException or silently loses data.
Do instead: use < with Count/Length, prefer foreach or Chunk(size) (.NET 6+), and unit-test boundaries (0, 1, exactly N, N+1).
IEnumerable<T> more than onceWhat happens: if (items.Any()) { var n = items.Count(); foreach (var i in items) ... } over a LINQ query or IQueryable.
Why it is wrong: each enumeration re-runs the pipeline — repeated database queries, repeated expensive work, or different results if the source changed.
Do instead: materialise once (ToList()/ToArray()), or accept IReadOnlyCollection<T> when callers already have a collection.
What happens: while (!done) { done = TryCall(); } with no delay, cap or cancellation.
Why it is wrong: burns CPU, amplifies load on a struggling dependency, and can run forever during an outage.
Do instead: use a resilience pipeline (Polly / Microsoft.Extensions.Resilience) with exponential backoff, jitter, max attempts and a CancellationToken.
What happens: found booleans checked in every loop level.
Why it is wrong: easy to get wrong and hard to read; break only exits the inner loop.
Do instead: extract the nested loops into a method and return the result.
await in a loop over independent work — or unbounded parallelismWhat happens: foreach (var id in ids) await client.GetAsync(id); for 1,000 IDs, or await Task.WhenAll(ids.Select(Fetch)) for 100,000.
Why it is wrong: the first is needlessly slow; the second exhausts sockets, connection pools or downstream rate limits.
Do instead: use Parallel.ForEachAsync with MaxDegreeOfParallelism, or SemaphoreSlim for bounded concurrency.
What happens: for (int i = 0; i < query.Count(); i++) or while (DateTime.Now < deadline) with heavy work.
Why it is wrong: the condition is evaluated every iteration — Count() on a query re-executes it each time.
Do instead: compute invariants once before the loop; use Stopwatch or a CancellationTokenSource with a timeout for time limits.
What happens: var e = source.GetEnumerator(); while (e.MoveNext()) ... with no using.
Why it is wrong: iterator finally blocks and using resources (readers, connections) never run.
Do instead: use foreach, or wrap manual enumeration in using var e = ....
What happens: developers coming from C or JavaScript expect a case without break to fall through.
Why it is wrong: C# reports CS0163; when people "fix" it they sometimes add break in the wrong place and lose behaviour.
Do instead: stack empty case labels, use or patterns (case 1 or 2:), or goto case when fall-through is truly intended.
Seniors treat loops as data-flow decisions: eager or lazy, mutate or project, sequential or parallel.
| Need | Prefer |
|---|---|
| Transform or filter | LINQ (Select, Where) or a simple foreach building a new list |
| Remove in place | List<T>.RemoveAll |
| Hot numeric loop | for over an array or Span<T> |
| Stream large data | yield return / IAsyncEnumerable<T> with await foreach |
| CPU-bound independent items | Parallel.ForEach with care for shared state |
| I/O-bound items | Parallel.ForEachAsync with MaxDegreeOfParallelism |
return can replace complex break and flag logic.async void lambdas in ForEach; use await foreach or Task.WhenAll deliberately.A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.
An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.
Last updated 30 Oct 2026.