Delegates in C#: Callbacks, Func, Action, Predicate and Multicast Delegates

Say you run checkout for a Denver outdoor store, and marketing changes the discount every week: 10% off one week, $15 off orders over $100 the next.

Delegates in C# handle that cleanly. A delegate is a type-safe variable that holds a method, so checkout calls whichever rule you plug in at run time, with no if chain to edit.

Quick answer: declare delegate decimal DiscountRule(decimal s);, assign a method or lambda, and call it like a method. Prefer Func and Action when you don’t need a named type.

What Is a Delegate in C#?

A delegate type describes a method signature: a return type and a parameter list. Any static or instance method with a compatible signature can be stored in a variable of that type and called through it later.

Think of it as a variable whose value is one of your C# methods instead of a number or a string. For delegates, the return type is part of the signature, unlike with method overloading.

Here’s the store’s DiscountRule type, filled three ways on an $80 cart (and $120 for the lambda):

using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");

// 1. Method group: the compiler creates the delegate
DiscountRule rule = TenPercentOff;
Console.WriteLine($"Method group: {rule(80m):C}");

// 2. Explicit new (C# 1 syntax, still valid)
rule = new DiscountRule(FlatFiveOff);
Console.WriteLine($"new + method: {rule(80m):C}");

// 3. Lambda expression
rule = s => s >= 100m ? s - 15m : s;
Console.WriteLine($"Lambda:       {rule.Invoke(120m):C}");

static decimal TenPercentOff(decimal s) => s * 0.90m;
static decimal FlatFiveOff(decimal s) => s - 5m;

delegate decimal DiscountRule(decimal subtotal);

Output:

Method group: $72.00
new + method: $75.00
Lambda:       $105.00
Delegates in C#: DiscountRule delegate assigned from a method group, with new, and from a lambda, VS Code terminal output
One delegate type, three ways to fill it: method group, new, and a lambda

TenPercentOff returned $72.00, FlatFiveOff returned $75.00, and the lambda took $15 off $120 for $105.00. You can call rule(80m) or rule.Invoke(80m); they do exactly the same thing.

Way to create itSyntaxWhen I use it
Method grouprule = TenPercentOff;The method already exists and has a name
Explicit newnew DiscountRule(FlatFiveOff)Only in old code; the method group does the same
Lambdarule = s => s - 15m;Short, one-off logic written inline

Note: in a file-based app, the delegate declaration goes after the top-level statements, like any other type. Every delegate type derives from System.MulticastDelegate and is sealed.

Pass a Delegate as a Callback: Swap Discount Rules at Run Time

The real payoff is passing a delegate into a method. Checkout doesn’t know which discount it’s applying; it just calls the DiscountRule it was handed.

Same store, now with three customers and a dictionary that maps promo codes to rules. Carmen has no promo code, so she gets a do-nothing none rule:

using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");

var rules = new Dictionary<string, DiscountRule>
{
    ["SAVE10"] = s => s * 0.90m,
    ["SHIP15"] = s => s >= 100m ? s - 15m : s,
};
DiscountRule none = s => s;

Checkout("Jenna Morales", 89.50m, rules["SAVE10"]);
Checkout("Tyler Brooks", 142.00m, rules["SHIP15"]);
Checkout("Carmen Diaz", 64.25m, none);

static void Checkout(string who, decimal sub,
                     DiscountRule rule) =>
    Console.WriteLine($"{who}: {sub:C} -> {rule(sub):C}");

delegate decimal DiscountRule(decimal subtotal);

Output:

Jenna Morales: $89.50 -> $80.55
Tyler Brooks: $142.00 -> $127.00
Carmen Diaz: $64.25 -> $64.25
C# delegate callback: Checkout method receives a DiscountRule from a promo code dictionary, SAVE10 and SHIP15 applied, VS Code output
Checkout never changes; the dictionary decides which rule it gets

Jenna’s $89.50 cart dropped to $80.55 with SAVE10, Tyler’s $142.00 became $127.00 with SHIP15, and Carmen’s none rule left her at $64.25. The same Checkout method produced all three.

When marketing sends next week’s promo, you add one dictionary line. That’s the pattern Microsoft Learn calls late binding: the caller supplies part of the algorithm.

Func, Action and Predicate: The Built-in Delegates

You rarely need your own delegate type. .NET ships generic ones that cover almost every signature:

DelegateReturnsExampleTypical use
FuncThe last type argumentFunc<decimal, int, decimal>Calculations, LINQ selectors
ActionNothing (void)Action<string>Logging, notifications
PredicateboolPredicate<decimal>List.FindAll, Exists, RemoveAll

Func and Action come in versions that take up to 16 parameters. Same checkout again: four $24.99 items, Austin’s 8.25% tax, and a check for big orders:

using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");

