Day 15: LINQ - Stop Writing Loops, Start Asking Questions
arrow_back All Posts
April 06, 2026 13 min read .NET/C#

Day 15: LINQ - Stop Writing Loops, Start Asking Questions

Yesterday you learned about generics — the "write once, use everywhere" superpower. You built boxes that hold anything, swapped values without caring about types, and discovered that List<T> is magic. Today's topic is the reason generics exist. Today you meet LINQ — and once you do, you'll wonder how you ever lived without it.

1. The Pain of Living Without LINQ

You have a list of students. You want the ones who scored above 80. Before LINQ, here's what you'd write:

List<Student> students =
[
    new("Alice", 92),
    new("Bob", 67),
    new("Charlie", 85),
    new("Diana", 45),
    new("Eve", 91)
];

List<Student> topStudents = [];
foreach (Student s in students)
{
    if (s.Grade > 80)
    {
        topStudents.Add(s);
    }
}

record Student(string Name, int Grade);

Five lines of ceremony to say "give me the good students." And that's just filtering. What if you also want them sorted by grade, then projected into just their names? You'd nest loops, add more temporary lists, and your code would start looking like a tax form.

LINQ (Language Integrated Query) replaces all of that with one line:

var topStudents = students.Where(s => s.Grade > 80);

That's it. No temporary list. No loop. No if statement. Just a question — "which students have a grade above 80?" — and the answer.

LINQ works on anything that implements IEnumerable<T> — lists, arrays, dictionaries, even database results. It shipped in C# 3.0 back in 2007 and has been the most beloved feature of the language ever since.

2. Where — Your First Filter

Where is LINQ's bouncer. It takes a condition and only lets matching elements through:

List<int> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

var evens = numbers.Where(n => n % 2 == 0);
// Result: 2, 4, 6, 8, 10

var bigOnes = numbers.Where(n => n > 7);
// Result: 8, 9, 10

That n => n % 2 == 0 is a lambda expression — a tiny inline function. Read it as: "given n, return true if n is even." We'll cover lambdas properly in Day 16, but for now just think of them as the condition you're checking.

Here's a more realistic example:

List<Product> inventory =
[
    new("Laptop", 999.99m, 5),
    new("Mouse", 29.99m, 150),
    new("Keyboard", 79.99m, 80),
    new("Monitor", 449.99m, 12),
    new("USB Cable", 9.99m, 300)
];

// Products under $100 with more than 50 in stock
var affordable = inventory
    .Where(p => p.Price < 100m && p.Stock > 50);

foreach (var p in affordable)
    Console.WriteLine($"{p.Name}: ${p.Price} ({p.Stock} in stock)");
// Mouse: $29.99 (150 in stock)
// Keyboard: $79.99 (80 in stock)
// USB Cable: $9.99 (300 in stock)

record Product(string Name, decimal Price, int Stock);

You can stack conditions inside a single Where using && and ||, just like a regular if.

3. Select — Transforming Data

Where filters. Select transforms. It takes each element and reshapes it into something new — like a factory assembly line where raw materials go in and finished products come out.

List<string> names = ["alice", "bob", "charlie"];

// Transform each name to uppercase
var upper = names.Select(n => n.ToUpper());
// Result: "ALICE", "BOB", "CHARLIE"

// Transform into something completely different
var lengths = names.Select(n => new { Name = n, Length = n.Length });
// Result: { Name = "alice", Length = 5 }, { Name = "bob", Length = 3 }, ...

That second example creates anonymous types — objects with properties you define on the fly. You don't need a class or record for them. The compiler figures out the type.

Here's Select doing real work with our student list:

List<Student> students =
[
    new("Alice", 92),
    new("Bob", 67),
    new("Charlie", 85),
    new("Diana", 45),
    new("Eve", 91)
];

// Create a report card
var reportCards = students.Select(s => new
{
    s.Name,
    s.Grade,
    Status = s.Grade >= 60 ? "Pass" : "Fail",
    Letter = s.Grade switch
    {
        >= 90 => "A",
        >= 80 => "B",
        >= 70 => "C",
        >= 60 => "D",
        _ => "F"
    }
});

foreach (var card in reportCards)
    Console.WriteLine($"{card.Name}: {card.Grade} ({card.Letter}) - {card.Status}");
// Alice: 92 (A) - Pass
// Bob: 67 (D) - Pass
// Charlie: 85 (B) - Pass
// Diana: 45 (F) - Fail
// Eve: 91 (A) - Pass

