C# & .NET (C# 14, .NET 10 LTS) Module 14 – LINQ
LINQ projection, filtering, sorting and paging
Shape C# data with Select and SelectMany, filter it with Where, sort it by several keys with OrderBy and ThenBy, and page it with Skip and Take.
What you will learn
- Transform items with Select and SelectMany into anonymous types, tuples and records
- Remove repeats and pick extremes by a key with DistinctBy, MinBy and MaxBy
- Sort by several keys with OrderBy, ThenBy and a comparer, and predict where ties land
- Split a sequence into pages with Skip, Take and Chunk without repeating or skipping items
Before you start
What you’ll learn · Before you start
What you will learn
- Transform items with Select and SelectMany into anonymous types, tuples and records
- Remove repeats and pick extremes by a key with DistinctBy, MinBy and MaxBy
- Sort by several keys with OrderBy, ThenBy and a comparer, and predict where ties land
- Split a sequence into pages with Skip, Take and Chunk without repeating or skipping items
Before you start
On this page
A booking screen receives 3 bookings that hold 7 passengers, and must show the adults as one numbered list, by coach
and then by name, 4 to a page. Each of those jobs is one LINQ operator or two: projection: Making a new value out of each item of a sequence, such as a passenger's name or a new record, with Select or SelectMany. The items themselves do not change.
with Select and SelectMany, filtering with Where, sorting with OrderBy and ThenBy, and paging with Skip
and Take.
The runs below do all four on those bookings. You will flatten lists of lists, shape results into types that can leave a method, sort by two keys and cut pages, and then see where each of these operators goes wrong.
Select reshapes each item, and SelectMany flattens lists of lists
LINQ projection makes new values out of the items of a sequence and leaves the items as they were. Select makes
exactly one result per item. With bookings that each hold a list of passengers, one result per booking is a list,
so SelectMany exists: it hands on the items of every list as one flat sequence. flatten.cs does both:
// Three bookings hold seven passengers. The bookings are made up for this lesson.
Booking[] bookings =
[
new("PNR-4521", "B2", [new("Asha", 34), new("Ravi", 36)]),
new("PNR-4522", "B1", [new("Meera", 61), new("Kabir", 8), new("Isha", 33)]),
new("PNR-4523", "B2", [new("Dev", 27), new("Zoya", 29)]),
];
// Select makes one result per booking: here each result is a whole list of passengers.
var lists = bookings.Select(b => b.Passengers);
Console.WriteLine($"Select: {lists.Count()} results, lists of {string.Join(", ", lists.Select(l => l.Count))} passengers");
// SelectMany makes one flat sequence of the lists' items, booking after booking.
var everyone = bookings.SelectMany(b => b.Passengers);
Console.WriteLine($"SelectMany: {everyone.Count()} results: {string.Join(", ", everyone.Select(p => p.Name))}");
// A second lambda receives the booking and each of its passengers, so the result can keep both.
var rows = bookings.SelectMany(b => b.Passengers, (b, p) => new { b.Pnr, b.Coach, p.Name, p.Age });
// Index() (.NET 9) pairs every item with its position, counted from 0.
Console.WriteLine("Numbered with Index():");
foreach (var (index, row) in rows.Index())
{
Console.WriteLine($" {index + 1}. {row.Name,-6} {row.Age,2} coach {row.Coach} {row.Pnr}");
}
// An anonymous type has no name, so it cannot leave the method: project into a record to return the rows.
List<Ticket> tickets = BuildTickets(bookings);
Console.WriteLine($"Tickets: {tickets.Count}, the first is {tickets[0]}");
static List<Ticket> BuildTickets(IEnumerable<Booking> bookings) =>
bookings
.SelectMany(b => b.Passengers, (b, p) => (b.Pnr, p.Name))
.Select((pair, index) => new Ticket(index + 1, pair.Name, pair.Pnr))
.ToList();
record Passenger(string Name, int Age);
record Booking(string Pnr, string Coach, List<Passenger> Passengers);
record Ticket(int No, string Name, string Pnr); Output
Select: 3 results, lists of 2, 3, 2 passengers
SelectMany: 7 results: Asha, Ravi, Meera, Kabir, Isha, Dev, Zoya
Numbered with Index():
1. Asha 34 coach B2 PNR-4521
2. Ravi 36 coach B2 PNR-4521
3. Meera 61 coach B1 PNR-4522
4. Kabir 8 coach B1 PNR-4522
5. Isha 33 coach B1 PNR-4522
6. Dev 27 coach B2 PNR-4523
7. Zoya 29 coach B2 PNR-4523
Tickets: 7, the first is Ticket { No = 1, Name = Asha, Pnr = PNR-4521 }
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run flatten.cs
Select handed on 3 results, lists of 2, 3 and 2 passengers; SelectMany handed on the 7 passengers. Step through
how it walks the bookings:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Three bookings. The source has 3 items, and each holds a list. Select(b => b.Passengers) would hand on those 3 lists as they are, which is why the first line of the run says lists of 2, 3 and 2.
- The first booking. SelectMany calls its lambda for the first booking, gets its list and hands on the passengers in it one at a time: Asha, then Ravi. They are items 0 and 1 of the new sequence.
- The second booking. Then the second booking: Meera, Kabir and Isha become items 2, 3 and 4. The lists are read in the order of the bookings, and each list in its own order.
- The third booking. The third booking adds Dev and Zoya as items 5 and 6. SelectMany has read every list once.
- One flat sequence. The result is one sequence of 7 passengers, not a sequence of lists. Index() pairs each one with its position, 0 to 6, and the run printed them numbered from 1.
The second lambda of SelectMany receives the booking and each of its passengers, so a row can keep the PNR and the
coach beside the name. Index(), new in .NET 9, pairs every item with its position from 0; Select((item, i) => …)
does the same inside a projection.
A LINQ projection usually builds a new shape on the spot. new { b.Pnr, p.Name } is an
anonymous type: A class without a name that the compiler makes for an expression such as new { City, Total }. Its properties are read-only, Equals compares their values, and it can be used only inside the method that creates it. More in “Tuples, anonymous types and deconstruction”: quick, with value equality, but without a name that a method could
return. A tuple works for small pairs. To pass rows out of a method, project into a record, as BuildTickets did.
Key idea
Select makes exactly one result per item and SelectMany one flat sequence out of the lists it gets; choose the
result’s shape by where it goes: an anonymous type inside the method, a record out of it.
Checkpoint
Check: Select, SelectMany and the shape of a result
Where filters, OrderBy and ThenBy sort, and Skip and Take cut pages
Filtering keeps the items whose test is true, sorting orders them by a key selector: A lambda that takes an item and returns the value an operator works with, such as b => b.Coach for OrderBy or DistinctBy, or o => o.State for GroupBy.,
and paging cuts one page out of the sorted rows. pages.cs does the three to the passengers:
// Filtering, sorting and paging the passengers of the three bookings.
Booking[] bookings =
[
new("PNR-4521", "B2", [new("Asha", 34), new("Ravi", 36)]),
new("PNR-4522", "B1", [new("Meera", 61), new("Kabir", 8), new("Isha", 33)]),
new("PNR-4523", "B2", [new("Dev", 27), new("Zoya", 29)]),
];
var rows = bookings.SelectMany(b => b.Passengers, (b, p) => (b.Coach, p.Name, p.Age));
// Where keeps the items whose test is true: the adults.
var adults = rows.Where(r => r.Age >= 18);
Console.WriteLine($"Adults: {adults.Count()} of {rows.Count()}");
// OrderBy sorts by a key; ThenBy breaks ties with a second key: by coach, then by name within a coach.
var ordered = adults.OrderBy(r => r.Coach).ThenBy(r => r.Name);
Console.WriteLine($"By coach, then name: {string.Join(", ", ordered.Select(r => $"{r.Coach} {r.Name}"))}");
// Skip and Take cut one page out of the ordered rows.
const int size = 4;
for (int page = 1; page <= 3; page++)
{
var onPage = ordered.Skip((page - 1) * size).Take(size).Select(r => r.Name).ToList();
Console.WriteLine($"Page {page} (Skip({(page - 1) * size}).Take({size})): {(onPage.Count == 0 ? "empty" : string.Join(", ", onPage))}");
}
// Chunk cuts the whole sequence into arrays of the same size in one pass; the last one may be shorter.
foreach (var chunk in ordered.Chunk(size))
{
Console.WriteLine($"Chunk of {chunk.Length}: {string.Join(", ", chunk.Select(r => r.Name))}");
}
record Passenger(string Name, int Age);
record Booking(string Pnr, string Coach, List<Passenger> Passengers); Output
Adults: 6 of 7 By coach, then name: B1 Isha, B1 Meera, B2 Asha, B2 Dev, B2 Ravi, B2 Zoya Page 1 (Skip(0).Take(4)): Isha, Meera, Asha, Dev Page 2 (Skip(4).Take(4)): Ravi, Zoya Page 3 (Skip(8).Take(4)): empty Chunk of 4: Isha, Meera, Asha, Dev Chunk of 2: Ravi, Zoya
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run pages.cs
Filtering with Where left the 6 adults. Sorting with OrderBy put coach B1 before B2, and ThenBy sorted by name
inside each coach. Paging with Skip((page - 1) * size).Take(size) cut page 1 and page 2, and page 3 came back
empty, not as an error. Chunk(4) cut the same two pages in one pass, as arrays.
The rest of the lesson refines each of the four jobs with more operators. Here is what each of them does:
| Operator | What it does |
|---|---|
Distinct, DistinctBy |
drop repeated items, or items with a repeated key, keeping the first |
MinBy, MaxBy |
return the item with the smallest or largest key, not the key |
ExceptBy, IntersectBy, UnionBy |
compare two sequences by one key |
Order, OrderDescending |
sort values by themselves, without a key lambda |
a comparer such as StringComparer.Ordinal |
decides how two keys compare |
TakeLast, TakeWhile, SkipWhile, Take(5..10) |
take from the end, while a test holds, or by a range |
| keyset paging | asks for the items after the last key already shown, instead of for page n |
Shuffle |
returns the items in a random order |
Checkpoint
Check: Where, OrderBy, ThenBy, Skip and Take
Repeats and extremes by a key: Distinct, DistinctBy, MaxBy and the set operators Pro: opens with a Pro, Premium or Ultimate pass
Removing repeats looks like one job and is three: what counts as the same item, which copy you keep, and whether another list takes part. The run below answers each with a station log.
In this part:
- Distinct compares whole items, so a class and a record behave differently
- DistinctBy keeps the first item of each key
- MaxBy returns the item, Max only the key
- ExceptBy, IntersectBy and UnionBy compare by one key
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Sorting by several keys: ties, comparers and the OrderBy-twice bug, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A sort by several keys fails in quiet ways: rows come out in an order that looks almost right. The run below reproduces the three common failures on seven berths.
In this part:
- A second OrderBy replaces the first, and ThenBy fixes it
- Ties keep their order, because OrderBy is a stable sort
- Comparers decide what comes first: case, and codes such as B10
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Paging that neither repeats nor skips: Skip, Take, Chunk and keyset pages Pro: opens with a Pro, Premium or Ultimate pass
Paging bugs show up as a row seen twice, a row never seen, or page 0 quietly showing page 1. The run below produces each of them on purpose and fixes them.
In this part:
- Page n skips the pages before it, and page 0 is page 1
- Ranges, TakeLast, TakeWhile and Chunk
- Offset pages repeat or skip items when the list changes
- Testing Shuffle by what must stay true
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a quiz leaderboard
leaderboard.cs holds nine quiz attempts by six learners and checks three methods for you to write: each learner’s
best attempt, the ranked rows, and one page of them. Until you write their bodies, all 7 checks fail:
// Practice: write Best, Ranked and Page at the bottom so that every check passes, then run this file again.
// Nine quiz attempts by six learners; a learner may try more than once. The attempts are made up for this lesson.
Attempt[] attempts =
[
new("Asha", 72), new("Ravi", 88), new("Asha", 91), new("meera", 88), new("Dev", 64),
new("Ravi", 79), new("Zoya", 91), new("Kabir", 55), new("Asha", 85),
];
var checks = new List<(string Name, Func<string> Run, string Expected)>
{
("one row per learner", () => Best(attempts).Count().ToString(), "6"),
("Asha's best score", () => Best(attempts).Single(a => a.Learner == "Asha").Score.ToString(), "91"),
("Ravi's best score", () => Best(attempts).Single(a => a.Learner == "Ravi").Score.ToString(), "88"),
("page 1 of 3", () => string.Join(" | ", Page(Ranked(attempts), 1, 3)), "1. Asha 91 | 2. Zoya 91 | 3. meera 88"),
("page 2 of 4", () => string.Join(" | ", Page(Ranked(attempts), 2, 4)), "5. Dev 64 | 6. Kabir 55"),
("a page after the last one", () => string.Join(" | ", Page(Ranked(attempts), 3, 4)), ""),
("page 0", () => string.Join(" | ", Page(Ranked(attempts), 0, 4)), "ArgumentOutOfRangeException"),
};
int passed = 0;
foreach (var (name, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = e.GetType().Name;
}
string verdict = got == expected ? "ok" : $"FAIL, got \"{got}\"";
if (verdict == "ok")
{
passed++;
}
Console.WriteLine($"{name}: {verdict}");
}
Console.WriteLine($"{passed} of {checks.Count} checks pass");
return passed == checks.Count ? 0 : 1;
// Each learner's best attempt, one attempt per learner.
static IEnumerable<Attempt> Best(IEnumerable<Attempt> attempts) =>
throw new NotImplementedException("Put the highest scores first, then keep the first attempt of each learner.");
// "rank. Learner score" for every learner's best attempt: the highest score first, equal scores by name,
// ignoring upper and lower case, and ranks counted from 1.
static IEnumerable<string> Ranked(IEnumerable<Attempt> attempts) =>
throw new NotImplementedException("Sort Best by score, then by name with a comparer, and number the rows with Select.");
// Page page (counted from 1) of size rows; a page before the first throws ArgumentOutOfRangeException.
static IEnumerable<string> Page(IEnumerable<string> rows, int page, int size) =>
throw new NotImplementedException("Check page, then Skip the rows of the pages before it and Take one page.");
record Attempt(string Learner, int Score); Output (exit status 1)
one row per learner: FAIL, got "NotImplementedException" Asha's best score: FAIL, got "NotImplementedException" Ravi's best score: FAIL, got "NotImplementedException" page 1 of 3: FAIL, got "NotImplementedException" page 2 of 4: FAIL, got "NotImplementedException" a page after the last one: FAIL, got "NotImplementedException" page 0: FAIL, got "NotImplementedException" 0 of 7 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run leaderboard.cs
Rank the learners
Copy leaderboard.cs. In Best, sort by score, highest first, and keep the first attempt of each learner with
DistinctBy. In Ranked, sort by score and then by name with StringComparer.OrdinalIgnoreCase, and number the
rows with Select((a, i) => …). In Page, throw for a page below 1, then Skip and Take. Run
dotnet run leaderboard.cs until it prints 7 of 7 checks pass.
The questions below stay with the first two sections, projection, filtering, sorting and paging, and your weakest idea comes up first:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between Select and SelectMany?
Both run a lambda on every item. Select keeps one result per item, so when the lambda returns a list, you get a
sequence of lists. SelectMany expects the lambda to return a sequence and hands on the items of all those sequences
as one flat sequence, in order: three bookings with 2, 3 and 2 passengers become 7 passengers. An overload of
SelectMany also passes the outer item to a second lambda, so each result can combine the booking and the passenger.
Use Select to reshape and SelectMany to flatten.
-
Fresher to mid
A sales report sorts orders with .OrderBy(o => o.Region).OrderBy(o => o.Date). Users say the regions are mixed together, although within one date the regions look sorted. Explain exactly what the query does, fix it, and say how a test would have caught it.
-
Mid to senior
An export merges two CSV files into a List<Customer> and calls Distinct() on it, but the duplicate customers stay. Customer is a class. Explain why, and compare three ways to remove the duplicates.
-
Mid to senior
A feed loads 20 posts a page with posts.OrderByDescending(p => p.CreatedAt).Skip(page * 20).Take(20). In busy hours users see the same post twice while scrolling, and now and then a post never appears. Explain both and redesign the paging.
Key takeaways
Selectmakes one result per item andSelectManyflattens the lists it gets into one sequence; project into a record when the result leaves the method.Wherefilters,OrderBysorts andThenByadds keys under the first one, andSkipwithTakecuts a page, with an empty page after the last one.- The refining operators, from
DistinctByandMaxBytoOrder, comparers andChunk, each answer one of those four jobs more exactly.
Terms in this lesson
- key selector
- A lambda that takes an item and returns the value an operator works with, such as b => b.Coach for OrderBy or DistinctBy, or o => o.State for GroupBy.
- projection
- Making a new value out of each item of a sequence, such as a passenger's name or a new record, with Select or SelectMany. The items themselves do not change.
References
- Projection operations (C#) (Microsoft Learn)
- Filtering data (C#) (Microsoft Learn)
- Sorting data (C#) (Microsoft Learn)
- Partitioning data (C#) (Microsoft Learn)
- Set operations (C#) (Microsoft Learn)
- Enumerable.OrderBy method (Microsoft Learn)
- Enumerable.Index method (Microsoft Learn)
- Enumerable.Shuffle method (Microsoft Learn)
- Enumerable.Skip method (Microsoft Learn)
- String.CompareTo method (Microsoft Learn)
- Pagination (EF Core): offset and keyset pagination (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress