Skip to main content

LINQ: Toppers, Averages and Pass Rates in C# – C# | Lesson 50

Published / updated

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, and Take.
  • Calculate Count, Average, Max, and Min, including how nulls are treated.
  • Group with GroupBy and combine collections with Join.
  • Use Any, All, First, FirstOrDefault, and Single correctly.
  • 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:

Example
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)LINQPurpose
WHEREWhereKeep matching items
SELECT colSelectShape each item
ORDER BY ... DESCOrderByDescending, then ThenBySort
TOP (5)Take(5)Keep the first few
GROUP BYGroupByMake groups
JOIN ... ONJoinCombine two sources
COUNT, AVG, MAX, MIN, SUMCount, Average, Max, Min, SumAggregate
EXISTSAnyIs 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
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"}");
Output
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 syntaxMethod syntax
from m in marks where m.Subject == "Science" select mmarks.Where(m => m.Subject == "Science")
Easier for joins and several sourcesShorter 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​

MethodNo matchMore than one match
FirstThrows: "Sequence contains no matching element"Returns the first
FirstOrDefaultReturns default (null for classes)Returns the first
SingleThrows: "Sequence contains no matching element"Throws: "Sequence contains more than one matching element"
SingleOrDefaultReturns defaultThrows

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:

QueryResult 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:

HabitWhy
Build a dictionary with ToDictionary before looking items up in a loopOne lookup instead of a search per item (Lesson 47)
Call ToList() once when results are reusedNo repeated enumeration (Lesson 49)
Filter before sorting and projectingLess 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​

  1. First when nothing may match. It throws. Fix: FirstOrDefault, then check for null.
  2. First when exactly one should match. Duplicates go unnoticed. Fix: Single.
  3. Sorting without a tie-breaker. "Top 5" changes between runs. Fix: add ThenBy.
  4. Counting with Count() > 0. Fix: Any(), which stops early.
  5. Returning anonymous types from methods. Callers cannot name the type. Fix: a record.

In real projects​

From 16 years of .NET 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 their OrDefault versions 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, and OrderBy.
  • Average, Max, and Min skip nulls, like SQL's aggregates.
  • GroupBy makes groups with a Key; Join combines sources on a matching value.
  • First and Single throw when nothing matches; the OrDefault versions return default.
  • 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:

Output
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-assignment/050-library-linq.cs
// 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​

  1. Which LINQ method matches SQL's WHERE?

    1. Select
    2. Where
    3. OrderBy
    4. GroupBy
    Show answer

    Answer: B. Where Where keeps the items that match a condition.

  2. Twenty students were absent. What does Average do with their null marks?

    1. Counts them as 0
    2. Skips them, like SQL's AVG
    3. Throws an exception
    4. Returns null
    Show answer

    Answer: B. Skips them, like SQL's AVG The result, 60.23, matches SQL exactly.

  3. Which method throws if two items match?

    1. First
    2. FirstOrDefault
    3. Single
    4. Any
    Show answer

    Answer: C. Single Single expects exactly one match.

  4. Why add ThenBy(m => m.AdmissionNumber) after OrderByDescending(m => m.MarksObtained)?

    1. It is faster
    2. To give tied marks a fixed order
    3. To remove duplicates
    4. It is required
    Show answer

    Answer: B. To give tied marks a fixed order Without a tie-breaker, the top five can change between runs.

  5. In a GroupBy result, what is g.Key?

    1. The first item
    2. The value the group was made by, such as the subject
    3. The group's count
    4. An index
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.