Grouping
By the end of this lesson
Gather items by a key and summarise each group.
GroupBy takes a lambda that produces a key for each item, and returns one group per distinct key. A group is two things at once: it carries the key, and it is itself a sequence of the items that share that key.
That second half is the part worth holding on to. Because a group is a sequence, every operator you already know works on it. Counting a group, averaging a group, ordering inside a group — none of these need anything new.
IEnumerable<IGrouping<string, Employee>> byDepartment = employees
.GroupBy(e => e.Department);
foreach (IGrouping<string, Employee> group in byDepartment)
{
Console.WriteLine(
$"{group.Key}: headcount {group.Count()}, average {group.Average(e => e.AnnualSalary)}");
}
// Engineering: headcount 3, average 1800000
// Support: headcount 2, average 840000
// Sales: headcount 2, average 1900000
// Finance: headcount 1, average 950000- IGrouping<string, Employee> reads as "a group of Employee items identified by a string key". The two type arguments are the key type and the item type, in that order.
- group.Key is the department name. Everything else about the group is reached by treating it as a sequence: Count() counts its items, Average(e => e.AnnualSalary) averages a value across them.
- The groups arrive in the order each key was first seen in the source, not in alphabetical order. Engineering comes first because Asha Mehta is the first employee in the list. Add OrderBy(g => g.Key) when you want alphabetical.
- Inside the groups, items keep their original relative order. Nothing is sorted unless you sort it.
- The eight employees are still eight employees. Grouping rearranges the view; it does not filter anything out.
var summary = employees
.GroupBy(e => e.Department)
.Select(g => new
{
Department = g.Key,
Headcount = g.Count(),
TotalSalary = g.Sum(e => e.AnnualSalary),
TopEarner = g.OrderByDescending(e => e.AnnualSalary).First().Name,
})
.OrderByDescending(row => row.TotalSalary);
foreach (var row in summary)
{
Console.WriteLine(
$"{row.Department}: {row.Headcount} people, {row.TotalSalary} total, top earner {row.TopEarner}");
}
// Engineering: 3 people, 5400000 total, top earner Neha Kulkarni
// Sales: 2 people, 3800000 total, top earner Meera Nair
// Support: 2 people, 1680000 total, top earner Divya Rao
// Finance: 1 people, 950000 total, top earner Sanjay Gupta- This is the shape most reports want: one row per group, with the details already reduced to numbers. GroupBy gathers, Select summarises.
- Inside the Select, g is a group, so g.OrderByDescending(...).First() finds the highest paid member of that group. First is safe here — a group cannot be empty, because a group only exists when something is in it.
- OrderByDescending at the end sorts the summary rows, not the employees. By that point the sequence is rows, and the operators apply to whatever the current element type is.
- The anonymous type is fine here because everything happens in one method. To return this from a method, declare a record — the projection lesson covers why.
- Only the last output line is grammatically awkward: "1 people". Fixing that is a formatting concern, not a query concern, and it belongs in the code that prints rather than in the query that produces them.
Four variations that cover most real grouping work:
- Alphabetical groups
- employees.GroupBy(e => e.Department).OrderBy(g => g.Key) — grouping does not sort, so ask for the order you want.
- A key built from two values
- employees.GroupBy(e => (e.Department, Band: e.AnnualSalary > 1_000_000m)) groups by both at once. The key is a tuple, read as g.Key.Department and g.Key.Band. Two employees are in the same group only when both parts match.
- Group and project in one step
- employees.GroupBy(e => e.Department, e => e.Name) gives groups of names rather than groups of employees. Useful when the group only needs one field, and it keeps less in memory.
- ToLookup
- employees.ToLookup(e => e.Department) builds the same grouping but does the work immediately and lets you index into it by key: lookup["Sales"] returns the Sales employees, and an unknown key returns an empty sequence rather than throwing. Reach for it when you will query the same grouping repeatedly.
Summary
- GroupBy returns one group per distinct key; each group carries its Key and is itself a sequence
- Every operator works on a group, so Count, Sum, Average and OrderBy need nothing new
- Groups arrive in first-appearance order of the key, not sorted — order them if order matters
- Count() on the grouped sequence counts groups, not items
- Grouping reads the entire source before producing anything, so filter first and let a database group when it can
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Group the employees into two bands: those earning more than 1,000,000 and those earning that or less. For each band print the headcount and the total salary.
Then order the two bands so that the one with the larger total comes first.
Show solution
The key does not have to be a field. Any expression works, and here it is a condition turned into a label. Producing the label in the key lambda means the group heading is already the text you want to display.
A bool key would work too, and the groups would be keyed True and False. Returning a string instead keeps the printing code free of a translation step, at the cost of a slightly slower comparison. On two groups that trade is easy; on a key with a million distinct values, prefer the cheap key and translate when displaying.
var bands = employees
.GroupBy(e => e.AnnualSalary > 1_000_000m ? "Above 1,000,000" : "1,000,000 or below")
.Select(g => new
{
Band = g.Key,
Headcount = g.Count(),
TotalSalary = g.Sum(e => e.AnnualSalary),
})
.OrderByDescending(row => row.TotalSalary);
foreach (var row in bands)
{
Console.WriteLine($"{row.Band}: {row.Headcount} people, {row.TotalSalary} total");
}
// Above 1,000,000: 5 people, 9200000 total
// 1,000,000 or below: 3 people, 2630000 totalThink about it
Think about it
Where filters a sequence one item at a time and can hand back its first result before it has seen the second item. GroupBy cannot.
Explain why, and say what that means for a query that groups ten million rows in memory.
Show solution
A filter decision is local. Whether this employee earns over 1,000,000 depends on this employee alone, so Where can answer as it goes. A group is a claim about completeness: "here is Engineering" is only true once you know no further Engineering rows exist, and the only way to know that is to reach the end of the source.
So GroupBy buffers. Ten million rows means ten million objects held while the grouping is built, plus the group structures themselves. If the rows came from a database, you have also transferred all of them across the network first.
Two practical responses. Filter before grouping, so fewer items are buffered. Or let the data store group for you and return only the summary rows, which can be a handful of rows instead of ten million. Reaching for the second option is a large part of what makes a data-heavy page fast.
Saved in this browser only.