Local functions in practice: eager validation for iterators and async methods, capture without allocation, static local functions, recursion, and choosing between local functions, lambdas and private methods.
An iterator method runs none of its body until the first MoveNext, so a ThrowIfNull at the top only fires when someone enumerates. An async method stores the exception in the returned task, so it only shows up when awaited. The fix is to keep the public method non-iterator and non-async: validate there, then return a call to a local iterator or local async function. That's exactly how LINQ validates eagerly. Local functions can capture variables without allocating when they aren't turned into delegates, and marking them static forbids capture altogether. I use them for private helpers that only make sense inside one method.
Both iterator methods (yield return) and async methods (async) are compiled into state machines that run the method body later: an iterator's body starts on the first MoveNext(), and an async method's exceptions are captured into the returned Task. So argument validation inside the body doesn't throw at the call site — it throws when the result is enumerated or awaited, often far away. The fix is to split the method: validate eagerly in the outer method, and put the lazy/async work in a local function. I'd also explain static local functions and when to prefer local functions over lambdas or private methods.
Iterators:
public static IEnumerable<string> ReadLines(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path); // runs only on first MoveNext()!
foreach (var line in File.ReadLines(path)) yield return line;
}
var lines = ReadLines(null!); // no exception here
// ... 200 lines later ...
foreach (var l in lines) { } // ArgumentException thrown HERE
The compiler turns the whole body — including the guard — into MoveNext() of a generated class. Calling ReadLines just creates the iterator object.
Async methods:
public async Task<Order> GetAsync(Guid id, CancellationToken ct)
{
ArgumentOutOfRangeException.ThrowIfEqual(id, Guid.Empty); // exception stored in the Task
return await _repo.FindAsync(id, ct);
}
var task = GetAsync(Guid.Empty, ct); // no exception thrown here
...
var order = await task; // thrown here (or never, if the task isn't awaited)
In an async method, all exceptions (even before the first await) are captured into the returned task. Fire-and-forget callers never see them; Task.WhenAll callers see them later.
public static IEnumerable<string> ReadLines(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path); // eager: runs at call time
return Iterate(path);
static IEnumerable<string> Iterate(string path) // lazy part
{
foreach (var line in File.ReadLines(path)) yield return line;
}
}
public Task<Order> GetAsync(Guid id, CancellationToken ct) // NOT async
{
ArgumentOutOfRangeException.ThrowIfEqual(id, Guid.Empty); // throws synchronously to the caller
return GetCoreAsync(id, ct);
async Task<Order> GetCoreAsync(Guid id, CancellationToken ct) => await _repo.FindAsync(id, ct)
?? throw new OrderNotFoundException(id);
}
The BCL uses exactly this pattern (LINQ operators validate eagerly; many async APIs validate synchronously). Note: for async methods, some teams prefer exceptions in the task for consistency (callers handle all errors at await); the key is to be deliberate. Argument/usage errors (bugs) are best thrown eagerly.
static local functions (C# 8) can't capture locals, parameters or this — everything must be passed explicitly. Benefits:
static when possible.| Use | When |
|---|---|
| Local function | Helper used only in this method; iterator/async splitting; recursion; when you want no delegate allocation (direct calls to local functions capture via struct, not heap closure) |
| Lambda | You need a delegate to pass somewhere (LINQ, callbacks, Task.Run), or a short inline expression |
| Private method | Reused by multiple members, deserves its own name at class level, or is independently testable/significant |
Local functions also support generics, ref/out params, attributes, yield and async — things lambdas can't (or can only partially) do.
A file-import service's IEnumerable<Record> Parse(Stream stream) validated stream.CanRead inside the iterator. A caller passed a disposed stream; the exception surfaced in a background Parallel.ForEach minutes later, logged as an ObjectDisposedException with a stack trace pointing at the enumeration, not the caller. Splitting validation from iteration with a local function made the bug fail at the call site in a unit test. We applied the same pattern to async service methods so invalid IDs throw before any work is scheduled.
A debugging question that checks understanding of iterator and async state machines.
Green flags: explains why validation is deferred in both cases; shows the outer/inner split; knows struct closures and static local functions; reasons about lambda versus local function versus private method.
Red flags: blames the caller; suggests calling ToList inside the iterator; does not know async stores exceptions in the Task.
// public IEnumerable<string> ReadLines(string path) => new <ReadLines>d__0(-2) { <>3__path = path };
private sealed class <ReadLines>d__0 : IEnumerable<string>, IEnumerator<string>
{
private int <>1__state;
private string <>2__current;
public string <>3__path;
bool IEnumerator.MoveNext()
{
switch (<>1__state)
{
case 0:
<>1__state = -1;
ArgumentException.ThrowIfNullOrEmpty(path); // guard lives here
...
}
}
}
The method returns the object immediately; the guard runs when MoveNext first executes.
public Task<Order> GetAsync(Guid id, CancellationToken ct)
{
var sm = new <GetAsync>d__0 { <>t__builder = AsyncTaskMethodBuilder<Order>.Create(), id = id, ct = ct, <>1__state = -1 };
sm.<>t__builder.Start(ref sm); // runs MoveNext synchronously until the first incomplete await
return sm.<>t__builder.Task;
}
MoveNext wraps the body in try/catch; any exception calls builder.SetException(ex), faulting the task. So even synchronous exceptions become task faults.
A non-static local function that captures locals and is only called directly uses a struct closure passed by ref (no heap allocation). But iterator or async local functions must hoist captured variables into their state machine (heap) — another reason to pass values as parameters and make them static.
Modern .NET stack traces show local functions as ReadLines(String path)+Iterate(String path) (demangled from <ReadLines>g__Iterate|0_0) and async frames cleanly, so splitting doesn't hurt diagnostics.
await.ValueTask and eager pathsFor hot async methods, the split pattern also enables synchronous fast paths: the non-async outer method can return ValueTask.FromResult(cached) without allocating a state machine, delegating to the async local function only when I/O is needed.
static and fix compile errors by passing parameters.namespace Advanced.LocalFunctions;
public sealed record Row(int LineNumber, string[] Fields);
public static class CsvReader
{
// BUG: validation deferred until enumeration
public static IEnumerable<Row> ReadRowsDeferred(TextReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
var line = 0;
while (reader.ReadLine() is { } text) yield return new Row(++line, text.Split(','));
}
// FIX: eager validation, lazy iteration
public static IEnumerable<Row> ReadRows(TextReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
return Iterator(reader);
static IEnumerable<Row> Iterator(TextReader r) // static: no accidental captures
{
var line = 0;
while (r.ReadLine() is { } text) yield return new Row(++line, text.Split(','));
}
}
// Same idea for async
public static Task<int> CountRowsAsync(TextReader reader, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(reader);
return Core();
async Task<int> Core() // captures reader and ct without allocation
{
var count = 0;
while (await reader.ReadLineAsync(ct) is not null) count++;
return count;
}
}
// Recursion and generics are natural in local functions
public static int Depth<T>(IReadOnlyDictionary<T, T> parentOf, T node) where T : notnull
{
return Walk(node);
int Walk(T current) => parentOf.TryGetValue(current, out var parent) ? 1 + Walk(parent) : 0;
}
}
public static class Program
{
public static async Task Main()
{
var deferred = CsvReader.ReadRowsDeferred(null!); // no exception yet
try { deferred.First(); } catch (ArgumentNullException) { Console.WriteLine("Thrown late, at enumeration"); }
try { CsvReader.ReadRows(null!); } catch (ArgumentNullException) { Console.WriteLine("Thrown at the call"); }
using var reader = new StringReader("a,b\nc,d\n");
Console.WriteLine(await CsvReader.CountRowsAsync(reader, CancellationToken.None)); // 2
var parents = new Dictionary<string, string> { ["leaf"] = "branch", ["branch"] = "root" };
Console.WriteLine(CsvReader.Depth(parents, "leaf")); // 2
}
}
What happens: argument checks in methods with yield.
Why it is wrong: exceptions surface on enumeration, far from the bug.
Do instead: validate in an outer method; iterate in a local function.
What happens: try/catch around a call without await.
Why it is wrong: exceptions are stored in the task.
Do instead: await the task inside the try, or split usage-error validation into a non-async wrapper.
What happens: _ = GetAsync(badId); never observed.
Why it is wrong: errors silently vanish.
Do instead: await tasks or use background queues with error handling.
What happens: local functions capture locals and this.
Why it is wrong: hoisted into heap state machines; harder to reason about.
Do instead: make them static and pass parameters.
What happens: Func<...> helper = ... for internal helpers.
Why it is wrong: delegate and closure allocations; no yield.
Do instead: use local functions.
What happens: single-use helpers at class level. Why it is wrong: clutters the class and separates logic from its only use. Do instead: use local functions for single-use helpers.
What happens: "not found" thrown before returning a task. Why it is wrong: inconsistent error handling for callers. Do instead: reserve synchronous throws for usage errors.
What happens: methods with many large local functions. Why it is wrong: hides that the method does too much. Do instead: extract a class or private methods.
The eager-validation split is the canonical reason local functions exist, and it is a strong signal in interviews because it combines knowledge of iterator and async state machines. Beyond that, local functions are a tidy way to keep helpers private to one method with zero allocation for captures. I mark them static when they do not need captures, and I promote them to private methods when they grow or need direct tests.
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.
A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.
IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.
How to tell it (STAR)
**S:** Invalid import requests were accepted and failed later in a background worker. **T:** Make the API fail fast. **A:** I found the validation lived inside an iterator, so it only ran when the worker enumerated it. I split the method into eager validation plus a local iterator. **R:** Bad requests now get a 400 immediately and the support team can tie errors to requests.
Last updated 30 Oct 2026.