Linq Advanced
By the end of this lesson you'll be able to group, flatten, join, and reduce real-world data with LINQ — and write queries that stay fast and predictable in production by mastering deferred execution and materialisation.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 Real-World Analogy
Think of a busy mailroom. GroupBy is sorting the post into pigeonholes by recipient, then counting each pile. SelectMany is tipping every pigeonhole out onto one big table — flattening many piles into a single stream. Join is matching each letter to its recipient's address card by a shared name. And deferred execution is the difference between writing the sorting instructions on a clipboard (the query) and actually walking the room to do it (enumerating) — nothing moves until someone reads the clipboard and acts.
📊 Advanced LINQ Methods Reference
Method
What it does
Returns
GroupBy
Bucket items by a key
IEnumerable<IGrouping>
SelectMany
Flatten nested sequences
one flat sequence
Join
Match two sources on a key
combined sequence
Aggregate
Fold to one value (reduce)
a single value
Skip / Take
Page through a sequence
a sub-sequence
ToList / ToArray
Run now & snapshot
List<T> / T[]
ToDictionary
Build O(1) key lookup
Dictionary<K,V>
ToLookup
Materialised one-to-many grouping
ILookup<K,V>
Every one of these still works on IEnumerable < T > . The To... family is special: those are the methods that force execution and turn a lazy recipe into concrete data.
Running C# locally: install the .NET SDK or use dotnetfiddle.net . Every example here uses an in-memory List < T > , so it runs unchanged in a console app. Keep using System.Linq; at the top.
1. GroupBy — Bucket & Summarise
GroupBy splits a collection into groups using the key its lambda returns. The clever part: each group is an IGrouping < K, T > — it carries a .Key and it is itself a sequence of the items in that bucket. That means you can call Count() , Sum(...) , or Average(...) straight on a group. Pair it with Select to turn each group into a tidy summary object. Read this worked example, run it, then you'll write your own.
Your turn. The program below groups sales by region — fill in the three blanks so it counts the sales and sums the amount per region, then run it and check the output.
2. SelectMany — Flatten Nested Data
Select maps each item to something new — but if each item is itself a list, you end up with a list of lists. SelectMany solves this by flattening : it runs your lambda, then splices each returned sequence into one continuous stream. It's the go-to for departments→employees, posts→tags, or orders→line-items. A second overload takes a result selector so you can pair each child with its parent in one pass.
Now you try. Each shopping basket holds its own list of prices. Flatten them all into one sequence, then chain an aggregate to total them. Fill in the two blanks:
3. Join — Combine Two Sources
Real data is rarely in one list. Join matches rows from two sequences on a shared key — exactly like an inner JOIN in SQL. You give it the second source, a key selector for each side, and a result selector that combines the matched pair. Items with no match on the other side are simply dropped (that's an inner join). It's how you stitch customers to their purchases, or orders to their products.
4. Aggregate — Custom Reduction
Sum and Count are pre-built reductions; Aggregate is the general one — LINQ's reduce . It walks the sequence carrying an accumulator , applying your lambda (acc, item) => ... at each step. Always prefer the form that takes a seed (the starting accumulator): it lets the result be a different type from the items, and — crucially — it won't throw on an empty sequence the way the seedless form does.
5. Skip / Take — Paging
When you can't show everything at once — search results, an admin table, an API endpoint — you page . Skip(n) jumps past the first n items; Take(n) keeps the next n . The classic formula is Skip((page - 1) * pageSize).Take(pageSize) . Their cousins TakeWhile / SkipWhile work on a condition instead of a count, stopping at the first item that fails the test.
6. Deferred Execution & Multiple Enumeration
This is the difference between a senior and a junior LINQ user. A query is deferred : defining it runs nothing — it's a recipe. The work happens every time you enumerate it ( foreach , Count() , ToList() …). So enumerating the same query twice runs the whole pipeline twice — wasteful if it's expensive, and a real bug if the source changed in between. The fix is to materialise once with ToList() (or ToArray() ) and reuse that snapshot. Watch the evaluation count in the example.
🔎 Deep Dive: ToList vs ToArray vs ToDictionary vs ToLookup
These four all force execution — they turn a lazy query into concrete, in-memory data. Which one you pick depends on how you'll use the result:
Rule of thumb: reach for ToList() by default; ToDictionary when you'll look items up by a unique key; and ToLookup when keys repeat (it's essentially a materialised GroupBy you can index into).
7. Performance — Filter Early, Avoid Re-enumeration
LINQ is readable, but order still matters. Filter early so later steps process fewer items; project late so you only carry the fields you need; limit with Take so you stop as soon as you have enough. Prefer Any() over Count() > 0 — Any stops at the first match while Count scans everything. And when you look the same data up repeatedly, build a ToDictionary once (O(1) lookups) instead of calling Where / First in a loop (O(n) every time — the classic N+1 trap).
Pro Tips
- 💡 Materialise once: if you'll iterate a query more than once, call .ToList() first and reuse it — otherwise the whole pipeline re-runs every time.
- 💡 ToLookup beats repeated GroupBy : it runs immediately and lets you index by key, so it's ideal when you'll query the same buckets again and again.
- 💡 Watch for null keys in GroupBy : items with a null key all land in a single group whose Key is null — coalesce first ( o.Category ?? "Unknown" ) if that's not what you want.
- 💡 Give Aggregate a seed: the seeded form is empty-safe and lets the result differ in type from the items.
- 💡 .NET 6+ shortcuts: DistinctBy(x => x.Key) , MaxBy / MinBy , and Chunk(100) (batch a sequence) often replace clunky GroupBy + First patterns.
Common Errors (and the fix)
- "Possible multiple enumeration of IEnumerable" (analyser warning): you iterate the same deferred query twice, re-running the whole pipeline each time. Call .ToList() once and reuse the result.
- "System.ArgumentException: An item with the same key has already been added": ToDictionary hit a duplicate key. Use ToLookup for one-key-many-values, or de-duplicate with DistinctBy / GroupBy(...).Select(g => g.First()) first.
- "System.InvalidOperationException: Sequence contains no elements" from Aggregate : the no-seed overload was called on an empty sequence. Use the Aggregate(seed, ...) form — a seed makes it empty-safe.
- Group "lost" some items / a stray null bucket: a null grouping key sweeps every null-keyed item into one group. Coalesce the key: GroupBy(o => o.Category ?? "Unknown") .
- Trying to index a GroupBy result like a list: a group is an IGrouping , not a List . Enumerate it with foreach , or call .ToList() on the group if you need indexing.
📋 Quick Reference
Task
Code
Result
Group + count
items.GroupBy(x => x.Cat)
groups w/ .Key
Sum per group
g.Sum(x => x.Amount)
a number
Flatten
items.SelectMany(x => x.Sub)
flat sequence
a.Join(b, x => x.Id, y => y.AId, ...)
matched pairs
Reduce
items.Aggregate(0, (a, x) => a + x)
one value
Page
items.Skip(10).Take(10)
items 11–20
Snapshot
query.ToList()
List<T>
Key lookup
items.ToDictionary(x => x.Id)
O(1) lookups
Frequently Asked Questions
Q: When should I use GroupBy versus ToLookup ?
Both bucket items by a key. GroupBy is deferred — it re-groups every time you enumerate it. ToLookup runs immediately and gives you an indexable ILookup < K, V > you can reuse. Use ToLookup when you'll hit the same buckets repeatedly; GroupBy when it's a one-pass summary.
Q: What's the real difference between Select and SelectMany ?
Select gives you one output per input — if each input is a list, you get a list of lists. SelectMany flattens those inner lists into one continuous sequence. Reach for it whenever a Select would leave you with nested collections.
LINQ is lazy, so enumerating the same query object more than once re-executes the entire pipeline each time. Call .ToList() once to materialise the result, then iterate the list as often as you like.
Use a seed whenever the result type differs from the item type, or whenever the sequence might be empty. The seedless overload throws on an empty sequence; Aggregate(0, ...) just returns the seed.
Mini-Challenge: Spend Per Account
No blanks this time — just a brief and an outline to keep you on track. Starting from the list of transactions, GroupBy the account name, Sum each group's amount, and print one line per account. Then add the bonus: OrderByDescending the total so the biggest account leads. Run it and check your output against the expected lines in the comments.
🎉 Lesson Complete
- ✅ GroupBy buckets by a key; each group is an IGrouping you can Count / Sum / Average
- ✅ SelectMany flattens nested collections into one sequence
- ✅ Join matches two sources on a shared key (inner join)
- ✅ Aggregate is reduce — give it a seed to stay empty-safe and change types
- ✅ Skip / Take page results; TakeWhile / SkipWhile page by condition
- ✅ Queries are deferred — materialise once with ToList / ToArray / ToDictionary to avoid re-enumeration
- ✅ Next lesson: Deep Dive Into Delegates — the function-passing power behind every LINQ lambda
Practice quiz
What does GroupBy return for each bucket?
- A single number
- An IGrouping that has a .Key and is itself a sequence of its items
- A sorted list
- A Dictionary
Answer: An IGrouping that has a .Key and is itself a sequence of its items. Each group is an IGrouping<K,T>: it carries a .Key and is itself a sequence, so you can call Count(), Sum(), or Average() on it.
What does SelectMany do that Select does not?
- Sorts the sequence
- Flattens nested sequences into one continuous sequence
- Removes duplicates
- Groups items by key
Answer: Flattens nested sequences into one continuous sequence. If each item is itself a list, Select gives a list of lists; SelectMany flattens those inner sequences into one continuous stream.
What kind of join does the LINQ Join operator perform?
- A left outer join
- A full outer join
- An inner join — items with no match on the other side are dropped
- A cross join
Answer: An inner join — items with no match on the other side are dropped. Join matches rows from two sources on a shared key, like a SQL inner join; unmatched items on either side are dropped.
Why should you prefer the seeded form Aggregate(seed, ...)?
- It runs faster
- It is empty-safe and lets the result be a different type from the items
- It sorts the result
- It is the only form that compiles
Answer: It is empty-safe and lets the result be a different type from the items. The seeded form won't throw on an empty sequence (the seedless form does) and lets the accumulator/result type differ from the item type.
What is the classic paging formula with Skip and Take?
- Take(page).Skip(pageSize)
- Skip(page * pageSize).Take(page)
- Skip((page - 1) * pageSize).Take(pageSize)
- Take(pageSize).Skip(pageSize)
Answer: Skip((page - 1) * pageSize).Take(pageSize). Skip((page - 1) * pageSize).Take(pageSize) jumps past earlier pages and takes the current page's worth of items.
What happens if you enumerate the same deferred query twice?
- The second enumeration is free (cached)
- The whole pipeline re-runs each time
- It throws an exception
- Only the first item is recomputed
Answer: The whole pipeline re-runs each time. A deferred query is a recipe; each enumeration re-runs the entire pipeline. Materialise once with ToList() to avoid the waste.
Why is Any() generally better than Count() > 0 to test for matches?
- Any() is more readable only
- Any() stops at the first match, while Count() scans everything
- Count() throws on empty sequences
- Any() sorts the data first
Answer: Any() stops at the first match, while Count() scans everything. Any() short-circuits at the first matching item; Count() > 0 scans the whole sequence to produce a total it doesn't need.
What does ToDictionary throw on if two items share the same key?
- NullReferenceException
- InvalidOperationException
- ArgumentException ('An item with the same key has already been added')
- Nothing — it overwrites
Answer: ArgumentException ('An item with the same key has already been added'). ToDictionary requires unique keys and throws ArgumentException on a duplicate. Use ToLookup for one-key-to-many values.
When should you use ToLookup instead of ToDictionary?
- When keys are unique
- When keys repeat (one key maps to many values)
- When you need O(n) lookups
- When the sequence is empty
Answer: When keys repeat (one key maps to many values). ToLookup builds an ILookup where one key maps to many values and never throws on duplicates — essentially a materialised GroupBy you can index.
Where do items with a null grouping key end up in a GroupBy?
- They are dropped from the result
- They throw an exception
- They all land in a single group whose Key is null
- They are spread across all groups
Answer: They all land in a single group whose Key is null. A null key sweeps every null-keyed item into one group with a null Key. Coalesce the key (e.g. o.Category ?? "Unknown") if that's not what you want.
Continue this course
- Previous: File I/O
- Next: Deep Dive Into Delegates