record Student(string Name, int Grade);

Notice the switch expression from Day 8 making a comeback inside a Select. LINQ plays well with every C# feature you've already learned.

4. OrderBy and ThenBy — Sorting Without the Headache

Sorting collections by hand means writing comparison logic, tracking indices, and praying you don't introduce an off-by-one error. LINQ gives you OrderBy and OrderByDescending:

List<Product> products =
[
    new("Laptop", 999.99m, "Electronics"),
    new("Notebook", 4.99m, "Office"),
    new("Mouse", 29.99m, "Electronics"),
    new("Pen", 1.99m, "Office"),
    new("Monitor", 449.99m, "Electronics")
];

// Cheapest first
var byPrice = products.OrderBy(p => p.Price);
// Pen, Notebook, Mouse, Monitor, Laptop

// Most expensive first
var byPriceDesc = products.OrderByDescending(p => p.Price);
// Laptop, Monitor, Mouse, Notebook, Pen

record Product(string Name, decimal Price, string Category);

What if two products have the same price? Use ThenBy for a tiebreaker:

var sorted = products
    .OrderBy(p => p.Category)        // First: sort by category
    .ThenBy(p => p.Price);            // Then: within each category, sort by price

// Electronics: Mouse (29.99), Monitor (449.99), Laptop (999.99)
// Office: Pen (1.99), Notebook (4.99)

ThenBy only kicks in when OrderBy encounters equal values. You can chain as many ThenBy calls as you need — sort by department, then by seniority, then by last name, then by how much they annoy you in meetings.

5. GroupBy — Putting Things in Buckets

GroupBy splits a collection into groups based on a key — like sorting mail into mailboxes.

List<Employee> team =
[
    new("Alice", "Engineering", 95000),
    new("Bob", "Marketing", 72000),
    new("Charlie", "Engineering", 88000),
    new("Diana", "Marketing", 68000),
    new("Eve", "Engineering", 102000),
    new("Frank", "Sales", 61000)
];

var departments = team.GroupBy(e => e.Department);

foreach (var dept in departments)
{
    Console.WriteLine($"\n{dept.Key} ({dept.Count()} employees):");
    foreach (var employee in dept)
        Console.WriteLine($"  {employee.Name}: ${employee.Salary:N0}");
}
// Engineering (3 employees):
//   Alice: $95,000
//   Charlie: $88,000
//   Eve: $102,000
// Marketing (2 employees):
//   Bob: $72,000
//   Diana: $68,000
// Sales (1 employees):
//   Frank: $61,000

record Employee(string Name, string Department, int Salary);

Each group has a .Key (the value you grouped by) and is itself an IEnumerable<T> — so you can loop through the items in each group, or run more LINQ on them:

// Average salary per department
var salaryReport = team
    .GroupBy(e => e.Department)
    .Select(g => new
    {
        Department = g.Key,
        HeadCount = g.Count(),
        AvgSalary = g.Average(e => e.Salary),
        TopEarner = g.OrderByDescending(e => e.Salary).First().Name
    });

foreach (var dept in salaryReport)
    Console.WriteLine($"{dept.Department}: {dept.HeadCount} people, " +
                      $"avg ${dept.AvgSalary:N0}, top earner: {dept.TopEarner}");
// Engineering: 3 people, avg $95,000, top earner: Eve
// Marketing: 2 people, avg $70,000, top earner: Bob
// Sales: 1 people, avg $61,000, top earner: Frank

That's a full departmental salary report in about ten lines. Try doing that with nested foreach loops and temporary dictionaries. Go on, I'll wait.

6. Method Syntax vs. Query Syntax

C# gives you two ways to write LINQ. Everything above used method syntax — the .Where(), .Select(), .OrderBy() chain. There's also query syntax, which looks like SQL:

List<Student> students =
[
    new("Alice", 92),
    new("Bob", 67),
    new("Charlie", 85),
    new("Diana", 45),
    new("Eve", 91)
];

// Method syntax
var method = students
    .Where(s => s.Grade > 80)
    .OrderByDescending(s => s.Grade)
    .Select(s => s.Name);

// Query syntax — same result
var query =
    from s in students
    where s.Grade > 80
    orderby s.Grade descending
    select s.Name;

// Both produce: "Alice", "Eve", "Charlie"

record Student(string Name, int Grade);

