C# LINQ

Query and transform collections with LINQ: filter with Where, project with Select, sort with OrderBy, group with GroupBy, combine with Join, summarise with Count, Sum and Average, and understand deferred execution.

Lesson 19 of 43Advanced18 min read

A huge amount of programming is working with collections: find the orders over £100, list customer names in alphabetical order, total the sales for each month, check whether any item is out of stock. Written with loops, each task takes several lines of bookkeeping. LINQ (Language Integrated Query) lets you express them directly: orders.Where(o => o.Total > 100).OrderBy(o => o.Date).

LINQ is a set of methods that work on any IEnumerable<T>: arrays, lists, dictionaries, sets, lines of a file, and results from a database. Each method takes a lambda, a small inline function, that says how to filter, transform or sort the items. Methods return new sequences, so they chain together into readable pipelines.

LINQ is one of the features C# developers use most every day. In this lesson you'll filter, transform, sort, group, join and summarise data, learn which methods return one item and which return many, and understand deferred execution, which explains some of LINQ's surprising behaviour.

Filtering, projecting and sorting

C#
var products = new List<Product>
{
    new("Mug", "Kitchen", 8.50m, 12),
    new("Lamp", "Home", 24.00m, 0),
    new("Kettle", "Kitchen", 29.99m, 5)
};

var names = products
    .Where(p => p.Stock > 0)                // keep matching items
    .OrderByDescending(p => p.Price)        // sort
    .Select(p => p.Name)                    // transform each item
    .ToList();                              // run the query and store the result
  • Where keeps items for which the lambda returns true.
  • Select turns each item into something else, such as one property or a new anonymous object.
  • OrderBy and OrderByDescending sort; ThenBy adds a second sort key.
  • Take(n), Skip(n), Distinct() and Chunk(size) are useful for paging and cleaning data.

Single items and summaries

MethodReturns
First(predicate)The first match, or throws if none
FirstOrDefault(predicate)The first match, or null/default if none
Single(predicate)The only match; throws if there are zero or several
Any(predicate) / All(predicate)true or false
Count(), Sum(), Average(), Min(), Max()A number
MaxBy(selector) / MinBy(selector)The item with the largest or smallest value

Any stops at the first match, so list.Any() is the fastest way to check that a sequence isn't empty.

Grouping and joining

GroupBy collects items that share a key. Each group has a Key and contains its items:

C#
var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new { Category = g.Key, Count = g.Count(), Value = g.Sum(p => p.Price * p.Stock) });

Join matches items from two sequences by a shared key, like a SQL join. SelectMany flattens nested collections, such as all the items from every order. LINQ also has a query syntax that reads like SQL: from p in products where p.Stock > 0 select p.Name. It compiles to the same method calls, so use whichever reads better.

Deferred execution

Most LINQ methods don't run immediately. products.Where(...) creates a query that runs only when you loop over it or call a method like ToList(), Count() or First(). If the source changes in between, the query sees the change, and running it twice does the work twice. Call ToList() or ToArray() when you want to capture results once.

Turning query results into other collections

ToList() and ToArray() are the usual ways to run a query, but LINQ can also build other collections directly:

C#
var byName = products.ToDictionary(p => p.Name, p => p.Price);
decimal lampPrice = byName["Lamp"];

var categories = products.Select(p => p.Category).ToHashSet();

var byCategory = products.ToLookup(p => p.Category);
foreach (var p in byCategory["Kitchen"])
    Console.WriteLine(p.Name);
  • ToDictionary(keySelector, valueSelector) throws an ArgumentException if two items produce the same key, so only use it when keys are unique, such as ids.
  • ToHashSet() keeps one copy of each value, which is handy for fast "is this present?" checks later.
  • ToLookup is like a dictionary where every key maps to a group of items. Unlike a dictionary, asking for a missing key returns an empty sequence instead of throwing.

For a calculation that LINQ doesn't have a named method for, Aggregate combines all items into one result by applying a function repeatedly. new[] { 1, 2, 3, 4 }.Aggregate((a, b) => a * b) multiplies them together to give 24.

C# Advanced Syntax

LINQ methods

var cheap = items.Where(i => i.Price < 10);
var names = items.Select(i => i.Name);
var sorted = items.OrderBy(i => i.Category).ThenByDescending(i => i.Price);
var first = items.FirstOrDefault(i => i.Id == 3);
bool any = items.Any(i => i.Stock == 0);
decimal total = items.Sum(i => i.Price);
var groups = items.GroupBy(i => i.Category);
var page = items.Skip(20).Take(10);
var lookup = items.ToDictionary(i => i.Id);
var q = from i in items where i.Price > 5 orderby i.Name select i.Name;

