C# Local Function: Syntax, Use Cases, and Performance
Learn how C# local functions work, when they are a better choice than lambda expressions, and how they affect performance and code clarity.
In C# 7.0, local functions were introduced: methods declared inside the body of another method. They give you a way to define helper logic exactly where it is used, without adding private methods to the class. A C# local function can capture variables from the enclosing method, call itself recursively, and even be declared after the code that calls it. This article covers the syntax, the differences from lambda expressions, and the performance implications to consider before using them.
Declaring a Local Function
A local function uses method-like syntax and appears inside another method body. Unlike a regular method, it cannot have an access modifier such as private or public. Here is a minimal example:
public void ProcessOrder(Order order) { decimal CalculateTotal(Order o) { return o.Items.Sum(item => item.Price * item.Quantity); } decimal total = CalculateTotal(order); // Continue processing with total }
The local function CalculateTotal is only visible within ProcessOrder. It can access parameters and local variables of the enclosing method. One useful detail is that a local function can be called before its declaration because the compiler hoists the function to the method level. This lets you place the call first and the definition later, which can improve readability when the call is the primary flow.
Local Functions vs. Lambda Expressions
Local functions and lambda expressions both let you define inline logic, but they differ in several important ways. The table below summarizes the key distinctions:
| Feature | Local Function | Lambda Expression |
|---|---|---|
| Syntax | Named method declaration | Anonymous expression or statement |
| Naming | Has an explicit name | No name, assigned to a delegate or expression tree |
| Recursion | Can call itself directly by name | Requires assigning to a delegate variable first |
| Attributes | Can have attributes in C# 9+ | Cannot have attributes |
| Allocation | No delegate instance unless explicitly converted to a delegate | Typically allocates a delegate instance |
Because a local function is named, it can be used for recursion without the awkward pattern of declaring a delegate and then assigning it. For example, inside a method:
int Factorial(int n) { return n <= 1 ? 1 : n * Factorial(n - 1); }
With a lambda, the same recursion requires a separate delegate variable:
Func<int, int> factorial = null; factorial = n => n <= 1 ? 1 : n * factorial(n - 1);
The local function version is clearer and avoids the null initialization.
Capturing Variables and Closure Behavior
Like lambdas, local functions can capture variables from the enclosing scope. When a local function captures a variable, the compiler creates a closure to hold that variable. The exact allocation behavior depends on how the variable is used and the compiler version. In many cases, the compiler can represent the closure as a struct and avoid heap allocation. However, if the local function is converted to a delegate, the closure is typically boxed to a reference type.
When a local function does not capture any variables, it can be emitted as a static method, so calls do not require closure allocation. This can be a useful advantage over lambdas when the logic is pure and only uses its parameters.
Starting with C# 8, you can also declare a local function as static to make that guarantee explicit and get a compile-time error if the function tries to capture from the enclosing method.
Performance Characteristics
The JIT can often inline a local function because both the call site and the function body are available during compilation. Inlining eliminates the method-call overhead and can enable further optimizations. A lambda converted to a delegate, on the other hand, typically requires a delegate instance and a call through Invoke; even a cached delegate may not be inlined in all cases. The difference is most noticeable on hot paths where a helper is called repeatedly.
Consider this example:
public void ProcessItems(List<Item> items) { bool IsValid(Item item) { return item.Quantity > 0 && item.Price >= 0; } foreach (var item in items) { if (IsValid(item)) { // Process item } } }
Here, IsValid does not capture any variables, so the compiler can emit it as a static method. The JIT can often inline the call, avoiding per-iteration delegate allocation and call overhead. If you wrote the same logic as a Func<Item, bool> lambda, the compiler would normally allocate or cache a delegate; caching is possible but not guaranteed in every context.
Practical Use Cases
Local functions shine in a few specific scenarios:
- Validation logic that is only needed in one method. Placing it as a local function keeps the method self-contained and avoids polluting the class with a private method that is never used elsewhere.
- Iterator methods that use
yield. A common pattern is to validate arguments eagerly and then delegate to a local iterator function. For example:
public IEnumerable<int> GetNumbers(int start, int end) { if (start > end) throw new ArgumentException("Start must be less than or equal to end."); return GetNumbersCore(); IEnumerable<int> GetNumbersCore() { for (int i = start; i <= end; i++) yield return i; } }
- Async methods where you need a small helper to avoid duplicating
awaitlogic. A local function can beasyncand is declared with the sameasyncmodifier. - Recursive algorithms where the recursion is only relevant inside a single method. The named function makes the recursion explicit and avoids the delegate assignment dance.
Common Pitfalls and Limitations
Local functions have a few limitations to keep in mind:
- They are not accessible outside the enclosing method. If the same helper is needed in multiple methods, promote it to a private method, or to a private static method when it does not use instance state.
- A local function that is an iterator (contains
yield) does not execute its body when called; it returns anIEnumerable<T>that must be enumerated. This is the same behavior as a regular iterator method. - Local functions can have
refandoutparameters, but an iterator containingyieldcannot usereforoutparameters. The compiler rejects that combination. - If you need to pass a local function as a delegate to another method, you must convert it explicitly. The conversion can allocate a delegate, which may offset the performance benefit; in that case, a lambda may be equally efficient.
Choosing Between Local Functions and Other Approaches
Use a local function when the helper is specific to one method and you want the benefits of a named method: recursion, attributes (C# 9+), or the potential for inlining without delegate allocation. Use a lambda when you need to pass the logic as a delegate to another API, such as LINQ methods or event handlers. For a one-off helper that does not need to be reused, a local function often leads to cleaner code than a lambda because it has a descriptive name and can be placed after the call site.
If the helper is used in several methods, promote it to a private method or a private static method. If it is only used in one method but is long and complex, a local function can still be appropriate, but you should consider whether extracting it to a separate method improves testability. Local functions are not directly unit-testable, so if the logic is non-trivial and worth testing independently, a private method is a better choice.