Which should you use? Method syntax. Here's why:

  • It's more common in real-world codebases
  • It supports every LINQ method — query syntax only covers a subset
  • It chains naturally with other method calls
  • It's what you'll see in Stack Overflow answers, tutorials, and your coworker's pull requests

Query syntax shines when you have complex join operations or multiple from clauses, but you won't hit those until you're doing advanced data work. For everything in this series, method syntax is the way to go.

7. Chaining — Where LINQ Gets Fun

The real magic of LINQ isn't any single method — it's that you can chain them together. Each method returns an IEnumerable<T>, which means you can call another method on the result, and another, and another. It reads like a pipeline:

List<Employee> company =
[
    new("Alice", "Engineering", 95000),
    new("Bob", "Marketing", 72000),
    new("Charlie", "Engineering", 88000),
    new("Diana", "HR", 68000),
    new("Eve", "Engineering", 102000),
    new("Frank", "Marketing", 61000),
    new("Grace", "HR", 71000),
    new("Hank", "Engineering", 79000)
];

// "Give me the names of the top 3 highest-paid engineers"
var topEngineers = company
    .Where(e => e.Department == "Engineering")  // filter to engineers
    .OrderByDescending(e => e.Salary)           // highest salary first
    .Take(3)                                     // grab the top 3
    .Select(e => $"{e.Name} (${e.Salary:N0})"); // format as string

foreach (var engineer in topEngineers)
    Console.WriteLine(engineer);
// Eve ($102,000)
// Alice ($95,000)
// Charlie ($88,000)

record Employee(string Name, string Department, int Salary);

Read the chain from top to bottom: filter → sort → take → transform. Each step narrows or reshapes the data. It's like an assembly line where each station does one thing.

Here's another chain — a bit more involved:

List<Order> orders =
[
    new(1, "Alice", "Laptop", 999.99m),
    new(2, "Bob", "Mouse", 29.99m),
    new(3, "Alice", "Keyboard", 79.99m),
    new(4, "Charlie", "Monitor", 449.99m),
    new(5, "Alice", "USB Cable", 9.99m),
    new(6, "Bob", "Headphones", 59.99m)
];

// Total spending per customer, highest spender first
var spending = orders
    .GroupBy(o => o.Customer)
    .Select(g => new
    {
        Customer = g.Key,
        TotalSpent = g.Sum(o => o.Amount),
        OrderCount = g.Count()
    })
    .OrderByDescending(x => x.TotalSpent);

foreach (var c in spending)
    Console.WriteLine($"{c.Customer}: ${c.TotalSpent:N2} across {c.OrderCount} orders");
// Alice: $1,089.97 across 3 orders
// Charlie: $449.99 across 1 orders
// Bob: $89.98 across 2 orders

record Order(int Id, string Customer, string Product, decimal Amount);

Group → aggregate → sort. Three operations, one readable pipeline. This is why people say LINQ changes how you think about data.

8. Deferred Execution — The Lazy Gotcha

Here's something that trips up every C# beginner. LINQ queries don't run when you write them. They run when you consume them:

List<int> numbers = [1, 2, 3, 4, 5];

var evens = numbers.Where(n =>
{
    Console.WriteLine($"Checking {n}...");
    return n % 2 == 0;
});

Console.WriteLine("Query created. Nothing has been checked yet.");

Console.WriteLine("Now iterating:");
foreach (var n in evens) // THIS is when Where actually runs
    Console.WriteLine($"Got: {n}");

Output:

Query created. Nothing has been checked yet.
Now iterating:
Checking 1...
Checking 2...
Got: 2
Checking 3...
Checking 4...
Got: 4
Checking 5...

The Where call doesn't filter anything — it builds a recipe for filtering. The recipe only executes when you iterate with foreach, call ToList(), or use a method that needs a concrete result like Count().

This is called deferred execution, and it matters for two reasons:

1. The source can change between query creation and execution:

List<string> fruits = ["apple", "banana", "cherry"];

var longFruits = fruits.Where(f => f.Length > 5);

fruits.Add("dragonfruit"); // added AFTER the query was written

foreach (var f in longFruits)
    Console.WriteLine(f);
// banana, cherry, dragonfruit  ← dragonfruit is included!

2. The query runs again every time you iterate:

var expensiveQuery = products.Where(p => SlowDatabaseLookup(p));

// This runs the filter TWICE — once per loop
foreach (var p in expensiveQuery) { /* ... */ }
foreach (var p in expensiveQuery) { /* ... */ }

The fix? Call .ToList() or .ToArray() when you want to materialize the results and lock them in:

