Skip to main content
C# · lesson 15 of 26
C# intermediate Lesson 15 of 26

LINQ in C#

LINQ in C# by example: query vs method syntax, filtering, ordering, GroupBy, Join and left join, SelectMany, deferred execution and IQueryable.

By SkillByExample · Updated

Query Syntax vs Method Syntax

LINQ filters, sorts and transforms any IEnumerable<T>. The SQL-like query syntax compiles to exactly the same method calls as the lambda-based method syntax.

var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var methodSyntax = numbers
    .Where(n => n % 2 == 0)
    .Select(n => n * n);

var querySyntax =
    from n in numbers
    where n % 2 == 0
    select n * n;

Console.WriteLine(string.Join(" ", methodSyntax));
Console.WriteLine(string.Join(" ", querySyntax));
Console.WriteLine(methodSyntax.SequenceEqual(querySyntax));
Console.WriteLine(methodSyntax.GetType().Name);
$ dotnet run
4 16 36 64 100
4 16 36 64 100
True
WhereSelectArrayIterator`2

The compiler translates where into .Where(...) and select into .Select(...), so the two produce identical results. The result is not a list: its type is an internal iterator (WhereSelectArrayIterator on .NET 8, which fused the Where and Select into one pass) that computes values only when enumerated. Method syntax is more common in practice and covers operators the query syntax has no keyword for, like Take and Distinct.

Filtering, Ordering and Aggregating

Most day-to-day LINQ is Where, Select, OrderBy and an aggregate.

var products = new List<Product>
{
    new("Widget", "Tools", 9.99m, 150),
    new("Gadget", "Electronics", 49.99m, 30),
    new("Sprocket", "Tools", 4.99m, 200),
    new("Doohickey", "Electronics", 19.99m, 80),
    new("Gizmo", "Toys", 14.99m, 0),
};

Console.WriteLine(string.Join(", ", products.Where(p => p.Category == "Tools").Select(p => p.Name)));
Console.WriteLine(string.Join(", ", products.OrderBy(p => p.Category).ThenByDescending(p => p.Price).Select(p => p.Name)));

Console.WriteLine($"stock value {products.Sum(p => p.Price * p.Stock):0.00}, avg price {products.Average(p => p.Price):0.00}");
Console.WriteLine($"{products.MinBy(p => p.Price)!.Name} is cheapest; {products.Count(p => p.Stock == 0)} out of stock");
Console.WriteLine($"any toys: {products.Any(p => p.Category == "Toys")}, all stocked: {products.All(p => p.Stock > 0)}");

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

var cheap = products.Select(p => new { p.Name, Discounted = p.Price * 0.9m }).First();
Console.WriteLine(cheap);

public record Product(string Name, string Category, decimal Price, int Stock);
$ dotnet run
Widget, Sprocket
Gadget, Doohickey, Widget, Sprocket, Gizmo
stock value 5595.40, avg price 19.99
Sprocket is cheapest; 1 out of stock
any toys: True, all stocked: False
page 2: Gizmo, Sprocket
{ Name = Widget, Discounted = 8.991 }

ThenByDescending breaks ties within each category, so Gadget (49.99) comes before Doohickey. Skip(2).Take(2) is the standard pagination pattern — it must come after OrderBy, or “page 2” is whatever order the source happens to be in. new { p.Name, Discounted = ... } creates an anonymous type, whose generated ToString prints its properties.

First, Single and Their OrDefault Variants

Picking one element has four variants, and they differ exactly in what they throw.

int[] nums = { 3, 8, 12, 5 };

Console.WriteLine(nums.First(n => n > 4));
Console.WriteLine(nums.FirstOrDefault(n => n > 100));
Console.WriteLine(nums.FirstOrDefault(n => n > 100, -1));
Console.WriteLine(nums.Single(n => n > 10));

Try(() => nums.First(n => n > 100));
Try(() => nums.Single(n => n > 4));
Try(() => nums.SingleOrDefault(n => n > 4));

static void Try(Func<int> f)
{
    try { Console.WriteLine(f()); }
    catch (InvalidOperationException ex) { Console.WriteLine($"throws: {ex.Message}"); }
}
$ dotnet run
8
0
-1
12
throws: Sequence contains no matching element
throws: Sequence contains more than one matching element
throws: Sequence contains more than one matching element

FirstOrDefault on int returns 0 for “not found”, which is indistinguishable from a real 0 — the .NET 6+ overload with an explicit default (-1) fixes that. Single is an assertion: two matches (8 and 12) is an error, and even SingleOrDefault throws in that case, because “OrDefault” only covers zero matches.

GroupBy

GroupBy partitions a sequence by a key. Each group has a Key and is itself a sequence, so you can aggregate it.

var products = new List<Product>
{
    new("Widget", "Tools", 9.99m, 150),
    new("Gadget", "Electronics", 49.99m, 30),
    new("Sprocket", "Tools", 4.99m, 200),
    new("Doohickey", "Electronics", 19.99m, 80),
    new("Gizmo", "Toys", 14.99m, 50),
};

foreach (var group in products.GroupBy(p => p.Category))
    Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(p => p.Name))}");

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

foreach (var s in summary)
    Console.WriteLine($"{s.Category,-12}{s.Count} items  {s.Value,9:0.00}");

var lookup = products.ToLookup(p => p.Category);
Console.WriteLine($"{lookup["Tools"].Count()} tools, {lookup["Food"].Count()} food");

public record Product(string Name, string Category, decimal Price, int Stock);
$ dotnet run
Tools: Widget, Sprocket
Electronics: Gadget, Doohickey
Toys: Gizmo
Electronics 2 items    3098.90
Tools       2 items    2496.50
Toys        1 items     749.50
2 tools, 0 food

Groups appear in the order their keys are first seen, not alphabetically — sort explicitly if order matters. ToLookup is the eager cousin of GroupBy: it builds the groups immediately and returns an empty sequence (not an exception) for a missing key like "Food".

Join and Left Join

Join matches two sequences on a key, like a SQL inner join. A left join keeps unmatched rows from the left side.

var customers = new[] { new Customer(1, "Alice"), new Customer(2, "Bob"), new Customer(3, "Carol") };
var orders = new[]
{
    new Order(101, 1, 250m), new Order(102, 1, 80m),
    new Order(103, 2, 150m), new Order(104, 4, 50m),
};

var inner = orders.Join(customers, o => o.CustomerId, c => c.Id,
    (o, c) => $"{c.Name}: #{o.Id} {o.Amount}");
Console.WriteLine(string.Join(" | ", inner));

var left =
    from o in orders
    join c in customers on o.CustomerId equals c.Id into matches
    from c in matches.DefaultIfEmpty()
    select $"#{o.Id} {c?.Name ?? "(unknown)"}";
Console.WriteLine(string.Join(" | ", left));

var perCustomer = customers.GroupJoin(orders, c => c.Id, o => o.CustomerId,
    (c, os) => $"{c.Name}={os.Sum(o => o.Amount)}");
Console.WriteLine(string.Join(" | ", perCustomer));

public record Customer(int Id, string Name);
public record Order(int Id, int CustomerId, decimal Amount);
$ dotnet run
Alice: #101 250 | Alice: #102 80 | Bob: #103 150
#101 Alice | #102 Alice | #103 Bob | #104 (unknown)
Alice=330 | Bob=150 | Carol=0

Order 104 points at customer 4, who does not exist: the inner join silently drops it, the left join keeps it with (unknown). GroupJoin goes the other way — every customer with their orders — so Carol appears with a total of 0 even though she has no orders. The left join is one place where query syntax (into ... DefaultIfEmpty()) reads more clearly than the method chain.

Deferred Execution

Where, Select and OrderBy do not run when you call them. They run each time the result is enumerated.

var data = new List<int> { 1, 2, 3, 4, 5 };
int evaluations = 0;

var query = data.Where(n => { evaluations++; return n > 2; });
Console.WriteLine($"after defining: {evaluations} evaluations");

data.Add(10);
Console.WriteLine(string.Join(" ", query));
Console.WriteLine($"after one pass: {evaluations}");

Console.WriteLine(query.Count());
Console.WriteLine($"after two passes: {evaluations}");

var snapshot = query.ToList();
data.Add(20);
Console.WriteLine($"snapshot: {string.Join(" ", snapshot)} | live: {string.Join(" ", query)}");
$ dotnet run
after defining: 0 evaluations
3 4 5 10
after one pass: 6
4
after two passes: 12
snapshot: 3 4 5 10 | live: 3 4 5 10 20

Defining the query evaluated nothing. It then saw the 10 added after it was defined, because it reads data when enumerated. Calling Count() ran the whole filter again — 12 evaluations over two passes. ToList() materialises the results once; the snapshot ignores the later 20, while the live query picks it up. If a query is expensive (or hits a database), materialise it before enumerating twice.

SelectMany: Flattening

SelectMany maps each element to a sequence and concatenates the results.

var orders = new[]
{
    new { Customer = "Alice", Items = new[] { "Widget", "Gadget" } },
    new { Customer = "Bob", Items = new[] { "Sprocket" } },
    new { Customer = "Carol", Items = new[] { "Gizmo", "Widget" } },
};

var nested = orders.Select(o => o.Items);
Console.WriteLine(nested.First().GetType().Name + " x " + nested.Count());

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

var withCustomer = orders.SelectMany(o => o.Items, (o, item) => $"{o.Customer}:{item}");
Console.WriteLine(string.Join(" ", withCustomer));
$ dotnet run
String[] x 3
Widget, Gadget, Sprocket, Gizmo, Widget
Widget, Gadget, Sprocket, Gizmo
Alice:Widget Alice:Gadget Bob:Sprocket Carol:Gizmo Carol:Widget

Select gives you three arrays; SelectMany gives you five strings. The two-argument overload keeps the parent in scope, so each item can carry its customer’s name.

A Pipeline Over Log Lines

Chained operators replace nested loops and temporary lists with one readable pipeline.

string[] logLines =
{
    "2024-01-15 ERROR NullReferenceException in OrderService",
    "2024-01-15 INFO  Request completed in 120ms",
    "2024-01-15 ERROR TimeoutException in PaymentService",
    "2024-01-16 WARN  High memory usage: 85%",
    "2024-01-16 ERROR ArgumentException in UserService",
    "garbage line",
};

var errorsByDay = logLines
    .Select(line => line.Split(' ', 3))
    .Where(parts => parts.Length == 3 && parts[1] == "ERROR")
    .GroupBy(parts => parts[0], parts => parts[2].Split(' ')[0])
    .OrderBy(g => g.Key);

foreach (var day in errorsByDay)
    Console.WriteLine($"{day.Key}: {day.Count()} error(s) — {string.Join(", ", day)}");
$ dotnet run
2024-01-15: 2 error(s) — NullReferenceException, TimeoutException
2024-01-16: 1 error(s) — ArgumentException

The second GroupBy argument is an element selector: each group holds just the exception name instead of the whole split line. The malformed line is filtered out by the length check rather than crashing the pipeline.

IEnumerable vs IQueryable

With Entity Framework Core, LINQ is not run in memory at all: an IQueryable<T> records the query as an expression tree that the provider translates to SQL. You can see the difference without a database.

int[] numbers = { 5, 12, 8, 20 };

IEnumerable<int> inMemory = numbers.Where(n => n > 6);
IQueryable<int> queryable = numbers.AsQueryable().Where(n => n > 6).OrderBy(n => n);

Console.WriteLine(inMemory.GetType().Name);
Console.WriteLine(queryable.Expression);
Console.WriteLine(string.Join(", ", queryable));
$ dotnet run
WhereArrayIterator`1
System.Int32[].Where(n => (n > 6)).OrderBy(n => n)
8, 12, 20

