C# · 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
- 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)
- 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.
- 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.
- 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.