Skip to main content
Published / updated

LINQ Fundamentals

Before you start

You need: collections, generics and delegates (Article 05). If Func<Student, bool> does not look familiar, go back — every LINQ method takes one.

Time: about 50 minutes, plus the practice.

Learning objective

Replace hand-written loops with LINQ queries that read like the question being asked, and know where LINQ hides real bugs.

Topics

  • What LINQ is, and lambda syntax
  • Filtering with Where
  • Projection with Select
  • Ordering
  • First, FirstOrDefault, Single
  • Any, All, Count
  • Aggregation — Sum, Average, Min, Max
  • Grouping
  • Deferred execution
  • Nulls in aggregates

What LINQ is

LINQ is a set of methods for querying collections. The same operations you would write as loops, expressed in one line each.

// By hand
List<Student> activeStudents = new List<Student>();

foreach (Student student in students)
{
if (student.Status == StudentStatus.Active)
{
activeStudents.Add(student);
}
}

// With LINQ
List<Student> activeStudents = students
.Where(s => s.Status == StudentStatus.Active)
.ToList();

Both are correct. The second says what it wants rather than how to get it, and that is the whole benefit — a reader sees the intent without simulating the loop.

s => s.Status == StudentStatus.Active is a lambda — a small unnamed function. Read => here as "goes to": take s, return whether its status is Active. This is the one place => is used in this track; it is not an expression-bodied method.

That lambda is a Func<Student, bool> — the delegate type from Article 05. Every LINQ method takes a delegate and applies it to each item, which is why Where can filter on anything you can express as a test.

using System.Linq;

ImplicitUsings in a modern project adds this for you.

Filtering

List<Student> tenthA = students
.Where(s => s.ClassName == "10th" && s.Section == "A")
.ToList();

List<Student> activeInSchool = students
.Where(s => s.SchoolId == currentSchoolId)
.Where(s => s.Status == StudentStatus.Active)
.ToList();

List<FeeAccount> overdue = accounts
.Where(a => a.DueDate < DateTime.UtcNow)
.Where(a => a.TotalFees - a.DiscountAmount - a.PaidAmount > 0)
.ToList();

Chained Where calls are equivalent to one with &&. Chain them when each condition means something separate — the overdue example reads as two distinct business rules.

SchoolId belongs in the filter of every query in a multi-tenant system. It is a habit worth forming here, in memory, before it matters in SQL — where forgetting it means one school reading another's records.

Projection

List<string> names = students
.Select(s => s.Name)
.ToList();

List<string> labels = students
.Select(s => $"{s.Name} ({s.RollNumber})")
.ToList();
public class StudentSummary
{
public string Name { get; set; }
public string RollNumber { get; set; }
public string ClassName { get; set; }
}

List<StudentSummary> summaries = students
.Where(s => s.Status == StudentStatus.Active)
.Select(s => new StudentSummary
{
Name = s.Name,
RollNumber = s.RollNumber,
ClassName = s.ClassName
})
.ToList();

Select reshapes each item into something else. Projecting into a summary class rather than passing whole Student objects around is exactly how a DTO works in the API track — and it is how you avoid exposing a Salary or a ParentPhone to a screen that should not show it.

SelectMany flattens nested collections:

List<ExamResult> allResults = students
.SelectMany(s => s.ExamResults)
.ToList();

Ordering

List<Student> byRoll = students
.OrderBy(s => s.RollNumber)
.ToList();

List<ExamResult> topFirst = results
.OrderByDescending(r => r.MarksObtained)
.ToList();

List<Student> byClassThenName = students
.OrderBy(s => s.ClassName)
.ThenBy(s => s.Name)
.ToList();

Use ThenBy, not a second OrderBy. A second OrderBy discards the first ordering entirely — a subtle bug that produces a plausible-looking but wrong sort.

First, FirstOrDefault, Single

MethodNo matchSeveral matches
FirstThrowsReturns the first
FirstOrDefaultReturns nullReturns the first
SingleThrowsThrows
SingleOrDefaultReturns nullThrows
// Throws InvalidOperationException: Sequence contains no elements
Student student = students.First(s => s.RollNumber == "NCA-2024-9999");

// Returns null — check it
Student student = students.FirstOrDefault(s => s.RollNumber == "NCA-2024-9999");

if (student == null)
{
Console.WriteLine("No student with that roll number.");
return;
}

Console.WriteLine(student.Name);

FirstOrDefault plus a null check is the default choice. First is right only when a missing item genuinely is a bug worth crashing for.