C# Advanced Summary

  • LINQ methods work on any IEnumerable<T> and chain into pipelines.
  • Where filters, Select transforms, OrderBy/ThenBy sort.
  • First, FirstOrDefault, Single, Any and All return single results; Count, Sum and Average summarise.
  • GroupBy groups by a key; Join combines two sequences; SelectMany flattens.
  • Queries are deferred until enumerated; ToList captures results.

C# Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Filtering and summarising orders

C#
var orders = new List<Order>
{
    new(1, "Ada", 120.00m, new DateOnly(2026, 3, 2)),
    new(2, "Ben", 35.50m, new DateOnly(2026, 3, 2)),
    new(3, "Ada", 64.25m, new DateOnly(2026, 3, 5)),
    new(4, "Cy", 210.00m, new DateOnly(2026, 3, 9)),
    new(5, "Ben", 18.75m, new DateOnly(2026, 3, 9))
};

var big = orders.Where(o => o.Total >= 60).OrderByDescending(o => o.Total);
Console.WriteLine(string.Join(", ", big.Select(o => $"#{o.Id} {o.Total}")));

Console.WriteLine($"Count: {orders.Count()}, total: {orders.Sum(o => o.Total)}");
Console.WriteLine($"Average: {orders.Average(o => o.Total):F2}");
Console.WriteLine($"Largest: #{orders.MaxBy(o => o.Total)!.Id}");
Console.WriteLine($"Any under 20: {orders.Any(o => o.Total < 20)}");
Console.WriteLine($"Customers: {string.Join(", ", orders.Select(o => o.Customer).Distinct())}");

record Order(int Id, string Customer, decimal Total, DateOnly Date);
Output
#4 210.00, #1 120.00, #3 64.25
Count: 5, total: 448.50
Average: 89.70
Largest: #4
Any under 20: True
Customers: Ada, Ben, Cy
Line-by-line explanation
  1. Line 10Where filters and OrderByDescending sorts, largest first.
  2. Line 11Select turns each order into a short label.
  3. Line 13120 + 35.50 + 64.25 + 210 + 18.75 = 448.50.
  4. Line 14448.50 divided by 5 is 89.70.
  5. Line 15MaxBy returns the whole order with the largest total, not just the number.
  6. Line 17Distinct removes repeated names, keeping the first-seen order.
Example 2

Grouping by customer

C#
var orders = new[]
{
    new { Customer = "Ada", Total = 120.00m },
    new { Customer = "Ben", Total = 35.50m },
    new { Customer = "Ada", Total = 64.25m },
    new { Customer = "Ben", Total = 18.75m },
    new { Customer = "Cy", Total = 210.00m }
};

var summary = orders
    .GroupBy(o => o.Customer)
    .Select(g => new { Name = g.Key, Orders = g.Count(), Spent = g.Sum(o => o.Total) })
    .OrderByDescending(x => x.Spent);

foreach (var s in summary)
{
    Console.WriteLine($"{s.Name,-4}{s.Orders} orders {s.Spent,8:F2}");
}

var lookup = orders.ToLookup(o => o.Customer);
Console.WriteLine(lookup["Ben"].Count());
Output
Cy  1 orders   210.00
Ada 2 orders   184.25
Ben 2 orders    54.25
2
Line-by-line explanation
  1. Lines 1 to 8Anonymous objects hold quick, throwaway data without declaring a type.
  2. Line 11GroupBy collects the orders that share a customer name.
  3. Line 12Each group has a Key and can be counted and summed.
  4. Line 13Sort the summaries by amount spent.
  5. Lines 20 to 21ToLookup builds a ready-made grouping you can index by key; Ben has two orders.
Example 3

Join, SelectMany and deferred execution

C#
var customers = new List<(int Id, string Name)> { (1, "Ada"), (2, "Ben") };
var orders = new List<(int CustomerId, string[] Items)>
{
    (1, new[] { "Mug", "Lamp" }),
    (2, new[] { "Pen" }),
    (1, new[] { "Kettle" })
};

var joined = customers.Join(orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => $"{c.Name}: {string.Join("/", o.Items)}");
Console.WriteLine(string.Join("; ", joined));

var allItems = orders.SelectMany(o => o.Items).OrderBy(i => i);
Console.WriteLine(string.Join(", ", allItems));

