LINQ: Toppers, Averages and Pass Rates in C# – C# | Lesson 50
Introduction
In Module 4, you asked SchoolDb questions in SQL: toppers, averages, counts per group. LINQ (Language Integrated Query) lets you ask the same kinds of questions in C#, over any collection: lists, arrays, files, and, in Module 9, the database itself.
This lesson answers real school questions over the 12,012 Class 10 marks. Every number in the output was checked against the same query in SQL Server, and every one matched.
By the end of this lesson, you will be able to:
- Filter, shape, and sort data with
Where,Select,OrderBy, andTake. - Calculate
Count,Average,Max, andMin, including how nulls are treated. - Group with
GroupByand combine collections withJoin. - Use
Any,All,First,FirstOrDefault, andSinglecorrectly. - Read both LINQ styles: method syntax and query syntax.
Why this matters
Before LINQ, every one of these questions was a hand-written loop with counters and temporary lists, like the topper loop from Lesson 33. LINQ says what you want; .NET works out how.
LINQ is also how EF Core queries the database in Module 9. The Where and OrderBy you
write in this lesson are the same calls that will become WHERE and ORDER BY in SQL.
Learn it once, use it on lists and databases alike.
The concept
Example: a pipeline of steps
A LINQ query is a chain of methods. Each takes a sequence and passes a new sequence on:
var top = marks
.Where(m => m.Subject == "Mathematics")
.OrderByDescending(m => m.MarksObtained)
.Take(5);
Think of the exam cell sorting answer sheets. One teacher removes every subject except Mathematics, the next stacks them from highest to lowest, and the last takes the top five. Each person does one simple job and hands the pile on.
Example: LINQ next to SQL
| SQL (Module 4) | LINQ | Purpose |
|---|---|---|
WHERE | Where | Keep matching items |
SELECT col | Select | Shape each item |
ORDER BY ... DESC | OrderByDescending, then ThenBy | Sort |
TOP (5) | Take(5) | Keep the first few |
GROUP BY | GroupBy | Make groups |
JOIN ... ON | Join | Combine two sources |
COUNT, AVG, MAX, MIN, SUM | Count, Average, Max, Min, Sum | Aggregate |
EXISTS | Any | Is there at least one? |
The order of steps differs: in SQL you write SELECT first; in LINQ, you filter first and
shape at the end, in the order the work happens.
Example: anonymous types
Select(m => new { Name = ..., m.MarksObtained }) creates an anonymous type: a small
object with the named properties, without declaring a class. The compiler names it for you,
so the variable must be declared with var.
Anonymous types are perfect for the middle of a query. When a result leaves a method, use a
DTO record instead, so callers know its shape. Anonymous types print their properties, and
their Equals method compares values, though == on them still compares references.
Apply it to the SMS
The program loads the marks with ToList() once (Lesson 49), and builds a dictionary of
students with ToDictionary, a LINQ method that replaces the loop from Lesson 47.
ToDictionary(e => e.Student!.AdmissionNumber, e => e.Student!) takes two small functions:
the first picks each entry's key, the second its value. Like Dictionary.Add, it throws if
two entries share a key, so duplicate admission numbers would be caught at once.
Show src/SchoolApp.Console/Program.cs
// Lesson 50: LINQ on 12,012 real Class 10 marks and 1,006 students.
using SchoolApp.Core.Entities;
using SchoolApp.Core.Rules;
List<MarkRecord> marks = DataFiles.ReadMarks().ToList();
List<Enrollment> enrollments = DataFiles.LoadClass10Enrollments();
Dictionary<string, Student> students = enrollments.ToDictionary(e => e.Student!.AdmissionNumber, e => e.Student!);
// 1. Where + Select: filter, then shape. Unit Test 1, Mathematics only.
List<MarkRecord> mathsUt1 = marks
.Where(m => m.Exam == "Unit Test 1" && m.Subject == "Mathematics")
.ToList();
Console.WriteLine($"Mathematics, Unit Test 1: {mathsUt1.Count} entries, {mathsUt1.Count(m => m.MarksObtained is null)} absent");
// 2. Aggregates. Average, Max, and Min skip nulls, just like SQL's AVG, MAX, and MIN.
Console.WriteLine($"Average {mathsUt1.Average(m => m.MarksObtained):F2}, highest {mathsUt1.Max(m => m.MarksObtained)}, lowest {mathsUt1.Min(m => m.MarksObtained)}");
// 3. OrderByDescending + ThenBy + Take: the top 5, with names from the student dictionary.
var toppers = mathsUt1
.Where(m => m.MarksObtained is not null)
.OrderByDescending(m => m.MarksObtained)
.ThenBy(m => m.AdmissionNumber)
.Take(5)
.Select(m => new { Name = students[m.AdmissionNumber].FullName, m.MarksObtained });
Console.WriteLine("Top 5: " + string.Join(", ", toppers.Select(t => $"{t.Name} {t.MarksObtained}")));
// 4. GroupBy: average and pass rate per subject (school-wide pass mark: 33%).
var bySubject = marks
.Where(m => m.Exam == "Unit Test 1" && m.MarksObtained is not null)
.GroupBy(m => m.Subject)
.Select(g => new
{
Subject = g.Key,
Average = g.Average(m => m.MarksObtained!.Value),
PassRate = 100.0 * g.Count(m => SchoolRules.HasPassed(m.MarksObtained!.Value / m.MaxMarks * 100)) / g.Count()
})
.OrderByDescending(s => s.Average);
foreach (var subject in bySubject)
{
Console.WriteLine($" {subject.Subject,-16} average {subject.Average,6:F2}, passed {subject.PassRate,5:F1}%");
}
// 5. Join: Meera's marks, matched to her name by admission number.
var meera = from m in marks
join e in enrollments on m.AdmissionNumber equals e.Student!.AdmissionNumber
where e.Student!.FirstName == "Meera" && e.Section!.Name == "A"
orderby m.Exam, m.Subject
select new { e.Student!.FullName, m.Exam, m.Subject, m.MarksObtained };
Console.WriteLine("Meera Iyer: " + string.Join("; ", meera.Select(x => $"{x.Exam}/{x.Subject} {x.MarksObtained?.ToString() ?? "absent"}")));
// 6. Students per section, the biggest first.
var sections = enrollments
.GroupBy(e => e.Section!.Name)
.Select(g => new { Section = g.Key, Students = g.Count() })
.OrderByDescending(s => s.Students)
.ThenBy(s => s.Section)
.Take(3);
Console.WriteLine("Largest sections: " + string.Join(", ", sections.Select(s => $"10-{s.Section} ({s.Students})")));
// 7. Any, All, Single, FirstOrDefault.
Console.WriteLine($"Any absent: {marks.Any(m => m.MarksObtained is null)}; all within maximum: {marks.All(m => m.MarksObtained is null || m.MarksObtained <= m.MaxMarks)}");
Student kabir = enrollments.Select(e => e.Student!).Single(s => s.AdmissionNumber == "ADM-2017-0003");
Student? nobody = enrollments.Select(e => e.Student!).FirstOrDefault(s => s.AdmissionNumber == "ADM-2099-0001");
Console.WriteLine($"Single: {kabir.FullName}; FirstOrDefault for a missing number: {nobody?.FullName ?? "null"}");
Mathematics, Unit Test 1: 1006 entries, 20 absent
Average 60.23, highest 100.00, lowest 20.00
Top 5: Aisha Reddy 100.00, Aditya Joshi 100.00, Bhavya Singh 100.00, Rahul Mishra 100.00, Divya Verma 100.00
Social Studies average 60.28, passed 84.6%
Computer Science average 60.27, passed 84.6%
Mathematics average 60.23, passed 84.2%
Science average 59.93, passed 83.8%
Hindi average 59.82, passed 83.6%
English average 59.66, passed 83.6%
Meera Iyer: Unit Test 1/Mathematics 95.00; Unit Test 1/Science absent
Largest sections: 10-C (50), 10-D (50), 10-E (50)
Any absent: True; all within maximum: True
Single: Kabir Singh; FirstOrDefault for a missing number: null
Example: aggregates and nulls
mathsUt1.Average(m => m.MarksObtained) returned 60.23, the same as SQL's AVG. The 20
absent students have null marks, and Average skipped them, exactly as AVG skips NULL
(Lesson 18). Max and Min skip nulls too.
Count(m => m.MarksObtained is null) counted the absent students: a count with a condition,
like SUM(CASE WHEN ... THEN 1 ELSE 0 END) in SQL.
Example: toppers with OrderBy, ThenBy, Take
Many students scored 100, so ThenBy(m => m.AdmissionNumber) breaks the ties in a fixed
order. Without it, the "top 5" could change from run to run. The same five names came back
from SQL with ORDER BY MarksObtained DESC, AdmissionNumber.
Select then turned each mark into a name and a score, looking the name up in the students
dictionary.
Example: GroupBy and pass rates
GroupBy(m => m.Subject) made six groups, one per subject. Inside Select, g.Key is the
subject and g itself is the sequence of that subject's marks, so g.Average(...) and
g.Count(...) work per group.
The pass rate uses SchoolRules.HasPassed from Lesson 42, so C# and the course share one
school-wide 33% rule. Social Studies leads with an average of 60.28 and 84.6% passing; every
figure matches the GROUP BY query in SQL to the decimal.
Example: Join and query syntax
Meera's marks were found by joining marks to enrollments on the admission number, written in
query syntax: from, join, where, orderby, select. It reads like SQL, and the
compiler turns it into the same method calls.
| Query syntax | Method syntax |
|---|---|
from m in marks where m.Subject == "Science" select m | marks.Where(m => m.Subject == "Science") |
| Easier for joins and several sources | Shorter for simple chains; has every operator |
Most teams use method syntax by default and query syntax for joins. Meera's Science mark shows as absent, the same NULL first met in Lesson 35.
Example: First, FirstOrDefault, Single
| Method | No match | More than one match |
|---|---|---|
First | Throws: "Sequence contains no matching element" | Returns the first |
FirstOrDefault | Returns default (null for classes) | Returns the first |
Single | Throws: "Sequence contains no matching element" | Throws: "Sequence contains more than one matching element" |
SingleOrDefault | Returns default | Throws |
Single is the right choice for admission numbers: exactly one student should match, and if
two ever do, the data is wrong and you want to know. FirstOrDefault suits "find if it exists",
such as a number typed into a search box.
Example: Any and All
Any(m => m.MarksObtained is null) asks "is there at least one absent mark?", and stops at
the first one (Lesson 49). All(...) checks that every mark is within its maximum, a quick
data-quality test that SchoolDb's trigger from Lesson 23 also guarantees.
Example: SelectMany flattens
Select gives one result per item. When each item holds a list, such as a section holding its
enrollments, Select gives a list of lists. SelectMany flattens them into one sequence:
| Query | Result for Class 10 |
|---|---|
sections.Select(s => s.Enrollments) | 22 lists, one per section |
sections.SelectMany(s => s.Enrollments) | 1,006 enrollments in one sequence |
Think of collecting the attendance registers of 22 sections and stapling all their pages into one long register, so one person can count every student in Class 10 at once.
Example: Distinct, Skip, and Take
Distinct() removes duplicates: the 12,012 real marks contain exactly 6 different subjects.
Skip(20).Take(10) returns items 21 to 30, which is page 3 of a list of ten per page. For the
admission numbers, that page runs from ADM-2026-09015 to ADM-2026-09024.
Skip and Take are how Module 11 will page through students in the database; Paging.ToPage
from Lesson 48 did the same job on a list. EF Core turns them into SQL's OFFSET and FETCH.
Example: LINQ and performance
LINQ is fast enough for nearly everything, but three habits keep it that way:
| Habit | Why |
|---|---|
Build a dictionary with ToDictionary before looking items up in a loop | One lookup instead of a search per item (Lesson 47) |
Call ToList() once when results are reused | No repeated enumeration (Lesson 49) |
| Filter before sorting and projecting | Less data flows through every later step |
A clear LINQ query that runs in milliseconds is better than a clever hand-written loop that nobody else can read. Optimise only what you have measured.
Common mistakes
Firstwhen nothing may match. It throws. Fix:FirstOrDefault, then check for null.Firstwhen exactly one should match. Duplicates go unnoticed. Fix:Single.- Sorting without a tie-breaker. "Top 5" changes between runs. Fix: add
ThenBy. - Counting with
Count() > 0. Fix:Any(), which stops early. - Returning anonymous types from methods. Callers cannot name the type. Fix: a record.
In real projects
A results screen once showed different "rank 1" students each time it was refreshed. Five students had the top mark, and the query sorted only by mark.
Adding a tie-breaker fixed the display, and the school then asked for a real rule: equal
marks share a rank, as DENSE_RANK did in Lesson 20.
LINQ made the rule easy to express, but the lesson was about the question, not the code. Ask what should happen on a tie before writing any sort.
Best practices
- Write queries in the order the work happens: filter, then sort, then shape.
- Always add a tie-breaker to sorts that users see.
- Choose
Single,First, or theirOrDefaultversions by what a missing or duplicate match means. - Use anonymous types inside a query, and records for results that leave a method.
- Check important results against the database the first time, as this lesson did.
Recap
- LINQ queries any collection with chained methods such as
Where,Select, andOrderBy. Average,Max, andMinskip nulls, like SQL's aggregates.GroupBymakes groups with aKey;Joincombines sources on a matching value.FirstandSinglethrow when nothing matches; theOrDefaultversions returndefault.- Query syntax and method syntax compile to the same calls.
- Every result in this lesson matched SQL Server: average 60.23, 20 absent, the same top five.
Homework
Library module task: Put the library's real rows in lists: three books, four copies, and two issues. With LINQ, list the available copies per title, the books currently on loan with the borrower's name, and the number of books per category.
A correct result looks like:
Available: Malgudi Days (1), Wings of Fire (2)
On loan: The Jungle Book with Advika Sharma
Per category: Biography: 1, Fiction: 2
Show the solution
// Library homework, Lesson 50: LINQ over the library's real rows in SchoolDb.
// Run with: dotnet run library-assignment/050-library-linq.cs
List<Book> books =
[
new Book(1, "Wings of Fire", "Biography"),
new Book(2, "The Jungle Book", "Fiction"),
new Book(3, "Malgudi Days", "Fiction"),
];
List<Copy> copies =
[
new Copy(1, 1, 1, "Available"),
new Copy(2, 1, 2, "Available"),
new Copy(3, 2, 1, "Issued"),
new Copy(4, 3, 1, "Available"),
];
List<Issue> issues =
[
new Issue(1, 2, "Aarav Sharma", new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 14)),
new Issue(2, 3, "Advika Sharma", new DateOnly(2026, 7, 6), null),
];
// 1. Available copies per title (GroupBy after a join).
var available = from c in copies
join b in books on c.BookId equals b.Id
where c.Status == "Available"
group c by b.Title into g
orderby g.Key
select $"{g.Key} ({g.Count()})";
Console.WriteLine("Available: " + string.Join(", ", available));
// 2. Open issues, with the title (two joins in method syntax).
var open = issues
.Where(i => i.ReturnedOn is null)
.Join(copies, i => i.CopyId, c => c.Id, (i, c) => new { i.StudentName, c.BookId })
.Join(books, x => x.BookId, b => b.Id, (x, b) => $"{b.Title} with {x.StudentName}");
Console.WriteLine("On loan: " + string.Join(", ", open));
// 3. Books per category.
var perCategory = books.GroupBy(b => b.Category).Select(g => $"{g.Key}: {g.Count()}");
Console.WriteLine("Per category: " + string.Join(", ", perCategory));
record Book(int Id, string Title, string Category);
record Copy(int Id, int BookId, int CopyNumber, string Status);
record Issue(int Id, int CopyId, string StudentName, DateOnly IssuedOn, DateOnly? ReturnedOn);
The lists are written with collection expressions, the [ ... ] syntax from C# 12, which
Lesson 52 covers. The first query uses query syntax with group ... into, and the second chains
two Join calls in method syntax.
Quiz
Which LINQ method matches SQL's WHERE?
Show answer
Answer: B. Where Where keeps the items that match a condition.
Twenty students were absent. What does Average do with their null marks?
Show answer
Answer: B. Skips them, like SQL's AVG The result, 60.23, matches SQL exactly.
Which method throws if two items match?
Show answer
Answer: C. Single Single expects exactly one match.
Why add ThenBy(m => m.AdmissionNumber) after OrderByDescending(m => m.MarksObtained)?
Show answer
Answer: B. To give tied marks a fixed order Without a tie-breaker, the top five can change between runs.
In a GroupBy result, what is g.Key?
Show answer
Answer: B. The value the group was made by, such as the subject The group itself is the sequence of items with that key.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.
Fresher (0-2 yrs)
F What is LINQ? concept
Language Integrated Query: a set of C# methods and syntax for querying any collection, such as
Where, Select, OrderBy, and GroupBy. The same queries work on lists, files, and, through EF
Core, databases.
F What is the difference between Where and Select? concept
Where filters: it keeps items that match a condition. Select projects: it turns each item into
a new shape, such as a name and a mark. Filter first, then shape.
F What is the difference between First, FirstOrDefault, and Single? concept
First returns the first match and throws if there is none. FirstOrDefault returns default
(null for classes) instead of throwing. Single expects exactly one match and throws for none or
for more than one.
Mid (3-5 yrs)
M How does LINQ's Average treat null values? concept
For nullable values, Average, Max, and Min skip nulls, like SQL's AVG. The Class 10
Mathematics average of 60.23 ignored 20 absent students, matching SQL Server exactly.
M How does GroupBy work in LINQ? coding
GroupBy(m => m.Subject) returns groups; each has a Key (the subject) and is itself the sequence
of items with that key, so g.Average(...) and g.Count() work per group, like SQL's GROUP BY.
M What is the difference between Select and SelectMany? concept
Select returns one result per item, so a list of sections with enrollments becomes a list of
lists. SelectMany flattens those inner lists into one sequence: 22 sections become 1,006
enrollments.
Senior (6-9 yrs)
S A results screen shows a different rank-1 student on every refresh. What is wrong? scenario
Several students share the top mark and the sort has no tie-breaker, so their order is not fixed.
Add ThenBy on a stable key, and agree a ranking rule with the school, such as equal marks
sharing a rank (DENSE_RANK).
How to answer: Explain non-deterministic ordering, the technical fix, and the business question behind it. Mention the same issue in SQL ORDER BY without a unique key.
S Query syntax or method syntax: which do you use? concept
Both compile to the same calls. Method syntax is shorter for simple chains and offers every operator; query syntax reads more easily for joins and multiple sources. Many teams default to method syntax and use query syntax for complex joins.
How to answer: Show you can read and write both, then state a consistent team preference and its reason.
Architect / Lead (10+ yrs)
A When does LINQ hurt performance, and how do you prevent it in a team? design
Through searches inside loops instead of dictionaries, repeated enumeration of deferred queries, materializing database queries too early, and sorting before filtering. Prevent it with code review rules, analyzers for multiple enumeration, logging of generated SQL, and measuring hot paths.
How to answer: Give concrete anti-patterns with fixes, then the team-level practices. Emphasize measuring before replacing readable LINQ with hand-written loops.
Resources
- Code for this lesson: git tag
lesson-50-linq(code repository coming soon) - Files:
src/SchoolApp.Console/Program.cs,src/SchoolApp.Console/DataFiles.cs,library-assignment/050-library-linq.cs - Video: coming soon
What's next
Every LINQ call in this lesson took a small function as its argument, such as m => m.MarksObtained > 95.
Next: what those functions really are, delegates and lambdas, and how events let one object
tell others that something happened. Lesson 51: Delegates and events
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.