Single when exactly one must exist. A student's fee account for one academic year should be unique — Single turns a duplicate into an immediate, loud failure rather than a silently arbitrary pick.

FeeAccount account = accounts
.Where(a => a.SchoolId == schoolId)
.Where(a => a.StudentId == studentId)
.Where(a => a.AcademicYear == currentAcademicYear)
.SingleOrDefault();

First without an ordering, on data that may hold several matches, returns an arbitrary item. Ravi Kumar with a 2023–24 and a 2024–25 fee account, queried with FirstOrDefault(a => a.StudentId == 12), returns whichever the collection happens to hold first — and a paid-amount of ₹0 from last year. The fix is to filter fully, as above.

Any, All, Count

bool hasOverdue = accounts.Any(a => a.DueDate < DateTime.UtcNow);
bool allPaid = accounts.All(a => a.PaidAmount >= a.TotalFees - a.DiscountAmount);
bool anyStudents = students.Any();

int activeCount = students.Count(s => s.Status == StudentStatus.Active);

Use Any() rather than Count() > 0. Any stops at the first match; Count walks the whole collection. On a list it is a small difference; on a database query in the EF Core track it is the difference between one row and a full table scan.

All on an empty collection returns true. Vacuously — there is nothing that fails the test. That is mathematically correct and occasionally surprising: "all fees paid" reports true for a class with no fee accounts at all.

Aggregation

decimal totalCollected = payments.Sum(p => p.Amount);
decimal highestFee = accounts.Max(a => a.TotalFees);
decimal lowestFee = accounts.Min(a => a.TotalFees);

Averages are where LINQ meets the absent-student problem.

// WRONG — treats an absent student as having scored 0
double wrongAverage = results.Average(r => r.MarksObtained ?? 0);

// CORRECT — exclude absentees before averaging
double correctAverage = results
.Where(r => !r.IsAbsent)
.Where(r => r.MarksObtained.HasValue)
.Average(r => r.MarksObtained.Value);

A class of 40 with 5 absent students: the wrong version divides the marks of 35 students by 40 and reports an average roughly 12% too low. No exception, no warning — a wrong number on a report someone acts on.

Average and Sum over an empty sequence:

// Throws InvalidOperationException: Sequence contains no elements
double average = new List<ExamResult>().Average(r => r.MarksObtained.Value);

// Returns 0 — Sum is safe where Average is not
decimal total = new List<FeePayment>().Sum(p => p.Amount);
List<ExamResult> present = results.Where(r => !r.IsAbsent).ToList();

if (present.Count == 0)
{
Console.WriteLine("No students appeared for this exam.");
return;
}

double average = present.Average(r => r.MarksObtained.Value);

Check for empty before averaging. Always.

Grouping

IEnumerable<IGrouping<string, Student>> byClass = students
.Where(s => s.Status == StudentStatus.Active)
.GroupBy(s => s.ClassName);

foreach (IGrouping<string, Student> group in byClass)
{
Console.WriteLine($"{group.Key}: {group.Count()} students");
}
8th: 62 students
9th: 58 students
10th: 71 students

Each group has a Key — the value grouped on — and the items themselves.

public class ClassResultSummary
{
public string ClassName { get; set; }
public int AppearedCount { get; set; }
public int AbsentCount { get; set; }
public double AverageMarks { get; set; }
}

List<ClassResultSummary> summaries = results
.GroupBy(r => r.Student.ClassName)
.Select(g => new ClassResultSummary
{
ClassName = g.Key,
AppearedCount = g.Count(r => !r.IsAbsent),
AbsentCount = g.Count(r => r.IsAbsent),
AverageMarks = g.Where(r => !r.IsAbsent).Average(r => r.MarksObtained.Value)
})
.OrderBy(s => s.ClassName)
.ToList();

This is a real report in eight lines, and it counts absentees separately rather than scoring them zero.

It still fails on a class where everyone was absentAverage over an empty sequence. Production code guards that; recognising the risk is the skill.

Grouping by more than one field:

var byClassAndSection = students.GroupBy(s => new { s.ClassName, s.Section });

This is the one place var is unavoidable. new { s.ClassName, s.Section } creates an anonymous type, which has no name you can write down — so the compiler must infer it. Everywhere else in this track, write the type out.

Deferred execution

A LINQ query is not run when you write it. It runs when you enumerate it.

IEnumerable<Student> query = students.Where(s => s.Status == StudentStatus.Active);