Func<decimal, int, decimal> lineTotal = (p, q) => p * q;
Func<decimal, decimal> tax = s => Math.Round(s * 1.0825m, 2);
Action<string> log = m => Console.WriteLine($"[log] {m}");
Predicate<decimal> isBig = t => t >= 100m;

decimal total = tax(lineTotal(24.99m, 4));
log($"Order total {total:C}");

List<decimal> totals = [89.50m, 142.00m, 64.25m, 210.75m];
List<decimal> big = totals.FindAll(isBig);
log($"Big orders: {string.Join(", ", big)}");
log($"LINQ count: {totals.Count(t => t >= 100m)}");

Output:

[log] Order total $108.21
[log] Big orders: 142.00, 210.75
[log] LINQ count: 2
C# Func, Action and Predicate delegates: line total with tax, log action, FindAll with a predicate, VS Code output
Func calculates, Action logs, Predicate filters the order list

The order came to $108.21, and FindAll on a List in C# returned the two totals of $100 or more. LINQ’s Count takes a Func<decimal, bool> instead, and it also counted 2.

Important: Predicate<T> and Func<T, bool> have the same shape but are different types, so you can’t pass a Predicate variable to Where. Pass a lambda, or isBig.Invoke.

I still write a custom delegate when the name documents intent (DiscountRule reads better than Func<decimal, decimal>), or when I need ref or out parameters.

Multicast Delegates: +=, -= and the Return Value

One delegate can hold several methods in its invocation list. += adds a method, -= removes it, and calling the delegate runs them in the order they were added.

When order 1047 is placed, the store emails the customer, texts them and tells the warehouse:

Action<string>? notify = SendEmail;
notify += SendText;
notify += id => Console.WriteLine($"Warehouse: pick {id}");

notify("order 1047");
int handlers = notify.GetInvocationList().Length;
Console.WriteLine($"Handlers: {handlers}");

notify -= SendText;          // customer turned off texts
notify?.Invoke("order 1048");

Func<decimal> quote = () => 10m;
quote += () => 25m;
Console.WriteLine($"Func returns: {quote()}"); // last wins

static void SendEmail(string id) =>
    Console.WriteLine($"Email: {id} confirmed");
static void SendText(string id) =>
    Console.WriteLine($"Text:  {id} confirmed");

Output:

Email: order 1047 confirmed
Text:  order 1047 confirmed
Warehouse: pick order 1047
Handlers: 3
Email: order 1048 confirmed
Warehouse: pick order 1048
Func returns: 25
C# multicast delegate: Action with email, text and warehouse handlers, GetInvocationList shows 3, -= removes the text handler, VS Code output
Three handlers run in order; after -= only two run; a multicast Func returns the last value

GetInvocationList() reported 3 handlers. After -= SendText, order 1048 got only the email and warehouse lines.

I declared notify as nullable because -= returns null once the last handler is gone. That’s why the second call uses ?.Invoke.

The Func part shows the catch: both lambdas ran, but the caller only got 25, the last method’s return value. Multicast makes sense for void work, not for collecting results.

Pro Tip: if one handler throws, the remaining ones never run and the exception reaches the caller. Loop over GetInvocationList() with a try catch block when every handler must get its turn.

Lambda Expressions and Anonymous Methods as Delegates

Lambdas are the normal way to create delegates today. The older delegate (decimal s) { ... } syntax still compiles; anonymous methods in C# covers it in detail.

Newer C# versions added a few lambda features worth knowing, all on the same $80 cart:

using System.Globalization;
CultureInfo.CurrentCulture = new("en-US");

// Anonymous method (C# 2): same job as a lambda, older syntax
Func<decimal, decimal> save10 = delegate (decimal s) { return s * 0.90m; };

// Natural type (C# 10): var works once the parameter type is written
var addTax = (decimal s) => Math.Round(s * 1.0825m, 2);
Console.WriteLine(addTax.GetType().Name);

// static lambda (C# 9): can't capture locals by accident
Func<decimal, decimal> shipping = static s => s >= 100m ? 0m : 4.99m;

// Explicit return type + default parameter value (C# 10 / C# 12)
var label = string (decimal s, string currency = "USD") => $"{s:F2} {currency}";

Console.WriteLine(label(addTax(save10(80m))));
Console.WriteLine($"Shipping on $64.25: {shipping(64.25m):C}");

Output:

Func`2
77.94 USD
Shipping on $64.25: $4.99
FeatureVersionWhat it does
Anonymous methodC# 2delegate keyword inline; replaced by lambdas
static lambdaC# 9Can’t capture locals or this, so nothing gets captured by accident
Natural typeC# 10var infers Func or Action when parameter types are written
Explicit return typeC# 10string (decimal s) => ... when the return type can’t be inferred
Default parameter valuesC# 12string currency = "USD" inside the lambda’s parameter list

The run printed Func`2 for addTax, because the compiler picked Func<decimal, decimal>. The chained call printed 77.94 USD, and shipping on a $64.25 cart cost $4.99.