var numbers = new List<int> { 1, 2, 3 };
var evens = numbers.Where(n => n % 2 == 0);
numbers.Add(4);
Console.WriteLine(string.Join(", ", evens));
Output
Ada: Mug/Lamp; Ada: Kettle; Ben: Pen
Kettle, Lamp, Mug, Pen
2, 4
Line-by-line explanation
  1. Lines 9 to 12Join matches each customer to their orders by id, then builds a result from each pair.
  2. Line 15SelectMany flattens the item arrays from every order into one sequence.
  3. Line 19The query is only defined here, not run.
  4. Lines 20 to 214 was added before the query ran, so it's included in the results.

Common C# Advanced Mistakes

First on a sequence that might be empty

Don't
var order = orders.First(o => o.Id == id);          // InvalidOperationException if not found
Do
var order = orders.FirstOrDefault(o => o.Id == id);
if (order is null) return NotFound();

Running the same query many times

Don't
var active = users.Where(u => u.IsActive);
Console.WriteLine(active.Count());                   // runs the filter
foreach (var u in active) { }                        // runs it again
Do
var active = users.Where(u => u.IsActive).ToList();  // run once, reuse the list

Count() > 0 to check for items

Don't
if (orders.Where(o => o.IsLate).Count() > 0) { }     // counts every match
Do
if (orders.Any(o => o.IsLate)) { }                   // stops at the first match

C# Advanced in Real Projects

LINQ in real applications

In ASP.NET Core apps with Entity Framework Core, LINQ queries such as db.Orders.Where(o => o.CustomerId == id).OrderByDescending(o => o.Date).Take(10) are translated into SQL and run in the database. In memory, LINQ shapes API responses, builds reports, removes duplicates from imports and groups log entries. It's so common that reading LINQ fluently is expected of every C# developer.

C# Advanced FAQ

What is the difference between First and FirstOrDefault?

First returns the first matching item and throws an InvalidOperationException if there isn't one. FirstOrDefault returns default instead, which is null for classes and 0 for numbers. Use First when an empty result would be a bug, and FirstOrDefault with a null check when it's a normal possibility.

What is the difference between Select and SelectMany?

Select turns each item into exactly one result, so a list of orders becomes a list of something else with the same count. SelectMany lets each item produce a sequence and flattens all of them into one, so orders.SelectMany(o => o.Items) gives every item from every order in a single list.

Is LINQ slower than a foreach loop?

LINQ adds a little overhead from delegates and iterators, so a hand-written loop can be slightly faster in hot paths. For most application code the difference is negligible, and LINQ's clarity is worth it. Measure before rewriting, and watch for real problems such as running the same query many times.

How do I remove duplicates with LINQ?

Use Distinct() for simple values like numbers and strings. To remove duplicates based on one property, use DistinctBy(p => p.Email), available since .NET 6, which keeps the first item for each key. Records compare by value, so Distinct() works on them directly.

What does ToList do in LINQ?

ToList() runs the query immediately and stores the results in a new List<T>. Without it, most LINQ queries are deferred and run again every time you loop over them. Call it when you need the results more than once or want to capture them at a particular moment.

C# Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the query to keep only prices above 10.

var expensive = prices.(p => p > 10);
Predict the outputeasy

What does this print?

var nums = new[] { 1, 2, 3, 4 };
Console.WriteLine(nums.Select(n => n * 10).Sum());
Multiple choicemedium

What does FirstOrDefault return when nothing matches a list of objects?

Predict the outputmedium

What does this print?

var words = new[] { "apple", "avocado", "banana", "blueberry", "cherry" };
var groups = words.GroupBy(w => w[0]);
Console.WriteLine(groups.Count());
Multiple choicehard

What does deferred execution mean in LINQ?

C# Advanced Quiz

5 questions to check what you learned.

  1. Which method transforms each item into something else?

    • Where
    • OrderBy
    • Select
    • Any
  2. Which method checks whether any item matches a condition?

    • All
    • Any
    • Contains
    • Count
  3. What does GroupBy return?

    • A dictionary
    • A sorted list
    • A single number
    • A sequence of groups, each with a Key and its items
  4. What does Single throw if two items match?

    • An InvalidOperationException
    • Nothing, it returns the first
    • A FormatException
    • A KeyNotFoundException
  5. Which method flattens a list of orders, each with a list of items, into one list of items?

    • Select
    • GroupBy
    • Join
    • SelectMany