A named, method-scoped helper that looks like a lambda but behaves like a method — built for recursion, readability, and skipping delegate overhead entirely.
Lambdas are great for short, throwaway logic. But what about a helper that's a little too long to read comfortably as one inline expression, or one that needs to call itself — recursion? Writing a self-referencing lambda is surprisingly awkward. C# 7 introduced a cleaner answer: the local function — a full method, with a name, declared right inside another method.
A local function is a method declared inside another method (or inside a property, constructor, or another local function), visible only within its containing member. It looks and behaves exactly like a regular method — it has a name, a return type, a parameter list, and a body — it's just scoped to live entirely inside its container.
void OuterMethod()
{
// Local function — declared inside OuterMethod, callable only from within it
static int Square(int x) => x * x;
Console.WriteLine(Square(5)); // 25
}
Introduced in C# 7 (2017), local functions accept the static modifier (available since C# 8), which is the detail that matters most for how they interact with closures — covered fully below.
Before local functions existed, a lambda assigned to a Func or Action variable was the only way to write a method-scoped helper. That runs into three friction points:
A local function is a genuine method the compiler can call directly, with no delegate indirection required unless you deliberately convert it to one. It supports natural recursion by name, reads like ordinary method syntax, and — when marked static — is guaranteed by the compiler to never capture anything from its surroundings, eliminating closure allocation entirely for logic that doesn't need it.
A local function is not "a lambda with a name." It's a real method that happens to be scoped to another method. That distinction is exactly why it can recurse naturally and skip delegate allocation — capabilities a lambda, by its nature as a delegate value, doesn't have for free.
static int Factorial(int n)
{
static int Compute(int value) => value <= 1 ? 1 : value * Compute(value - 1);
return Compute(n);
}
Compute(n) is an ordinary method call, resolved at compile time — nothing is allocated just to invoke it.static FORBIDS CAPTURINGstatic to a local function tells the compiler: "this must not reach outside its own parameters."static local function, it's a compile-time error — a guarantee, not just a convention.static, IT CAN CLOSE OVER OUTER VARIABLES — JUST LIKE A LAMBDAstatic local function follows the exact same capture-by-reference rules from lesson 102.using System;
class Program
{
static void Main()
{
Console.WriteLine(Fibonacci(10)); // 55
// Local function, declared inside Main, natural recursion by name:
static int Fibonacci(int n)
{
if (n <= 1) return n;
return Fibonacci(n - 1) + Fibonacci(n - 2); // calls itself directly — no workaround needed
}
}
}
Code → Meaning → Result: Notice that Fibonacci is called from Main before it's textually declared — local functions, unlike local variables, are available throughout their containing method's body regardless of where they appear. Inside Fibonacci, calling itself by name is completely natural, unlike the contortions a self-referencing lambda would require.
A method with several validation rules and multiple exit points often reads far more clearly with a local function pulling the repeated logic out, without polluting the containing class with a helper method nobody else should call:
using System;
using System.Collections.Generic;
public record Order(decimal Total, string CustomerEmail, int ItemCount);
public class OrderProcessor
{
public List<string> Validate(Order order)
{
var errors = new List<string>();
// static local function — cannot capture "order" or "errors" by accident,
// must be given everything it needs as parameters. Compiler-enforced.
static bool LooksLikeEmail(string value) => value.Contains('@') && value.Contains('.');
if (order.Total <= 0)
errors.Add("Total must be positive.");
if (order.ItemCount <= 0)
errors.Add("Order must contain at least one item.");
if (!LooksLikeEmail(order.CustomerEmail))
errors.Add("Customer email looks invalid.");
return errors;
}
}
class Program
{
static void Main()
{
var processor = new OrderProcessor();
var order = new Order(Total: -10m, CustomerEmail: "not-an-email", ItemCount: 0);
foreach (var error in processor.Validate(order))
Console.WriteLine(error);
// Total must be positive.
// Order must contain at least one item.
// Customer email looks invalid.
}
}
LooksLikeEmail is only ever meaningful inside Validate — declaring it as a private method on OrderProcessor would expose it to every other method in the class for no reason. As a static local function, its scope precisely matches where it's actually used, and the compiler guarantees it can't accidentally reach into order or errors — it must be handed exactly what it needs as a parameter.
A lambda stored in a Func/Action variable is like a tool placed in a portable toolbox — you can hand that toolbox to someone else, pass it to another room, store it for later. A local function is like a tool bolted permanently to a workbench in one specific workshop — it does its job extremely well right there, you never need to carry it anywhere, and precisely because it never leaves, it doesn't need the extra packaging (a delegate wrapper) that a portable tool requires.
The compiler turns a local function into an ordinary private method on the containing type (or on a compiler-generated helper type), given an unspeakable name you'll never type directly. A direct call to a local function — Compute(n) — compiles to a plain, direct method call, exactly like calling any other private method: no delegate is created, no Invoke indirection happens, unless you explicitly assign the local function to a Func/Action variable (which is legal — a local function can be converted to a delegate just like a named method group can), at which point the usual delegate-allocation cost applies.
A non-static local function that captures outer variables uses exactly the same display-class mechanism from lesson 102 — the compiler generates a class to hold the captured variables, and the local function becomes a method on that class. A static local function skips this entirely: because it's forbidden from capturing anything, the compiler can compile it as a plain static method with zero closure overhead, which is the concrete performance reason to reach for static whenever a local function genuinely doesn't need outer context.
static int Square(int x) => x * x; as a local function and Func<int,int> square = x => x * x; as a lambda can look visually similar, but only the lambda is immediately a delegate value. Square is a plain method — to pass it around as data, you'd write Func<int,int> f = Square;, converting it explicitly, exactly the way you'd convert any named method group.
Both mean "cannot use instance state (this)." But on a local function, static carries an additional, compiler-enforced promise: it also cannot capture any local variable or parameter from its containing method. That's a stronger guarantee than a top-level static method needs to make, because a top-level method never had access to another method's locals in the first place.
static and accidentally capturingWriting a local function that references an outer variable purely out of convenience, without meaning to create a closure — the resulting hidden allocation is invisible unless you know to look for it.
Add static to any local function that should be self-contained. If it doesn't compile, the compiler is telling you exactly which outer variable you're relying on — pass it in as a parameter instead.
Expecting to store Square in a List<Func<int,int>> without converting it first — a bare local function name isn't a delegate value on its own, any more than a bare named method is.
Convert it explicitly when you need it as data: Func<int,int> f = Square;. If you only ever call it directly by name, no conversion is needed at all.
static whenever it genuinely doesn't need outer context — you get a compiler-checked guarantee against accidental capture and avoid closure allocation entirely.static → compiler-guaranteed no capturing, zero closure overhead.
static forbids it from capturing outer variables, enforced at compile time, guaranteeing zero closure allocation.Let's confirm you can tell a local function apart from a lambda, and know when each is the right tool.
1. What does adding static to a local function guarantee?
Correct: B
Why B is correct: static on a local function is a compile-time guarantee against capturing — any attempt to reference an outer variable inside it is a compile error, forcing everything it needs to come in as a parameter.
Why A is incorrect: A static local function can be called from anywhere within its containing method's body, regardless of whether that method itself is static.
Why C is incorrect: The performance benefit comes specifically from being unable to capture — allowing capture would defeat the purpose of the guarantee.
Why D is incorrect: A local function, static or not, is a plain method by default — it only becomes a delegate value if explicitly assigned to a Func/Action-typed variable.
Reinforcement: Use static on a local function whenever it should be provably self-contained.
2. Why is a local function generally better suited than a lambda for writing a recursive helper?
Correct: B
Why B is correct: A local function's name is available throughout the containing method the moment it's declared, so it can call itself directly — the natural way to write recursion. A lambda has no name until it's assigned to a variable, and even then, referencing that variable from inside its own initializer is awkward.
Why A is incorrect: Lambdas can contain full statement bodies, including if statements — the limitation is specifically about self-reference, not general logic.
Why C is incorrect: A local function that captures nothing and a lambda assigned once outside a loop have comparable performance; the advantage here is about clean syntax for recursion, not raw speed.
Why D is incorrect: Delegate-based recursive calls do work — it's just awkward to set up cleanly with a lambda, not impossible.
Reinforcement: Natural self-reference is the clearest practical reason to reach for a local function over a lambda.
3. Given static int Square(int x) => x * x; declared as a local function, which statement correctly describes how to use it as a delegate value?
Correct: B
Why B is correct: A local function is a plain method by default. Just like any named method group, it can be converted to a delegate value by assigning it to a variable of a compatible delegate type — the conversion happens explicitly, at that assignment.
Why A is incorrect: Calling Square(5) directly is a plain method call, not a delegate invocation — it only becomes delegate data once you assign it to a delegate-typed variable.
Why C is incorrect: Local functions convert to compatible delegate types perfectly well — this is a normal, supported operation.
Why D is incorrect: No rewriting is necessary — the local function itself, referenced by name, can be assigned directly to a delegate-typed variable.
Reinforcement: A local function only pays the delegate-allocation cost at the point you convert it to a delegate value — direct calls never pay it.
You can now choose confidently between a lambda and a local function. Next: pulling everything together — Action, Func, lambdas, closures, and local functions are all instances of one bigger idea, higher-order functions.
dotnetmadeeasy.com — Learn C# and .NET, the right way.