The in-memory query holds a compiled delegate. The queryable holds a description of the query — printed above — which is what EF Core turns into WHERE and ORDER BY clauses. That is why ToListAsync, CountAsync and friends exist for EF Core queries; see Entity Framework Core for the SQL each query produces.

Practice

  1. Top earners. From (Name, Dept, Salary) records, print the highest-paid person in each department.
Solution
var staff = new[]
{
    new Emp("Ann", "Eng", 120), new Emp("Raj", "Eng", 135),
    new Emp("Li", "Ops", 90), new Emp("Sam", "Ops", 85),
};

foreach (var top in staff.GroupBy(e => e.Dept).Select(g => g.MaxBy(e => e.Salary)!))
    Console.WriteLine($"{top.Dept}: {top.Name} ({top.Salary})");

public record Emp(string Name, string Dept, int Salary);
$ dotnet run
Eng: Raj (135)
Ops: Li (90)
  1. Word frequency. Print the three most frequent words in "the cat and the dog and the bird", with counts.
Solution
var top3 = "the cat and the dog and the bird"
    .Split(' ')
    .GroupBy(w => w)
    .Select(g => (Word: g.Key, Count: g.Count()))
    .OrderByDescending(x => x.Count)
    .Take(3);

foreach (var (word, count) in top3)
    Console.WriteLine($"{word}: {count}");