A lambda with a default parameter doesn’t map to Func, so the compiler synthesizes its own delegate type for label. C# 14 also lets you add modifiers such as ref without writing parameter types; see what is new in C# 14.

Delegates vs Events in C#: What’s the Difference?

An event is a delegate field with a guard around it. Outside the class, code can only += and -= handlers; it can’t call the event or replace the whole list. Events in C# walks through the full publisher and subscriber pattern.

Public delegate fieldEvent
Subscribe with += / -=YesYes
Overwrite with =Yes, any caller can wipe every handlerNo (CS0070)
Invoke from outside the classYesNo, only the owning class raises it
Use it forCallbacks you pass into a methodNotifications to any number of listeners

My rule: if a method needs behavior to do its job, take a delegate parameter. If an object announces that something happened, expose an event.

Delegate or Interface: Which Should You Use?

Both let a caller plug in behavior. An interface in C# is the better fit once the behavior grows past one method:

Pick a delegate whenPick an interface when
The contract is one methodSeveral related methods belong together
Callers pass a lambda or a static methodThe implementation needs its own state and setup
You may combine handlers (multicast)A class implements it once, as part of its identity
You use it as an eventYou resolve it through dependency injection

In the store app, DiscountRule stays a delegate. A payment gateway with Authorize, Capture and Refund becomes an interface. The OOP concepts in C# guide covers where interfaces fit in a class design.

Common Delegate Errors and How to Fix Them

I broke the store code on purpose to show the three compiler errors I see most with delegates:

DiscountRule rule = HalfOff;   // int, not decimal
var tax = s => s * 1.0825m;    // no type to infer
var cart = new Cart();
cart.Changed = null;           // event, not field

static decimal HalfOff(int subtotal) => subtotal / 2m;

delegate decimal DiscountRule(decimal subtotal);

class Cart
{
    public event Action? Changed;
    public void Add() => Changed?.Invoke();
}

Build output:

C:\DotNetDemo\DelegatesDemo\Errors.cs(1,21): error CS0123: No overload for 'HalfOff' matches delegate 'DiscountRule'
C:\DotNetDemo\DelegatesDemo\Errors.cs(2,11): error CS8917: The delegate type could not be inferred.
C:\DotNetDemo\DelegatesDemo\Errors.cs(4,6): error CS0070: The event 'Cart.Changed' can only appear on the left hand side of += or -= (except when used from within the type 'Cart')

The build failed. Fix the build errors and run again.
C# delegate compiler errors CS0123 no overload matches delegate, CS8917 delegate type could not be inferred, CS0070 event, VS Code terminal
Wrong signature, a lambda with no type to infer, and an event assigned from outside its class
ErrorCauseFix
CS0123The method’s parameters don’t match the delegateChange int to decimal, or wrap it in a lambda
CS8917var on a lambda without parameter typesWrite (decimal s) => or declare Func<decimal, decimal>
CS0070Assigning or invoking an event outside its classUse += / -=, or raise it inside the class
NullReferenceExceptionInvoking a delegate that has no methodsCall rule?.Invoke(s)

An empty delegate variable is just null, which is why the Cart class raises its event with ?.Invoke. Checking for null values in C# covers the other null patterns.

Which Delegate Type Should You Use?

  • Func when the code returns a value, Action when it doesn’t.
  • Predicate only for List and Array methods that ask for one.
  • A named custom delegate when the name explains the role or you need ref/out.
  • An event when other objects subscribe to notifications.
  • An interface when the behavior is several methods with shared state.

Once delegates click, these four posts on the store’s other pieces are next:

Frequently Asked Questions

What is a delegate in C# in simple terms?

A delegate is a type that holds a reference to a method with a specific parameter list and return type. You can store it in a variable, pass it to another method and call it later, which makes it ideal for callbacks.

What is the difference between Func and Action in C#?

Func returns a value, and its last type argument is the return type. Action returns nothing. Both have versions with up to 16 parameters.

What does a multicast delegate return?

It calls every method in its invocation list in order and returns the value of the last one. The earlier return values are lost, so multicast is best for methods that return void.

Are lambdas and delegates the same thing?

No. A lambda is syntax for an anonymous function, and the compiler converts it to a delegate type such as Func or Action (or to an expression tree). The delegate is the object you call.

Why use a delegate instead of calling the method directly?

A delegate lets the caller decide which method runs, so code like a checkout or a sort routine stays the same while the rule changes. The Using Delegates guide on Microsoft Learn shows the same callback pattern.

Is an event a delegate in C#?

An event is built on a delegate type, but it limits outside code to += and -=. Only the declaring class can raise it or reset its handler list.