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.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

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.

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.

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.

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).

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.

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.

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.