Free Handbook · Every example compiled & verified

LINQ

Delegates and lambdas first, then LINQ: Where, Select, OrderBy, GroupBy, Join and Aggregate, deferred execution, and IQueryable versus IEnumerable.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 09 · what you'll be able to do

  • Store behaviour in Func, Action and Predicate variables and write lambdas that capture local variables
  • Write the same query in query syntax and method syntax and translate between them
  • Filter, project, sort, group, join and aggregate in-memory data with LINQ operators
  • Choose between First, FirstOrDefault, Single and Any without surprise exceptions
  • Explain deferred execution and why IQueryable queries become SQL while IEnumerable ones run in memory
01

Delegates, Func, Action and lambdas

LINQ is built on one idea: passing a piece of code into a method. A delegate is a type-safe reference to a method. You rarely declare your own, because .NET ships generic ones: Func<T, TResult> takes arguments and returns a value (the last type argument is the return type), Action<T> takes arguments and returns nothing, and Predicate<T> is a Func<T, bool> by another name. A lambda — x => x * x — is the short way to write the method on the spot.

C#Program.cs
Func<int, int> square = x => x * x;
Func<int, int, int> add = (a, b) => a + b;
Action<string> shout = msg => Console.WriteLine(msg.ToUpper() + "!");
Predicate<int> isEven = n => n % 2 == 0;

Console.WriteLine(square(5));
Console.WriteLine(add(2, 3));
shout("hello");
Console.WriteLine(isEven(7));

// a method group works anywhere a matching delegate is expected
Func<string, int> parse = int.Parse;
Console.WriteLine(parse("42") + 1);

Console.WriteLine(Apply(10, square));
Console.WriteLine(Apply(10, n => n - 1));

static int Apply(int value, Func<int, int> f) => f(value);
Outputcompiled & run with real C#
25
5
HELLO!
False
43
100
9
Your turn

Write a Func<string, bool> called isLong that is true for strings longer than 5 characters, and pass it into a method CountWhere(string[] words, Func<string, bool> test).

Closures: lambdas remember variables

C#Program.cs
var counter = MakeCounter();
Console.WriteLine(counter());
Console.WriteLine(counter());
Console.WriteLine(counter());

int threshold = 10;
Func<int, bool> big = n => n > threshold;
threshold = 100;                 // the lambda sees the NEW value
Console.WriteLine(big(50));

static Func<int> MakeCounter()
{
    int count = 0;
    return () => ++count;        // count lives on after MakeCounter returns
}
Outputcompiled & run with real C#
1
2
3
False

A lambda captures the variable itself, not a copy of its value. That is why big(50) compares against 100.

Error you will hit

CS1593: calling a delegate with the wrong number of arguments

C#
Func<int, int> square = x => x * x;
Console.WriteLine(square(3, 4));
Program.cs(2,19): error CS1593: Delegate 'Func<int, int>' does not take 2 arguments
Why the compiler said that

Func<int, int> is one int in, one int out. The last type argument is always the return type, so it takes a single argument. Beginners often read it as "two ints in".

The fix

Use Func<int, int, int> for two inputs and one output.

C#
Func<int, int, int> multiply = (a, b) => a * b;
Console.WriteLine(multiply(3, 4));
Error you will hit

CS8917: var with a lambda whose parameter type is unknown

C#
var doubler = x => x * 2;
Console.WriteLine(doubler(4));
Program.cs(1,15): error CS8917: The delegate type could not be inferred.
Why the compiler said that

Since C# 10 a lambda can have a "natural type" so var works — but only if the compiler can see the parameter types. Here x could be anything.

The fix

Type the parameter in the lambda, or declare the variable with an explicit Func.

C#
var doubler = (int x) => x * 2;        // Func<int, int>
Console.WriteLine(doubler(4));
02

Query syntax versus method syntax

LINQ (Language Integrated Query) is a set of extension methods on IEnumerable<T> in System.Linq — already imported by the implicit usings of a modern project. You can call them directly (method syntax) or use SQL-like keywords (query syntax), which the compiler rewrites into exactly the same method calls.

C#Program.cs
int[] nums = { 5, 12, 8, 3, 20, 7 };

// query syntax
var q1 = from n in nums
         where n > 6
         orderby n descending
         select n * 10;

// method syntax: what the compiler turns q1 into
var q2 = nums.Where(n => n > 6)
             .OrderByDescending(n => n)
             .Select(n => n * 10);