students.Add(new Student { Name = "Kiran Rao", Status = StudentStatus.Active });

List<Student> result = query.ToList(); // Kiran Rao IS included

The query was defined before Kiran was added and still sees him, because it evaluated at ToList().

IEnumerable<Student> query = students.Where(s => s.Status == StudentStatus.Active);

int firstCount = query.Count(); // walks the collection
int secondCount = query.Count(); // walks it again

Enumerating twice runs the query twice. In memory that is wasted work; against a database it is two round trips.

List<Student> activeStudents = students
.Where(s => s.Status == StudentStatus.Active)
.ToList(); // executes once, here

int count = activeStudents.Count;
Student first = activeStudents[0];

Call ToList() once when you will use the results more than once. Leave the query deferred when you will pass it on and let the caller decide.

ToList, ToArray, ToDictionary, Count, Sum, First, Any and a foreach all force execution. Where, Select, OrderBy and GroupBy do not.

Query syntax

IEnumerable<string> query = from student in students
where student.Status == StudentStatus.Active
orderby student.RollNumber
select student.Name;

Identical in meaning to the method chain. Method syntax is far more common in .NET code, and it is what the rest of these tracks use. Recognise query syntax; write method syntax.

Errors you will hit

MessageCauseFix
System.InvalidOperationException: Sequence contains no elementsFirst() or Average() on an empty resultFirstOrDefault() and a null check; guard before averaging
System.InvalidOperationException: Sequence contains more than one matching elementSingle() matched several rowsFilter further, or use First deliberately
System.NullReferenceException after FirstOrDefaultUsed the result without checking for nullCheck it
CS1061: does not contain a definition for 'Where'Missing using System.Linq;Add it
System.InvalidOperationException: Nullable object must have a value.Value on a null MarksObtainedFilter absentees out first
Class average looks about 12% too lowAbsentees counted as zeroExclude them before averaging

The last row produces no exception. It puts a wrong number on a report a principal reads.

Common mistakes

  • First where FirstOrDefault and a null check belong
  • First without ordering on data with several matches
  • A second OrderBy instead of ThenBy
  • Average over an empty sequence
  • ?? 0 inside an average, scoring absentees as zero
  • Absent students not excluded before aggregating
  • Count() > 0 instead of Any()
  • Enumerating a deferred query several times
  • Forgetting ToList() and re-querying accidentally
  • Omitting SchoolId from a filter
  • Chaining so long the query is unreadable — split it

Practice

  1. Rewrite a foreach-and-if filter as Where, and compare readability.
  2. Filter students to 10th-A and active, in one Where and then in three chained ones.
  3. Project students into a StudentSummary that excludes ParentPhone.
  4. Order students by class, then by name, using ThenBy. Then use a second OrderBy and observe the difference.
  5. Call First for a roll number that does not exist. Read the exception.
  6. Rewrite with FirstOrDefault and a null check.
  7. Give Ravi Kumar two fee accounts, query with FirstOrDefault(a => a.StudentId == 12), and see which one you get.
  8. Fix it by filtering on SchoolId, StudentId and AcademicYear with SingleOrDefault.
  9. Add a second account for the same year and confirm Single throws.
  10. Compare Any() and Count() > 0 on a large list.
  11. Call All on an empty list and explain the result.
  12. Compute a class average with ?? 0 and then excluding absentees. Compare the two numbers for a class with five absentees out of forty.
  13. Call Average on an empty sequence. Read the exception, then add the guard.
  14. Call Sum on an empty sequence and note that it does not throw.
  15. Group students by class and print the count per class.
  16. Build the ClassResultSummary report with appeared, absent and average.
  17. Define a deferred query, add an item to the source, then enumerate. Confirm the new item appears.
  18. Enumerate a deferred query twice with a Console.WriteLine inside the lambda, and count how many times it prints.
  19. Write one query in both query syntax and method syntax.

Exercise 12 is the one whose consequence is a wrong number on a real report card.

You can now

  • Filter, project, order and group with LINQ
  • Choose correctly between First, FirstOrDefault and Single
  • Exclude absent students before averaging, and say why
  • Guard against an empty sequence before aggregating
  • Explain deferred execution and when it costs you
  • Build a class report with GroupBy and a projection

Review questions

  1. When is First the right choice rather than FirstOrDefault?
  2. Why does Average(r => r.MarksObtained ?? 0) produce a wrong class average?
  3. What does deferred execution mean, and when does it cost you?
  4. Why is Any() preferable to Count() > 0?

Next: Errors, files, and JSON