📝 Lesson 2.3: LINQ
LINQ lets you filter, transform, sort, and summarize collections with short, readable code — replacing whole loops with a single expressive line. It's one of C#'s most beloved features, and it runs entirely on the lambdas you just learned.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what LINQ is and how it relates to lambdas
- Filter with
Whereand transform withSelect - Sort with
OrderBy, and summarize withCount,Sum,Average, etc. - Chain operators into a readable query pipeline
- Understand deferred execution and when a query actually runs
Estimated Time: 75 minutes
Project: Answer real questions about a dataset with concise LINQ queries.
In This Lesson
What Is LINQ?
LINQ (Language-Integrated Query) is a set of methods for querying collections directly in C#. Instead of writing a loop to filter or transform data, you describe what you want and let LINQ do the work.
Remember the Filter method you wrote in Lesson 2.1? LINQ's Where is exactly that — already built, and paired with dozens of related operators. Compare the two styles:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
// The old way: a loop
List<int> evensLoop = new List<int>();
foreach (int n in numbers)
{
if (n % 2 == 0) evensLoop.Add(n);
}
// The LINQ way: one line
List<int> evensLinq = numbers.Where(n => n % 2 == 0).ToList();
💡 One using to remember
LINQ's methods live in System.Linq. Modern projects include it automatically; in older templates add using System.Linq; at the top. LINQ works on anything enumerable — arrays, List<T>, dictionaries, and more (the IEnumerable<T> interface from the intro).
We'll use this small dataset throughout the lesson:
record Product(string Name, string Category, decimal Price);
List<Product> products = new List<Product>
{
new Product("Keyboard", "Electronics", 45m),
new Product("Mouse", "Electronics", 25m),
new Product("Desk", "Furniture", 150m),
new Product("Chair", "Furniture", 85m),
new Product("Monitor", "Electronics", 200m)
};
(That record is a concise way to declare a small data type — you'll learn records properly in Lesson 3.1. For now, just read it as a Product with three properties.)
Filtering with Where
Where keeps only the items that satisfy a condition. You pass it a lambda that takes an item and returns a bool — a Func<T, bool>, exactly like Lesson 2.1:
// All electronics
var electronics = products.Where(p => p.Category == "Electronics");
foreach (var p in electronics)
{
Console.WriteLine(p.Name);
}
// Keyboard, Mouse, Monitor
// Products under $100
var affordable = products.Where(p => p.Price < 100m);
💡 var shines with LINQ
LINQ result types can be verbose, so var (from the intro) is idiomatic here — the compiler still knows the exact type. You'll see var used throughout real LINQ code.
Transforming with Select
Select transforms each item into something else — it's a Func<T, TResult>. Use it to pull out one property, or to project into a new shape:
// Just the names (a sequence of strings)
var names = products.Select(p => p.Name);
Console.WriteLine(string.Join(", ", names));
// Keyboard, Mouse, Desk, Chair, Monitor
// Transform values — prices with 10% tax
var withTax = products.Select(p => p.Price * 1.10m);
// Project into a new shape (an anonymous object)
var summaries = products.Select(p => new { p.Name, Discounted = p.Price * 0.9m });
foreach (var s in summaries)
{
Console.WriteLine($"{s.Name}: {s.Discounted:C}");
}
✅ Where vs. Select
Remember the difference with a sentence: Where chooses which items to keep; Select chooses what each item becomes. Filtering vs. transforming.
Sorting and Chaining
Sort with OrderBy (ascending) or OrderByDescending, passing a lambda that selects the sort key. The real power is chaining: each operator returns a sequence, so you can pipe one into the next.
// Electronics under $100, cheapest first, names only
var result = products
.Where(p => p.Category == "Electronics")
.Where(p => p.Price < 100m)
.OrderBy(p => p.Price)
.Select(p => p.Name)
.ToList();
Console.WriteLine(string.Join(", ", result)); // Mouse, Keyboard
Read that top to bottom like a pipeline: filter to electronics → keep the cheap ones → sort by price → take the names → materialize to a list.
category"] B --> C["Where
price"] C --> D["OrderBy
price"] D --> E["Select
name"] E --> F["ToList"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
💡 Secondary sort with ThenBy
To break ties, follow OrderBy with ThenBy: products.OrderBy(p => p.Category).ThenBy(p => p.Price) sorts by category, then by price within each category.
Aggregation and Element Operators
LINQ can also reduce a whole sequence to a single answer, or grab a single element. These run immediately and return a value rather than a sequence.
Aggregation
Console.WriteLine(products.Count()); // 5
Console.WriteLine(products.Count(p => p.Price > 50m)); // 3
Console.WriteLine(products.Sum(p => p.Price)); // 505
Console.WriteLine(products.Average(p => p.Price)); // 101
Console.WriteLine(products.Max(p => p.Price)); // 200
Console.WriteLine(products.Min(p => p.Price)); // 25
Finding elements
// First match (throws if none found)
Product firstFurniture = products.First(p => p.Category == "Furniture");
// First match or null-ish default if none (safer)
Product? cheap = products.FirstOrDefault(p => p.Price < 10m); // null — nothing under $10
// Do any / do all items match?
bool anyExpensive = products.Any(p => p.Price > 150m); // True
bool allAffordable = products.All(p => p.Price < 500m); // True
⚠️ First vs. FirstOrDefault
First throws an InvalidOperationException if nothing matches (recall Lesson 1.1); FirstOrDefault returns the type's default (null for reference types) instead. When a "no match" is possible and normal, prefer FirstOrDefault and check the result.
Grouping
// Count products per category
var byCategory = products
.GroupBy(p => p.Category)
.Select(g => new { Category = g.Key, Count = g.Count() });
foreach (var group in byCategory)
{
Console.WriteLine($"{group.Category}: {group.Count}");
}
// Electronics: 3
// Furniture: 2
Deferred Execution
Here's a subtle but important behavior: most LINQ operators (like Where and Select) don't run when you define the query — they run when you iterate it. This is called deferred execution.
var query = products.Where(p => p.Price < 100m); // nothing runs yet
products.Add(new Product("Cable", "Electronics", 5m)); // added AFTER the query
foreach (var p in query) // NOW the query runs — and sees the new Cable
{
Console.WriteLine(p.Name);
}
// Keyboard, Mouse, Chair, Cable
⚠️ The query is a recipe, not a result
A LINQ query is a description of work. It re-runs every time you enumerate it, and reflects the data as it is at that moment. To capture a snapshot, force execution with ToList() (or ToArray()) — those run immediately and store the results.
✅ When to call ToList()
Materialize with ToList() when you need a stable snapshot, will iterate the results multiple times, or want the work done once. Otherwise, leaving a query deferred can be more efficient — it only computes what you actually consume.
💡 Query syntax exists too
C# also offers a SQL-like query syntax: from p in products where p.Price < 100 select p.Name. It compiles to the same method calls shown here. The method syntax (with lambdas) in this lesson is the most common in practice, so we focus on it.
Exercise & Quiz
🏋️ Exercise: Querying People
Objective: Answer questions about a dataset using LINQ instead of loops.
Instructions:
- Create a new project called
LinqLab. - Use the
Personlist in the starter code. - Write LINQ queries to produce: (a) the names of everyone 18 or older, sorted alphabetically; (b) the average age; (c) how many people are in each city (
GroupBy); (d) the oldest person's name.
Starter Code:
record Person(string Name, int Age, string City);
List<Person> people = new List<Person>
{
new Person("Ada", 36, "London"),
new Person("Grace", 45, "New York"),
new Person("Alan", 17, "London"),
new Person("Edsger", 61, "Austin"),
new Person("Katherine", 22, "New York")
};
// TODO (a): names of adults (Age >= 18), sorted A-Z
// TODO (b): average age
// TODO (c): count of people per city
// TODO (d): name of the oldest person
💡 Hint
(a) Where → OrderBy → Select. (b) people.Average(p => p.Age). (c) GroupBy(p => p.City) then select g.Key and g.Count(). (d) sort by age descending and take First(), or use MaxBy(p => p.Age).
✅ Solution
// (a) adult names, sorted
var adults = people
.Where(p => p.Age >= 18)
.OrderBy(p => p.Name)
.Select(p => p.Name)
.ToList();
Console.WriteLine(string.Join(", ", adults)); // Ada, Edsger, Grace, Katherine
// (b) average age
Console.WriteLine($"Average age: {people.Average(p => p.Age):F1}"); // 36.2
// (c) count per city
var perCity = people
.GroupBy(p => p.City)
.Select(g => new { City = g.Key, Count = g.Count() });
foreach (var c in perCity)
{
Console.WriteLine($"{c.City}: {c.Count}");
}
// London: 2, New York: 2, Austin: 1
// (d) oldest person
var oldest = people.OrderByDescending(p => p.Age).First();
Console.WriteLine($"Oldest: {oldest.Name}"); // Edsger
🎯 Quick Quiz
Question 1: Which LINQ operator keeps only items matching a condition?
Question 2: What does Select(p => p.Name) produce?
Question 3: With deferred execution, when does a Where query actually run?
Summary
🎉 Key Takeaways
- LINQ queries collections with concise operators that take lambdas — the
Func/Predicatedelegates from Lesson 2.1. Wherefilters (which items);Selecttransforms (what each becomes);OrderBy/OrderByDescendingsort.- Operators chain into readable pipelines; end with
ToList()/ToArray()to materialize results. - Aggregate with
Count,Sum,Average,Max/Min; find withFirst/FirstOrDefault,Any/All; bucket withGroupBy. - Deferred execution: a query is a recipe that runs when enumerated — use
ToList()for a stable snapshot.
📚 Additional Resources
🚀 What's Next?
That completes Module 2 — you can now express complex data operations in a line or two! In Module 3, we explore modern C# language features that make your code even more concise and safe, starting with Lesson 3.1: Records and Value Equality — the record you glimpsed in this lesson.
🎉 Module 2 complete!
Delegates, events, and LINQ — you now write expressive, functional-style C#. Next: modern language features.