Console.WriteLine(string.Join(" ", q1));
Console.WriteLine(string.Join(" ", q2));
Outputcompiled & run with real C#
200 120 80 70
200 120 80 70
Your turn

Change both queries to keep only odd numbers and sort ascending.

Query syntax

  • Reads like SQL: from … where … select
  • Nicer for joins and let (naming an intermediate value)
  • Must end in select or group
  • Has no keyword for Count, First, Take, Distinct — you mix in method calls

Method syntax

  • Plain method calls chained with dots
  • Every operator is available
  • What most teams write day to day
  • What you see in stack traces and the debugger
03

Filtering, projecting and sorting

Where keeps items that pass a test. Select turns each item into something else — often a smaller shape, an anonymous type (new { p.Name, p.Price }) or a record. OrderBy/OrderByDescending sort by a key and ThenBy breaks ties. Skip and Take page through the result; Distinct removes duplicates.

C#Program.cs
var products = new List<Product>
{
    new("Laptop", "Tech", 1200m),
    new("Mouse", "Tech", 25m),
    new("Desk", "Home", 300m),
    new("Lamp", "Home", 45m),
    new("Monitor", "Tech", 300m),
};

var affordable = products
    .Where(p => p.Price < 500m)
    .OrderByDescending(p => p.Price)
    .ThenBy(p => p.Name)
    .Select(p => new { p.Name, Label = $"{p.Price:0.00}" });

foreach (var item in affordable)
    Console.WriteLine($"{item.Name,-8} {item.Label}");

var categories = products.Select(p => p.Category).Distinct();
Console.WriteLine(string.Join(", ", categories));

var page2 = products.OrderBy(p => p.Name).Skip(2).Take(2).Select(p => p.Name);
Console.WriteLine(string.Join(", ", page2));

record Product(string Name, string Category, decimal Price);
Outputcompiled & run with real C#
Desk     300.00
Monitor  300.00
Lamp     45.00
Mouse    25.00
Tech, Home
Laptop, Monitor
Your turn

Add a Select((p, i) => …) that numbers the affordable products 1, 2, 3 using the index overload.

04

GroupBy, Join and ToDictionary

GroupBy buckets items by a key and gives you a sequence of groups; each group has a Key and is itself a sequence you can count or sum. Join pairs items from two sequences whose keys are equal — an inner join, just like SQL. ToDictionary and ToLookup build an index in one line.

C#Program.cs
var orders = new[]
{
    new Order(1, "ana", 40m), new Order(2, "ben", 15m),
    new Order(3, "ana", 60m), new Order(4, "cy", 25m),
    new Order(5, "ben", 35m),
};
var customers = new[]
{
    new Customer("ana", "Lisbon"), new Customer("ben", "Pune"), new Customer("cy", "Oslo"),
};

foreach (var g in orders.GroupBy(o => o.Customer).OrderBy(g => g.Key))
    Console.WriteLine($"{g.Key}: {g.Count()} orders, {g.Sum(o => o.Total)} total");

var withCity = orders.Join(customers,
    o => o.Customer,          // key from the left side
    c => c.Name,              // key from the right side
    (o, c) => $"#{o.Id} {c.City}");
Console.WriteLine(string.Join(" | ", withCity));

Dictionary<string, string> cityOf = customers.ToDictionary(c => c.Name, c => c.City);
Console.WriteLine(cityOf["cy"]);

record Order(int Id, string Customer, decimal Total);
record Customer(string Name, string City);
Outputcompiled & run with real C#
ana: 2 orders, 100 total
ben: 2 orders, 50 total
cy: 1 orders, 25 total
#1 Lisbon | #2 Pune | #3 Lisbon | #4 Oslo | #5 Pune
Oslo
Your turn

Rewrite the Join in query syntax: from o in orders join c in customers on o.Customer equals c.Name select ….

Newer shortcuts
.NET 9 added CountBy and AggregateBy, so "orders per customer" can be orders.CountBy(o => o.Customer). MaxBy/MinBy (.NET 6) return the whole item with the largest key instead of just the key: orders.MaxBy(o => o.Total).
05

Aggregate, Any, All, and First versus FirstOrDefault

Some operators return a single value instead of a sequence. Count, Sum, Min, Max and Average are the obvious ones. Aggregate is the general fold: it carries an accumulator through the sequence. Any and All answer yes/no questions and stop as soon as they know the answer.

C#Program.cs
int[] temps = { 18, 22, 25, 19, 30 };