$ dotnet run
the: 3
and: 2
cat: 1

OrderByDescending is a stable sort, so among the words seen once, cat — the first in the input — takes third place. On .NET 9+, .CountBy(w => w) replaces the GroupBy/Select pair.

  1. Chunked batches. Split Enumerable.Range(1, 10) into batches of 4 and print each batch’s sum.
Solution
foreach (var batch in Enumerable.Range(1, 10).Chunk(4))
    Console.WriteLine($"[{string.Join(",", batch)}] sum={batch.Sum()}");
$ dotnet run
[1,2,3,4] sum=10
[5,6,7,8] sum=26
[9,10] sum=19

Chunk (.NET 6+) makes the last batch shorter rather than padding it.

  1. Find the unmatched. Using the Join example’s data, list the order IDs whose customer does not exist, without a join.
Solution
var customerIds = new[] { 1, 2, 3 }.ToHashSet();
var orders = new[] { (Id: 101, Cust: 1), (Id: 103, Cust: 2), (Id: 104, Cust: 4), (Id: 105, Cust: 9) };

var orphans = orders.Where(o => !customerIds.Contains(o.Cust)).Select(o => o.Id);
Console.WriteLine(string.Join(", ", orphans));
$ dotnet run
104, 105

A HashSet makes each Contains O(1); ExceptBy would also work.

