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.
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.
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.
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.
IGrouping<string, Employee> — one per distinct key found
e => e.Department.IGrouping<TKey,TElement> bucket is created for that key.OrderBy afterward.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.
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, DevMost 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 $76000This 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.)
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.00Notice 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.
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.
GroupBy streams reasonably efficiently using an internal lookup structure, but conceptually — much like OrderBy from the previous lesson — it needs to have scanned the entire source before it can guarantee any one group is complete.Where, Select, and OrderBy, calling .GroupBy(...) alone does no work — it hands back an object describing the grouping. The actual scanning and bucketing happens only once you start enumerating the result (with foreach, ToList(), and so on).EqualityComparer<TKey>.Default — value equality for records, strings, numbers, and anonymous types (which is exactly why new { Year, Month } works cleanly as a grouping key above). You can also supply a custom IEqualityComparer<TKey> via an overload when the default comparison isn't what you want (e.g. case-insensitive string grouping).GroupBy vs OrderBy — grouping doesn't sort anythingOrderBy 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.
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.
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.
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.
Dictionary or ToLookup may serve you better.Any(predicate) is simpler and cheaper than grouping first.GroupBy reshapes a sequence into a sequence of buckets; it never removes or reorders the underlying items themselves.
GroupBy buckets a sequence by a key, producing a sequence of IGrouping<TKey,TElement> objects — each one both a key and a sub-sequence.OrderBy if a specific order matters.new { Year, Month } — a common, convenient multi-part grouping key.GroupBy is the foundation of most reporting-style queries: counts, totals, and averages "by X."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)?
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?
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?
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?
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.