Skip to main content

📝 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 Where and transform with Select
  • Sort with OrderBy, and summarize with Count, 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.

graph LR A["products"] --> B["Where
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:

  1. Create a new project called LinqLab.
  2. Use the Person list in the starter code.
  3. 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) WhereOrderBySelect. (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/Predicate delegates from Lesson 2.1.
  • Where filters (which items); Select transforms (what each becomes); OrderBy/OrderByDescending sort.
  • Operators chain into readable pipelines; end with ToList()/ToArray() to materialize results.
  • Aggregate with Count, Sum, Average, Max/Min; find with First/FirstOrDefault, Any/All; bucket with GroupBy.
  • 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.