Frequently Asked Questions

What is deferred execution in LINQ?
Most LINQ operators (Where, Select, OrderBy) build a query description but do not execute it immediately. Execution is deferred until you iterate the result (foreach, ToList, Count, First, etc.). This means the query runs against the latest data, but calling ToList() too early can hurt performance by materializing data you haven't filtered yet.
When should I call ToList() or ToArray()?
Call ToList()/ToArray() when you need to: iterate the result more than once, pass results to a method expecting a list, prevent multiple database round trips, or capture a snapshot of the data at a point in time. Avoid it just to make the type concrete — leave it as IEnumerable<T> if you only iterate once.
Is LINQ slower than a foreach loop?
LINQ has a small overhead from method calls and delegate invocations. For most applications the difference is negligible. In extremely hot paths (tight loops, game physics) a manual loop can be faster. For business logic, the readability gain is worth the minor cost.
What is the difference between First, FirstOrDefault, Single and SingleOrDefault?
First returns the first match and throws if there is none; FirstOrDefault returns default (null or 0) instead. Single insists there is exactly one match and throws if there are zero or several; SingleOrDefault allows zero but still throws on several. Use Single when more than one match would mean a data bug.
What is the difference between IEnumerable and IQueryable in LINQ?
IEnumerable<T> operators take delegates and run in memory, in your process. IQueryable<T> operators take expression trees that a provider such as Entity Framework Core translates into SQL and runs on the database. The same Where call can therefore filter a million rows in the database or pull them all into memory first, depending on which interface the query is typed as.