Console.WriteLine($"avg {temps.Average()}, max {temps.Max()}");
Console.WriteLine(temps.Aggregate((acc, t) => acc * 10 + t % 10));
Console.WriteLine(temps.Aggregate("", (acc, t) => acc + (t > 20 ? "H" : "c")));

Console.WriteLine($"any above 28? {temps.Any(t => t > 28)}");
Console.WriteLine($"all positive? {temps.All(t => t > 0)}");

Console.WriteLine(temps.First(t => t > 20));
Console.WriteLine(temps.FirstOrDefault(t => t > 40));
Console.WriteLine(temps.FirstOrDefault(t => t > 40, -1));
Console.WriteLine(temps.Single(t => t == 25));
Outputcompiled & run with real C#
avg 22.8, max 30
182590
cHHcH
any above 28? True
all positive? True
22
0
-1
25
Your turn

Use Aggregate to find the longest word in new[] { "linq", "delegate", "lambda" }.

Use Single when "more than one" would be a data bug you want to hear about, such as a lookup by unique email.
OperatorNo matchMore than one match
Firstthrows InvalidOperationExceptionreturns the first
FirstOrDefaultreturns default (0, null) or your fallbackreturns the first
Singlethrowsthrows
SingleOrDefaultreturns defaultthrows
Anyfalsetrue
Error you will hit

InvalidOperationException: First() on no match

C#
var nums = new List<int> { 1, 3, 5 };
int firstEven = nums.First(n => n % 2 == 0);
Console.WriteLine(firstEven);
Unhandled exception. System.InvalidOperationException: Sequence contains no matching element
   at System.Linq.ThrowHelper.ThrowNoMatchException()
   at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate)
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

First promises an element. When the data has none, it has nothing sensible to return, so it throws.

The fix

If "none" is a normal case, use FirstOrDefault and handle the default — but beware that for ints, 0 might also be a real value. The overload with a fallback, or Any first, makes the intent clear.

C#
var nums = new List<int> { 1, 3, 5 };
int firstEven = nums.FirstOrDefault(n => n % 2 == 0, -1);
Console.WriteLine(firstEven == -1 ? "no even numbers" : firstEven);
Error you will hit

InvalidOperationException: Single() with duplicates