var results = products.Where(p => p.Price > 100).ToList(); // runs NOW, stores results

After ToList(), results is a regular List<T>. No more deferred execution, no surprises.

9. Common LINQ Methods — Quick Reference

You've seen Where, Select, OrderBy, GroupBy, and Take. Here's a cheat sheet of other methods you'll use constantly:

List<int> numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3];

// --- Finding elements ---
int first = numbers.First();              // 3 (first element, throws if empty)
int firstEven = numbers.First(n => n % 2 == 0);  // 4 (first matching)
int? maybe = numbers.FirstOrDefault(n => n > 100); // 0 (default for int, no match)

int single = new List<int>([42]).Single();  // 42 (exactly one element, throws if 0 or 2+)

// --- Testing conditions ---
bool anyBig = numbers.Any(n => n > 8);    // true (at least one > 8)
bool allPos = numbers.All(n => n > 0);    // true (every element > 0)
bool hasNine = numbers.Contains(9);       // true

// --- Aggregating ---
int count = numbers.Count();              // 10
int sum = numbers.Sum();                  // 39
double avg = numbers.Average();           // 3.9
int max = numbers.Max();                  // 9
int min = numbers.Min();                  // 1

// --- Reshaping ---
var unique = numbers.Distinct();          // 3, 1, 4, 5, 9, 2, 6
var top3 = numbers.Take(3);              // 3, 1, 4
var skip3 = numbers.Skip(3);             // 1, 5, 9, 2, 6, 5, 3
var page2 = numbers.Skip(3).Take(3);     // 1, 5, 9  (pagination!)

// --- Converting ---
List<int> asList = numbers.ToList();
int[] asArray = numbers.ToArray();
Dictionary<int, int> asDict = numbers
    .Distinct()
    .ToDictionary(n => n, n => n * n);    // { 3:9, 1:1, 4:16, 5:25, ... }

A few warnings:

  • First() and Single() throw exceptions on empty sequences. Use FirstOrDefault() and SingleOrDefault() when you're not sure the element exists.
  • Single() also throws if there are multiple matches. It's strict — "I expect exactly one result, and if that's not what I get, something is wrong."
  • Skip and Take together give you pagination. Skip(pageSize * pageIndex).Take(pageSize) is a pattern you'll use in every web app.

Homework

Build a Student Grade Analyzer. Create a list of at least 10 students with Name, Subject, and Grade properties:

record Student(string Name, string Subject, int Grade);

List<Student> students =
[
    new("Alice", "Math", 92),
    new("Alice", "Science", 88),
    new("Bob", "Math", 67),
    new("Bob", "Science", 72),
    new("Charlie", "Math", 85),
    // ... add more students and subjects
];

Then use LINQ to answer these questions (print each result):

  1. Honor Roll: All students who scored above 85 in any subject, sorted by grade descending
  2. Subject Averages: The average grade for each subject
  3. Top Performer: The student with the single highest grade across all subjects
  4. Failing Risk: Students who scored below 60 in any subject — show their name and the subject they're struggling in
  5. Student Report Cards: Group all grades by student name, showing each student's grades and their personal average
  6. Ranking: All unique student names, ordered alphabetically, with their overall average grade across all subjects

Bonus: Find which subject has the highest standard deviation in grades. You'll need to compute standard deviation manually with LINQ's Average, Select, and Sum. (Hint: variance = average of squared differences from the mean.)

Summary of Day 15

  • LINQ (Language Integrated Query) lets you filter, transform, sort, group, and aggregate collections using method chains instead of manual loops.
  • Where filters, Select transforms, OrderBy sorts, GroupBy categorizes.
  • Method syntax (.Where().Select()) is preferred over query syntax (from x in collection where ...) — it's more flexible and more common.
  • LINQ chains read like a pipeline: each method takes the previous result and passes its output downstream.
  • Deferred execution means LINQ queries don't run until you iterate them. Use .ToList() to force immediate execution.
  • First, Single, Any, All, Count, Sum, Average, Distinct, Take, and Skip are methods you'll reach for daily.
  • LINQ works on anything implementing IEnumerable<T> — lists, arrays, dictionaries, database queries, and more.

Tomorrow: we'll learn about delegates and lambda expressions — the secret sauce that makes LINQ work. You've been using lambdas all day (s => s.Grade > 80) without fully understanding them. Tomorrow, we open the hood. 🔧

See you on Day 16!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.