Last Updated: May 17, 2026
A local function is a method declared inside the body of another method, visible only to that containing method. C# 7 added them so you can stash small helpers next to the code that uses them, without polluting the class with private methods that no other method should ever call. This chapter covers the syntax, how local functions capture variables from the enclosing scope, the static variant that opts out of capture, and the trade-offs against private methods and lambdas.
Picture a method called ProcessOrder that has to do four small jobs: compute a discount, calculate tax, build a printed line for each item, and assemble a total. None of those four jobs is useful anywhere else in the class. They're so specific to ProcessOrder that exposing them as private methods would be misleading. A local function lets you write them inline, scoped to the one method that needs them.
ApplyDiscount and CalculateTax are local functions. They live inside ProcessOrder and can be called only from inside ProcessOrder. From the rest of the class, they don't exist. Try to call ApplyDiscount from another method in the same file and the compiler reports CS0103 ("the name ApplyDiscount does not exist in the current context").
A local function has the same shape as a regular method: return type, name, parameter list, body. It can use expression-bodied syntax (=>) for one-liners or a full block for longer code. What changes is where it lives and who can see it.
The reason to reach for one is scope. A private method advertises to every other method in the class "you can call me." A local function says "only this one method calls me, and I want to keep it that way." When a helper is so tightly coupled to its parent that calling it from anywhere else would be a bug, a local function makes that intent explicit.
A local function can sit anywhere inside its containing method's body. Top, middle, or bottom. The placement doesn't change behavior, only readability. Most C# developers put helpers at the bottom of the method, after the main logic, so the reader sees the "what" first and the "how" only if they want to:
The main logic of PrintReceipt reads top to bottom in plain English. BuildLineItem sits at the bottom, out of the way. If you only care about what the method does, you stop reading after the foreach loop. If you want to see how a line is formatted, you scroll one inch.
Local functions can also be declared async, take params, have default parameter values, and use generic type parameters. They support everything a regular method supports, except access modifiers (no public or private) because the scope is fixed at "this method only."
The Capitalize helper is a one-liner used only by FormatProductName. Keeping it local makes the intent obvious: don't call this from anywhere else.
Inside the containing method, a local function can be called from a line that appears before its declaration. The compiler hoists local function declarations to the top of the scope, so order doesn't matter for visibility. This is different from a local variable, where the name has to be declared before use.
Both ApplyDiscount and CalculateTax are used on the first line of the body, but they're declared underneath. The code compiles and runs fine. Most C# developers lean on this so the "story" of the method reads top down and the "appendix" of helpers sits at the bottom.
Compare that to a local variable, where forward references don't work:
The compiler treats methods and variables differently for this reason. Methods are members of a scope and behave like declarations; variables have a strict lexical order of definition.
A local function can read and write local variables and parameters of its containing method. The compiler arranges for the function to share those variables with the outer code, not a copy. If the local function changes a captured variable, the outer method sees the change. If the outer method changes it, the local function sees the change too.
AddItem doesn't take runningTotal as a parameter. It reaches up into the enclosing method and modifies the variable directly. When ComputeCartTotal returns runningTotal, the changes made by every AddItem call are visible. This is closure-like behavior, similar to what lambda expressions do, but applied to a named local function.
Captures aren't limited to one variable. A local function can capture parameters, locals declared above it, and even locals declared below it (forward references work for captures too, as long as the variable is in scope when the function actually runs).
AddToSummary captures both subtotal and itemCount. Both get mutated through the local function and the outer code sees the final values.
Capture sounds free, but the compiler is doing real work behind the scenes. To share variables between the outer method and the local function, the compiler generates a hidden helper type called a "display class" (sometimes called a closure class). The captured variables move off the stack and onto a heap-allocated instance of that class. Every call to the containing method allocates one of these.
Cost: A non-static local function that captures variables causes the compiler to generate a display class on the heap. Each call to the containing method allocates one instance. In a hot loop, that allocation adds up and gives the garbage collector more work. If you're not actually capturing anything, declare the local function static to skip the display class entirely.
That cost rarely matters in normal code. A typical web request processes a few cart operations and the allocation noise is invisible. In tight loops, batch processing, or hot paths in a service, it can matter, which is exactly why C# 8 added a way to opt out.
static Local FunctionsSince C# 8, you can mark a local function with static. A static local function cannot capture anything from the enclosing scope. The compiler enforces it: try to read an outer local or parameter and the build fails with CS8421 ("a static local function cannot contain a reference to ...").
Tax is declared static and takes everything it needs through parameters. No display class is generated, no heap allocation per call. The compiler treats it like a normal private static method that just happens to live inside another method's scope.
Try to capture and you get a compile error:
What's wrong with this code?
Tax is marked static but tries to read taxRate from the enclosing method. The compiler refuses. Either drop the static modifier and accept the capture cost, or pass taxRate as a parameter.
Fix:
Marking the function static is a deliberate constraint. It says "this helper is pure, it depends only on its inputs, and I don't want a future edit to accidentally capture something and slow this code down." Most teams default to static for local functions and only drop it when they actually need to share state with the enclosing method.
The diagram below shows what happens at compile time when a local function captures versus when it's static.
The decision point is whether the function reaches into outer scope. If it does, the compiler builds a small wrapper class so both the outer method and the local function can see the same variables, and that class lives on the heap. If it doesn't, the local function is just a static helper with private visibility, no extra machinery.
You can confirm this by looking at the IL or by using a tool like SharpLab. A non-static, capturing local function compiles to a nested class with fields for each captured variable. A static local function compiles to a plain method on the containing class with a compiler-generated name.
A local function can call itself. The forward-reference rule means the recursive call works without any special syntax. This is useful when the recursion is genuinely a private detail of one method.
A common case is a cart with bundles: an item can be a regular product, or it can be a bundle that contains other items (which themselves might be bundles). Computing the total of a top-level cart entry means walking the tree.
WalkItem is recursive: it adds the current item's price, then recurses into each child. Marking it static keeps the recursion allocation-free. Each call uses a fresh stack frame for WalkItem, but no display class is ever allocated. For a cart with a few dozen items, that recursion is essentially free.
Cost: Recursion depth matters more than allocation for tree walks. A cart with deeply nested bundles will eventually blow the stack (default stack size is 1 MB on most platforms, roughly 10,000-50,000 frames depending on locals). For trees of unknown depth coming from user input, prefer an explicit Stack<T> and a loop.
Compare this to writing WalkItem as a private method on the class. It would work, but it would also advertise itself to every other method in the class. Keeping it local is honest: nothing else should use this walker, because it makes assumptions specific to the bundle layout used by ComputeBundleTotal.
A non-recursive helper inside the same method can use the recursive one. Both stay local:
PrintItem is recursive and static, taking indent as a parameter rather than capturing a mutable counter. That's a clean style: pass state through parameters so each recursion frame is self-contained.
For now, a short comparison is enough to know which tool to pick.
A lambda expression produces a delegate object. You can store it in a variable, pass it to another method, return it from a method, or stick it in a field. A local function is just a named, scoped method. You cannot store it in a variable or pass it as an argument unless you wrap it in a delegate (which then allocates the delegate object you were trying to avoid).
Both compute the same answer. The difference is in what the compiler emits:
Func<decimal, decimal> delegate. Calling it has the overhead of an indirect call through the delegate. If the lambda captures anything, the delegate also wraps a display class.static and doesn't capture, there's no allocation at all.When to pick which:
| Need | Pick |
|---|---|
| Pass the function as an argument to LINQ or another API | Lambda |
| Store the function in a field or return it from a method | Lambda |
| Just want a private helper inside one method | Local function |
| Want recursion | Local function (lambdas can't easily self-reference) |
Want out, ref, or in parameters | Local function (lambdas don't support these) |
| Care about avoiding the delegate allocation | Local function (especially static) |
The recursion point is worth a quick demo. A local function can call itself by name. A lambda cannot, at least not without a clumsy workaround:
A lambda assigned to a variable can't reference its own variable while it's being initialized (the variable isn't assigned yet at the point of the lambda body). You'd have to declare the variable first, assign null!, then assign the lambda, which is awkward and forces you to use a nullable type. Local functions make recursion natural.
A private method on a class is callable from any method on that class. A local function is callable only from inside its one containing method. The choice between them comes down to how widely the helper should be visible.
CalculateTax is a private method because two methods on the class (Process and QuickTaxQuote) both call it. Promoting it to a class member makes sense.
ApplyDiscount is local because only Process uses it. Making it private would invite any future method on the class to use it, even though the discount logic was designed specifically for the order-processing flow. Keeping it local prevents that drift.
The decision flow:
The first question is sharing: if more than one method needs the helper, it stops being local. The second is whether the helper is a value (passed around, stored, returned). If yes, a lambda or method group is the right tool. If it's just a scoped routine, a local function fits. The third question, only relevant once you've chosen a local function, is whether it needs outer state. Most of the time the answer is no, and static is the better default.
The three options exist for different reasons. The table sums up the practical differences:
| Property | Local function | Private method | Lambda |
|---|---|---|---|
| Visibility | Containing method only | Whole class | Wherever the delegate goes |
| First-class value? | No | No | Yes (delegate object) |
| Captures outer scope? | Yes (non-static only) | No (uses fields instead) | Yes |
| Allocation on definition | None (or display class if capturing) | None | Delegate object (and display class if capturing) |
Supports ref/out/in params | Yes | Yes | No |
| Supports recursion by name | Yes | Yes | Awkward |
| Forward references inside scope | Yes | N/A | N/A |
| Best for | One-method-only helpers | Shared class helpers | Callbacks, predicates, projections |
Most teams use all three in different places. A class typically has a handful of private methods for genuinely shared behavior, a sprinkle of local functions inside the larger methods to keep them readable, and lambdas where APIs like LINQ ask for them.
A local function supports a surprising amount of the regular method surface. The full list, beyond plain return type and parameters, includes:
Task, Task<T>, or ValueTask. Useful when one method needs an internal async helper that nothing else should call. (Async details come later in the course.)ref struct. Lambdas don't support these, which is one of the bigger reasons to pick a local function over a lambda.What you cannot do: access modifiers (public, private, internal, protected) are not allowed because the scope is implicit. Local functions are also not virtual, sealed, or abstract; those concepts only make sense for class members.
A generic local function example:
IndexOfFirstMatch<T> is generic and static. It can be reused inside FindFirstAvailable for different array types if needed, but the rest of the class still can't see it.
An example using out:
Lambdas cannot declare out parameters at all, so if your helper needs the try-pattern with out, a local function is the only inline option short of a private method.
A small ProcessOrder method that pulls together every idea from the chapter: local functions for ApplyDiscount, CalculateTax, and BuildLineItem, a static helper that doesn't capture, a non-static helper that mutates a running total, and a recursive walker for nested bundles.
A few things worth pointing out:
AddToSubtotal is the only non-static helper. It needs to mutate subtotal and totalUnits, which only works through capture. The display class that wraps those two variables is allocated once per ProcessOrder call, not once per item.ApplyDiscount, CalculateTax, SumBundle, CountUnits, and BuildLineItem are all static. The recursion in SumBundle, CountUnits, and BuildLineItem uses stack frames but allocates nothing extra.ProcessOrder. The class can have a dozen other methods that all do different things, and none of them can accidentally call BuildLineItem and assume it works on their data.That's the value of local functions in one place: a long method gets broken into small named pieces without leaking those pieces to the rest of the class.
static local function (C# 8+) cannot capture anything. The compiler enforces this with CS8421. No display class is generated, no per-call allocation. Default to static unless you actually need capture.ref, out, in, params, defaults, generics, and async. They do not allow access modifiers (public, private, etc.) because their visibility is fixed.static recursive function adds zero heap allocation.That wraps up the Methods section. Up to this point, the building blocks have been values, control flow, arrays, strings, and methods, all the pieces of a single procedural program. The next section, Object-Oriented Programming, shifts the focus from "what does this code do" to "what kinds of things does this code model." You'll start defining your own types with classes, give them state through fields and properties, and control how they're created with constructors. The local functions, parameters, and recursion you've just learned will all show up again as the building blocks of methods on those classes.