← Open in the full interactive course (progress tracking, search & more)

GroupBy doesn't sort, filter, or reshape items — it sorts them into buckets, one bucket per key.

"How many orders did each customer place?" "What's the average salary per department?" "What were total sales in each month?" Every one of these questions has the same shape: take a flat list, and bucket it by some key, so you can look at (or summarize) each bucket separately. That bucketing operation is grouping, and LINQ's GroupBy is the operator built specifically for it.

This lesson covers GroupBy, the IGrouping<TKey, TElement> objects it produces, grouping with an element selector and a result selector, and two real reporting-style examples: orders grouped by customer, and sales grouped by month.

What Is It?

The Simple Explanation

GroupBy walks through a sequence and sorts every item into a bucket, based on a key you extract from that item. Every item with the same key ends up in the same bucket. The result isn't a flat sequence anymore — it's a sequence of buckets, and each bucket knows both its key and the items inside it.

The Technical Definition

public static IEnumerable<IGrouping<TKey, TSource>> GroupBy<TSource, TKey>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)

Each "bucket" in the result is an IGrouping<TKey, TElement> — a small interface with exactly one extra member beyond what IEnumerable<TElement> already gives you:

public interface IGrouping<TKey, TElement> : IEnumerable<TElement> { TKey Key { get; } }