C#
var users = new[] { "ana", "ben", "ana" };
string ana = users.Single(u => u == "ana");
Console.WriteLine(ana);
Unhandled exception. System.InvalidOperationException: Sequence contains more than one matching element
   at System.Linq.ThrowHelper.ThrowMoreThanOneMatchException()
   at System.Linq.Enumerable.TryGetSingle[TSource](IEnumerable`1 source, Func`2 predicate, Boolean& found)
   at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source, Func`2 predicate)
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

Single asserts there is exactly one match. Finding two means your assumption about the data is wrong — which is exactly what it is designed to tell you.

The fix

Fix the data (or the unique index in the database). If duplicates are legal, use First.

06

Deferred execution

Most LINQ operators do not run when you write them. Where and Select return a small object that describes the query; the work happens when something enumerates it — a foreach, ToList(), Count(), First(). This is deferred execution, and it is built on the same yield machinery from Module 08.

C#Program.cs
var source = new List<int> { 1, 2, 3 };

var query = source.Where(n =>
{
    Console.WriteLine($"  checking {n}");
    return n > 1;
});

Console.WriteLine("query built");
source.Add(4);                        // added AFTER the query was written

Console.WriteLine($"count = {query.Count()}");
Console.WriteLine($"count = {query.Count()}");   // runs the whole thing again

List<int> snapshot = query.ToList();  // runs once more, then stores results
source.Add(5);
Console.WriteLine($"snapshot has {snapshot.Count}");
Outputcompiled & run with real C#
query built
  checking 1
  checking 2
  checking 3
  checking 4
count = 3
  checking 1
  checking 2
  checking 3
  checking 4
count = 3
  checking 1
  checking 2
  checking 3
  checking 4
snapshot has 3
Your turn

Remove the ToList() and print snapshot.Count() after adding 5 — what changes and why?

VisualizeA Where/Select pipeline pulls one item at a timeStep 1 / 6
int[] nums = { 1, 2, 3 };
var q = nums
.Where(n => n != 2)
.Select(n => n * 10);
foreach (int x in q)
Console.WriteLine(x);
Line 2

Nothing is filtered or multiplied yet: q is a description of the pipeline.

Variables now

nothing yet

All 6 steps as a table
StepLineWhat happenedVariables now
12Nothing is filtered or multiplied yet: q is a description of the pipeline.
25foreach asks Select for an item; Select asks Where; Where reads 1 from the array.
331 passes the filter and flows on to Select.n = 1
46Select produced 10, printed immediately — before 2 or 3 has even been read.x = 10
53Next pull: Where reads 2, rejects it, and reads 3 without asking the consumer.n = 3
663 becomes 30.x = 30
Multiple enumeration
Calling Count() then foreach on the same deferred query runs it twice. With an in-memory list that is wasted CPU; with a database query it is two round trips; with a sequence that reads a file it may give different results. When you will use the result more than once, materialise it once with ToList() or ToArray().
07

IQueryable versus IEnumerable

Enumerable.Where takes a Func<T, bool> — compiled code that runs in your process. Queryable.Where, used when the source is an IQueryable<T> such as an Entity Framework DbSet, takes an Expression<Func<T, bool>> instead: the lambda is kept as a data structure the provider can read and translate to SQL. Same lambda text, completely different thing at runtime.

C#Program.cs
// File-based apps run the trimming/AOT analyzers; AsQueryable() on an array trips them.
#pragma warning disable IL2026, IL3050
using System.Linq.Expressions;

Func<int, bool> compiled = n => n > 5;
Expression<Func<int, bool>> tree = n => n > 5;

Console.WriteLine(compiled(8));        // runs the code
Console.WriteLine(tree);                // prints the tree, it is data
Console.WriteLine(tree.Body.NodeType);

IQueryable<int> q = new[] { 3, 6, 9 }.AsQueryable().Where(n => n > 5);
Console.WriteLine(q.Expression);
Console.WriteLine(string.Join(",", q));
Outputcompiled & run with real C#
True
n => (n > 5)
GreaterThan
System.Int32[].Where(n => (n > 5))
6,9

An IQueryable carries an expression tree. EF Core walks that tree and emits SQL; here the in-memory provider just compiles it.

C#OrdersRepository.cs
// db.Orders is IQueryable<Order>: the filter below becomes
//   SELECT ... FROM Orders WHERE Total > 100 ORDER BY CreatedAt DESC LIMIT 20
var recent = await db.Orders
    .Where(o => o.Total > 100)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20)
    .ToListAsync();

// AsEnumerable() switches to in-memory LINQ: the WHOLE table is loaded,
// then filtered in C#. Fine for 50 rows, a disaster for 5 million.
var slow = db.Orders.AsEnumerable().Where(o => o.Total > 100).ToList();

Keep filtering on IQueryable so the database does the work; switch to IEnumerable only for logic SQL cannot express.

IEnumerable<T>

  • Runs in your process
  • Takes Func<> delegates
  • Any C# method can be called in a lambda
  • Source: lists, arrays, yield methods

IQueryable<T>

  • Translated by a provider (usually to SQL)
  • Takes Expression<Func<>> trees
  • Only what the provider can translate — your own methods throw at runtime
  • Source: EF Core DbSet, OData, some NoSQL drivers
Delegate
A type-safe reference to a method; Func, Action and Predicate are built-in generic delegates.
Lambda
An inline anonymous function, x => x * 2, convertible to a delegate or an expression tree.
Closure
A lambda plus the outer variables it captured; the variables live as long as the lambda does.
LINQ
Language Integrated Query: extension methods (and query keywords) over IEnumerable and IQueryable.
Deferred execution
A query runs when it is enumerated, not when it is defined, and runs again each time it is enumerated.
Materialise
Run a query and store its results, with ToList, ToArray or ToDictionary.
Expression tree
Code represented as data (Expression<Func<…>>) that a provider can translate, for example to SQL.
IQueryable<T>
A query whose operators build an expression tree for a provider instead of running delegates in memory.
Quick check

What does Func<string, int, bool> describe?

Quick check

You write var q = list.Where(x => x > 3);, then add an item 10 to list, then call q.Count(). Is 10 counted?

Frequently asked questions

What is the difference between First and FirstOrDefault in LINQ?
First throws InvalidOperationException when no element matches; FirstOrDefault returns the default value for the type (null for reference types, 0 for int) or a fallback you pass. Use First when a match is guaranteed and FirstOrDefault when "none" is a normal outcome.
Is LINQ slow in C#?
In-memory LINQ adds small overhead from delegates and iterators, which rarely matters outside tight hot loops. The common real performance problems are running a deferred query many times and pulling a whole table into memory by switching from IQueryable to IEnumerable too early.
Should I use query syntax or method syntax?
They compile to the same calls. Most teams use method syntax because every operator is available; query syntax is handy for joins and let clauses. Pick one per query and stay consistent within a codebase.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.