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,SingleAny,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
| Method | No match | Several matches |
|---|---|---|
First | Throws | Returns the first |
FirstOrDefault | Returns null | Returns the first |
Single | Throws | Throws |
SingleOrDefault | Returns null | Throws |
// 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 absent — Average 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
| Message | Cause | Fix |
|---|---|---|
System.InvalidOperationException: Sequence contains no elements | First() or Average() on an empty result | FirstOrDefault() and a null check; guard before averaging |
System.InvalidOperationException: Sequence contains more than one matching element | Single() matched several rows | Filter further, or use First deliberately |
System.NullReferenceException after FirstOrDefault | Used the result without checking for null | Check 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 MarksObtained | Filter absentees out first |
| Class average looks about 12% too low | Absentees counted as zero | Exclude them before averaging |
The last row produces no exception. It puts a wrong number on a report a principal reads.
Common mistakes
FirstwhereFirstOrDefaultand a null check belongFirstwithout ordering on data with several matches- A second
OrderByinstead ofThenBy Averageover an empty sequence?? 0inside an average, scoring absentees as zero- Absent students not excluded before aggregating
Count() > 0instead ofAny()- Enumerating a deferred query several times
- Forgetting
ToList()and re-querying accidentally - Omitting
SchoolIdfrom a filter - Chaining so long the query is unreadable — split it
Practice
- Rewrite a
foreach-and-iffilter asWhere, and compare readability. - Filter students to
10th-Aand active, in oneWhereand then in three chained ones. - Project students into a
StudentSummarythat excludesParentPhone. - Order students by class, then by name, using
ThenBy. Then use a secondOrderByand observe the difference. - Call
Firstfor a roll number that does not exist. Read the exception. - Rewrite with
FirstOrDefaultand a null check. - Give Ravi Kumar two fee accounts, query with
FirstOrDefault(a => a.StudentId == 12), and see which one you get. - Fix it by filtering on
SchoolId,StudentIdandAcademicYearwithSingleOrDefault. - Add a second account for the same year and confirm
Singlethrows. - Compare
Any()andCount() > 0on a large list. - Call
Allon an empty list and explain the result. - Compute a class average with
?? 0and then excluding absentees. Compare the two numbers for a class with five absentees out of forty. - Call
Averageon an empty sequence. Read the exception, then add the guard. - Call
Sumon an empty sequence and note that it does not throw. - Group students by class and print the count per class.
- Build the
ClassResultSummaryreport with appeared, absent and average. - Define a deferred query, add an item to the source, then enumerate. Confirm the new item appears.
- Enumerate a deferred query twice with a
Console.WriteLineinside the lambda, and count how many times it prints. - 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,FirstOrDefaultandSingle - 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
GroupByand a projection
Review questions
- When is
Firstthe right choice rather thanFirstOrDefault? - Why does
Average(r => r.MarksObtained ?? 0)produce a wrong class average? - What does deferred execution mean, and when does it cost you?
- Why is
Any()preferable toCount() > 0?
Next: Errors, files, and JSON