C# Func and Action Delegates: Pitfalls and Call Cost

Func vs Action, why out parameters and Predicate<T> do not fit, three multicast traps, and measured call cost vs interfaces.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled it on .NET 10.0.14 (C# 14). The Func example's code block had 0.1f split into 0.1 f, which produced four compile errors (error CS1002). The original also called Func and Action "pre-declared delegate variables"; they are types, not variables. Below are the fixed code, what Func cannot do, three multicast traps, and a measurement of call cost.

You use Func<T, bool> every day in LINQ and callbacks, but when should you declare your own delegate instead, and what really happens when you chain them with +=? In short: Func and Action are generic delegate types that .NET ships for you, and most code never needs a custom delegate. They fall short in three places: out/ref parameters, public APIs where a descriptive name helps the reader, and multicast return values. If lambdas themselves are new to you, start with C# lambda expressions.

Func and Action are types, not variables

To use a lambda or anonymous method you need a delegate variable to hold it, and before that you need a delegate type. Declaring a new delegate type for every signature gets tedious, so .NET declares two families of generic delegate types in the System namespace. In Func<int, float> func1, the variable is func1; Func<int, float> is the type.

  • Func refers to a method that returns a value. Its last type argument is the return type.
  • Action refers to a method that returns void.

We counted them with reflection:

var core = typeof(Func<>).Assembly;   // System.Private.CoreLib
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && t.Name.StartsWith("Func`")));
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && (t.Name == "Action" || t.Name.StartsWith("Action`"))));
17
17

That is 0 to 16 parameters for each. If you ever need 17, the method signature is the problem.

The Func example: one space breaks the build

Parameter types come first, the return type last:

Func<float> func0 = () => 0.1f;                          // no parameters, returns float
Func<int, float> func1 = (a) => a * 0.1f;                // one int
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;  // two ints
Func<int, int, int, float> func3 = (a, b, c) => (a + b + c) * 0.1f;

In the 2021 article's full example, 0.1f had become 0.1 f and Func<int, float> was spread over several lines as Func < int, / float >. Whitespace inside generic brackets is harmless, but a numeric suffix must touch its number:

Program.cs(4,37): error CS1002: ; expected
Program.cs(7,46): error CS1002: ; expected
Program.cs(9,44): error CS1002: ; expected
Program.cs(12,53): error CS1002: ; expected

The fixed and tidied version. To reference an ordinary method, you can drop new Func<…>(temp) and just write the method name (method group conversion):

static float Temp(int a, int b, int c) => (a + b + c) * 0.1f;

Func<float> func0 = () => 0.1f;
Func<int, float> func1 = a => a * 0.1f;
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;
Func<int, int, int, float> func3 = Temp;      // ordinary method

Console.WriteLine("func0 returned: {0}", func0());
Console.WriteLine("func1 returned: {0}", func1(10));
Console.WriteLine("func2 returned: {0}", func2(10, 10));
Console.WriteLine("func3 returned: {0}", func3(10, 10, 10));
func0 returned: 0.1
func1 returned: 1
func2 returned: 2
func3 returned: 3

A side note: switch the same expression to double and the output changes. With Func<int, double> d = a => a * 0.1;, d(3) printed 0.30000000000000004, while the float version func1(3) printed 0.3. That is not because float is more precise; since .NET Core 3.0, ToString() prints the shortest string that round-trips for each type. For money, use decimal, not either of them.

The Action example

Action is Func without a return value. The 2021 Action example worked apart from the missing using System; (which .NET 6+ projects supply through implicit usings), and printed the same output:

static void Temp(string name) => Console.WriteLine("name: {0}", name);

int sum = 0;
Action act0 = () => Console.WriteLine("name: act0");
Action<string> act1 = Temp;                       // ordinary method
Action<string, string> act2 = (name, age) =>
{
    Console.WriteLine("name: {0}", name);
    Console.WriteLine("age: {0}", age);
};
Action<int, int, int> act3 = (a, b, c) => sum = a + b + c;   // captures the outer sum

act0();
act1("act1");
act2("act2", "35");
act3(1, 2, 3);
Console.WriteLine("sum: {0}", sum);
name: act0
name: act1
name: act2
age: 35
sum: 6

act3 has no return value, so it hands its result back by writing to the captured variable sum. What that capture costs is measured in C# lambda expressions.

What Func cannot do: out/ref, and Predicate

out parameters

Func and Action type arguments cannot be ref or out, so storing int.TryParse fails:

Func<string, int, bool> tp = int.TryParse;
error CS0123: No overload for 'TryParse' matches delegate 'Func<string, int, bool>'

This is when you declare your own delegate:

delegate bool TryParser<T>(string s, out T value);

TryParser<int> tp = int.TryParse;
Console.WriteLine(tp("123", out var v) + " " + v);   // True 123

Same signature, different type

Predicate<int> and Func<int, bool> have identical signatures but are different types, so one does not convert to the other. You meet this constantly because List<T>.FindAll takes a Predicate<T> while LINQ's Where takes a Func<T, bool>:

Predicate<int> isEven = n => n % 2 == 0;
Func<int, bool> f = isEven;
error CS0029: Cannot implicitly convert type 'System.Predicate<int>' to 'System.Func<int, bool>'