In other words, a grouping is a sequence — you can foreach over it, call Count() on it, project it, anything you'd do to any other IEnumerable<T> — but it also carries a Key property telling you which bucket you're looking at. GroupBy has two further overloads worth knowing: one that also takes an element selector (to reshape what ends up inside each bucket, not just what's grouped), and one that takes a result selector (to skip the IGrouping<TKey,TElement> shape entirely and project each group straight into your own summary type). Both are covered below.

Why Does It Exist?

Before GroupBy, bucketing data by hand meant a Dictionary<TKey, List<TValue>> and careful "does this key already exist?" bookkeeping:

// Manual grouping with a Dictionary var byDepartment = new Dictionary<string, List<Employee>>(); foreach (var e in employees) { if (!byDepartment.TryGetValue(e.Department, out var list)) { list = new List<Employee>(); byDepartment[e.Department] = list; } list.Add(e); }

GroupBy replaces all of that bookkeeping with one line:

// GroupBy var byDepartment = employees.GroupBy(e => e.Department);

The value goes beyond just saving lines — grouping is the foundation almost every reporting feature is built on: "totals by category," "counts by status," "averages by region." Once data is grouped, each bucket can be summarized independently with the aggregation operators from the next lesson.

Big Picture

WHAT GroupBy DOES TO A SEQUENCE
Input: (Cara,Eng), (Ben,Sales), (Ana,Eng), (Dev,Sales), (Ella,Eng)
↓ .GroupBy(e => e.Department)
Bucket "Engineering" → [Cara, Ana, Ella]
Bucket "Sales" → [Ben, Dev]
Output: a sequence of 2 IGrouping<string, Employee> — one per distinct key found

How It Works

GroupBy, STEP BY STEP
1. FOR EACH ITEM, COMPUTE ITS KEY
2. FIND OR CREATE THE BUCKET FOR THAT KEY
3. REPEAT FOR EVERY ITEM IN THE SOURCE
4. YIELD ONE IGrouping PER DISTINCT KEY

Simple Example

public record Employee(int Id, string Name, string Department, decimal Salary); List<Employee> employees = [ new(1, "Cara", "Engineering", 95000m), new(2, "Ben", "Sales", 72000m), new(3, "Ana", "Engineering", 88000m), new(4, "Dev", "Sales", 80000m), new(5, "Ella", "Engineering", 88000m), ]; // Basic grouping — one IGrouping per department IEnumerable<IGrouping<string, Employee>> groups = employees.GroupBy(e => e.Department); foreach (var group in groups) { Console.WriteLine($"{group.Key} ({group.Count()}):"); foreach (var e in group) Console.WriteLine($" {e.Name}"); } // Engineering (3): // Cara // Ana // Ella // Sales (2): // Ben // Dev

Code → Meaning → Result: group.Key is the department name; iterating group itself walks the employees in that department, because IGrouping<TKey,TElement> is an IEnumerable<TElement> — no special API to learn beyond the one extra Key property.

Grouping with an Element Selector

By default, each bucket holds the whole original item. An overload lets you reshape what goes into the bucket, independent of what you're grouping by:

// Group by department, but store just the employee's name in each bucket IEnumerable<IGrouping<string, string>> namesByDept = employees.GroupBy(e => e.Department, e => e.Name); foreach (var group in namesByDept) Console.WriteLine($"{group.Key}: {string.Join(", ", group)}"); // Engineering: Cara, Ana, Ella // Sales: Ben, Dev

Grouping with a Result Selector

Most real code doesn't want to keep working with IGrouping<TKey,TElement> objects at all — it wants a summary per group right away. The result-selector overload projects each completed group straight into your own shape in one step:

var deptSummary = employees.GroupBy( e => e.Department, (key, group) => new { Department = key, Count = group.Count(), AverageSalary = group.Average(e => e.Salary) }); foreach (var s in deptSummary) Console.WriteLine($"{s.Department}: {s.Count} employees, avg ${s.AverageSalary:F0}"); // Engineering: 3 employees, avg $90333 // Sales: 2 employees, avg $76000

This is, in practice, the single most common way GroupBy shows up in real applications — grouping and immediately summarizing in one expression, without ever handing an IGrouping<TKey,TElement> to the rest of your code. (Count() and Average() are aggregation operators — the very next lesson covers them in full.)

Real-World Example

Grouping orders by customer, and sales by month, are two of the most common reporting queries in any e-commerce or business application:

public record Order(int Id, int CustomerId, DateOnly OrderDate, decimal Total); List<Order> orders = [ new(101, 1, new(2026, 1, 5), 120.50m), new(102, 2, new(2026, 1, 18), 75.00m), new(103, 1, new(2026, 2, 2), 200.00m), new(104, 3, new(2026, 2, 14), 50.25m), new(105, 1, new(2026, 2, 20), 99.99m), new(106, 2, new(2026, 3, 1), 310.00m), ]; // ─── Orders grouped by customer, with a per-customer total ─── var byCustomer = orders.GroupBy( o => o.CustomerId, (customerId, group) => new { CustomerId = customerId, OrderCount = group.Count(), TotalSpent = group.Sum(o => o.Total) }); foreach (var c in byCustomer) Console.WriteLine($"Customer {c.CustomerId}: {c.OrderCount} orders, ${c.TotalSpent}"); // Customer 1: 3 orders, $420.49 // Customer 2: 2 orders, $385.00 // Customer 3: 1 orders, $50.25 // ─── Sales grouped by month, for a monthly revenue report ─── var byMonth = orders .GroupBy(o => new { o.OrderDate.Year, o.OrderDate.Month }) .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month); foreach (var m in byMonth) Console.WriteLine($"{m.Key.Year}-{m.Key.Month:D2}: ${m.Sum(o => o.Total)}"); // 2026-01: $195.50 // 2026-02: $350.24 // 2026-03: $310.00

Notice the monthly report groups by an anonymous type key (new { Year, Month }) — grouping isn't limited to a single scalar value; any type with sensible equality (including anonymous types, which get value-based equality automatically) works as a grouping key. Also notice the explicit OrderBy/ThenBy afterward — as covered in How It Works, groups come out in first-seen order, not sorted, so a report that needs chronological order must sort the groups explicitly.

Analogy

Sorting Mail Into Labeled Bins

Think of GroupBy as a mailroom clerk sorting a pile of letters into labeled bins by recipient department. Every letter still exists — nothing is discarded, nothing is transformed — it's just no longer one big pile; it's several smaller piles, each with a label (the Key) telling you which department that pile belongs to. You can then deal with each labeled pile independently: count the letters in it, read them, or summarize them.

Under the Hood

GroupBy AND THE "MUST SEE EVERYTHING FIRST" PROBLEM
1. GroupBy CAN'T KNOW A BUCKET IS "DONE" UNTIL THE SOURCE ENDS
2. THE CALL ITSELF IS STILL DEFERRED
3. EQUALITY DECIDES WHICH BUCKET AN ITEM JOINS

Common Confusion

1. GroupBy vs OrderBy — grouping doesn't sort anything

OrderBy rearranges items into a single, ordered sequence. GroupBy partitions items into separate buckets — it changes the shape of the result (a sequence of groups, not a sequence of items) rather than just its order. The two are often combined (GroupBy then OrderBy the groups by key, as shown in the monthly report above), but they solve different problems.

2. IGrouping<TKey,TElement> vs Dictionary<TKey,List<TElement>>

They look similar but behave differently. A Dictionary gives you random-access lookup by key (dict["Sales"]). The result of GroupBy is a plain sequence you iterate through — there's no built-in "give me the Sales group directly" lookup; you'd need .First(g => g.Key == "Sales"), or convert to a lookup structure with ToLookup (a related, eagerly-evaluated cousin of GroupBy worth knowing exists, though it's outside this lesson's scope) or a Dictionary if you genuinely need repeated key-based access.

Common Mistakes

Mistake 1 — Forgetting groups aren't automatically sorted by key

Assuming employees.GroupBy(e => e.Department) comes out alphabetically by department. Groups come out in first-seen order. Add .OrderBy(g => g.Key) after GroupBy whenever the order of the groups themselves matters.

Mistake 2 — Treating a group like a list you can index or re-enumerate cheaply without thinking

Calling group.Count() and then separately foreach-ing over group again, assuming it's free. Depending on the source, an IGrouping<TKey,TElement> can be re-enumerated, but if you need several pieces of information from the same group (a count and a sum and the items themselves), it's often clearer — and sometimes cheaper — to materialize it once: var items = group.ToList(); then work from that snapshot. This connects directly to the Deferred vs Immediate Execution lessons later in this module.

When Should I Use It?

Use GroupBy when

Consider alternatives when

Mental Model

GroupBy = "sort every item into a bucket, one bucket per distinct key"
IGrouping<TKey,TElement> = "a bucket that knows its own key and is itself a sequence"

Remember:
· Groups come out in first-seen key order, never automatically sorted.
· The result-selector overload groups and summarizes in one step — the most common real-world shape.
· GroupBy reshapes a sequence into a sequence of buckets; it never removes or reorders the underlying items themselves.

Key Takeaway


Check Your Understanding

You've learned how to bucket data with GroupBy. Let's check your understanding.

1. What does group.Key represent when working with the result of employees.GroupBy(e => e.Department)?

Show answer

Correct: B

Why B is correct: Key holds the value produced by the key selector (e => e.Department) — the shared value that determined which items ended up in that particular bucket.

Why A is incorrect: Key has nothing to do with any individual employee's Id — it's the grouping value itself.

Why C is incorrect: GroupBy doesn't generate any arbitrary identifiers — Key is always the exact value your selector produced.

Why D is incorrect: That's what group.Count() would give you — a separate, computed value, not Key itself.

Reinforcement: Key always equals whatever your key selector returned for the items in that bucket.

2. After calling employees.GroupBy(e => e.Department), in what order do the resulting groups appear by default?

Show answer

Correct: B

Why B is correct: As covered in How It Works, GroupBy yields one group per distinct key in the order that key was first seen in the source sequence — it never sorts on your behalf.

Why A is incorrect: This is exactly the mistake called out in Common Mistakes — alphabetical order requires an explicit OrderBy(g => g.Key) afterward.

Why C is incorrect: The order is deterministic (first-seen order), not random — it will be the same every time given the same input sequence.

Why D is incorrect: Group size plays no role in the default ordering at all.

Reinforcement: Always add an explicit sort after GroupBy if the order of the groups themselves matters to your output.

3. Which GroupBy overload lets you group and summarize each bucket into your own shape in a single expression, without ever working with an IGrouping<TKey,TElement> directly?

Show answer

Correct: C

Why C is correct: As shown in the department summary example, the result-selector overload receives the completed key and group together and projects them straight into a custom shape — no IGrouping<TKey,TElement> ever leaves the expression.

Why A is incorrect: The basic overload still hands back a sequence of IGrouping<TKey,TElement> objects for you to process afterward.

Why B is incorrect: The element selector only changes what's stored inside each bucket — the result is still a sequence of IGrouping<TKey,TElement>, just with reshaped elements.

Why D is incorrect: The result-selector overload exists precisely to skip that intermediate shape.

Reinforcement: The result-selector overload is the most common real-world form of GroupBy — group and summarize, in one step.

4. You need to group orders by both the year and month of their OrderDate. Which approach correctly expresses a multi-part grouping key?

Show answer

Correct: B

Why B is correct: As shown in the monthly sales report, an anonymous type combining multiple values works cleanly as a grouping key — anonymous types get automatic value-based equality, so items with the same Year and Month land in the same group.

Why A is incorrect: Chaining a second GroupBy would try to group IGrouping<int, Order> objects themselves by month, which isn't a meaningful or correct multi-part grouping — it doesn't produce the combined-key buckets you want.

Why C is incorrect: Any type with well-defined equality can serve as a grouping key, including anonymous types and multi-property records — it's not limited to a single scalar.

Why D is incorrect: This filters down to one specific year and month rather than grouping across all of them — a completely different operation.

Reinforcement: An anonymous type is the idiomatic way to express "group by more than one property at once" in LINQ.

You can now bucket data into meaningful groups. Next up: matching two related sequences together with Join and GroupJoin.


dotnetmadeeasy.com — Learn C# and .NET, the right way.