Wrap it with the Invoke method group or new Func<int, bool>(isEven):

Func<int, bool> f = isEven.Invoke;
var list = new List<int> { 1, 2, 3, 4 };
Console.WriteLine(string.Join(",", list.FindAll(isEven)) + " / " + string.Join(",", list.Where(f)));
// 2,4 / 2,4

Three multicast traps

Delegates can chain several methods with +=, which is handy for event-like Actions. Running it shows some surprising behaviour:

// 1. A chained Func returns only the last value
Func<int> multi = () => { Console.Write("A "); return 1; };
multi += () => { Console.Write("B "); return 2; };
Console.WriteLine("=> " + multi());

// 2. An exception stops the rest of the chain
Action chain = () => Console.Write("first ");
chain += () => throw new InvalidOperationException("boom");
chain += () => Console.Write("third ");
try { chain(); } catch (Exception e) { Console.WriteLine("| caught: " + e.Message); }

// 3. -= with a lambda removes nothing
Action log = () => Console.Write("log ");
log += () => Console.Write("tick ");
log -= () => Console.Write("tick ");
log(); Console.WriteLine($"| count={log.GetInvocationList().Length}");
A B => 2
first | caught: boom
log tick | count=2
  1. Both methods ran, but the call returned only 2. To collect each value, call them one by one: foreach (Func<int> d in multi.GetInvocationList()) d(); printed A 1 B 2.
  2. The second method threw, so third never ran. If one subscriber must not block the others, iterate GetInvocationList() and wrap each call in try.
  3. Two identical-looking lambdas are two different delegate instances, so nothing was removed. Store the lambda in a variable and use that same instance for += and -= (we then got count=1). Method groups (m -= Hello) compare by method and target, so removing them works.

Also, with nullable reference types enabled, -= triggers warning CS8601: Possible null reference assignment. Removing the last method leaves the delegate null, which is why you call it as log?.Invoke(). Invoking a null delegate directly throws NullReferenceException.

Call cost: direct, interface, delegate (measured)

To test the "delegates are slow" claim, we called a method computing x * 2 200 million times. The direct call is marked [MethodImpl(MethodImplOptions.NoInlining)] so the JIT cannot inline it away.

static long RunFunc(Func<int, int> f, int n) { long s = 0; for (int i = 0; i < n; i++) s += f(i); return s; }
// RunDirect: Calc.TwiceNoInline(i), RunIface: IOp.Apply(i), RunCustom: delegate int Op(int x)
Func<int, int> func = Calc.Twice;        // Func pointing at a static method
Func<int, int> lam  = x => x * 2;        // lambda

.NET 10.0.14, Release, Apple M1 Pro, 200 million calls, three runs:

Call Run 1 Run 2 Run 3 Per call
direct (no inlining) 222.5 ms 221.8 ms 223.8 ms 1.11 ns
interface 302.3 ms 303.1 ms 303.1 ms 1.51 ns
Func to static method 312.0 ms 312.6 ms 311.6 ms 1.56 ns
Func to lambda 265.9 ms 262.9 ms 263.0 ms 1.31 ns
custom delegate to static method 315.3 ms 312.5 ms 312.1 ms 1.56 ns

A delegate call cost about 0.2–0.45 ns more than a direct call, roughly the same as an interface call. Func and a custom delegate type performed identically. The interesting part: the lambda was faster than a delegate to the equivalent static method. The compiler emits lambdas as instance methods, and a delegate bound to a static method is known to need an extra argument-shuffling step on each call. Either way the gap is tens of milliseconds per 200 million calls, so choose for readability, not speed.

When to use Func/Action and when to declare your own

Situation Use
A method takes a callback or condition Func/Action (the LINQ convention)
You need out/ref/in parameters a custom delegate
Public library API where the name should explain the role a custom delegate (e.g. delegate decimal PriceRule(Order order))
Events event EventHandler<T> or event Action<T>, invoked with ?.Invoke
You need every subscriber's return value iterate GetInvocationList()
Several related methods plus state an interface instead of delegates
List<T>.FindAll, Array.Find Predicate<T> (not assignable to or from Func)

For lambda syntax when assigning to Func (natural types, default parameters), see C# lambda expressions; for the API that takes the most Funcs, see LINQ basics.

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on Apple M1 Pro.

FAQ

What is the difference between Func and Action?

Func is for methods that return a value, and its last type argument is the return type (Func<int, float> takes an int and returns a float). Action is for methods that return void. Counting on .NET 10, there are 17 of each, for 0 to 16 parameters.

Can a Func hold a method with out or ref parameters?

No. Func<string, int, bool> tp = int.TryParse; fails with error CS0123. Declare your own delegate, for example delegate bool TryParser<T>(string s, out T value);. Since C# 10, a lambda with an out parameter assigned to var gets a compiler-generated anonymous delegate type.

What does a multicast Func return?

Every method in the chain runs, but only the last return value survives. With two methods returning 1 and 2, the call returned 2. To get every result, call each delegate from GetInvocationList().

  • #C#
  • #Func
  • #Action
  • #delegates
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