C# & .NET (C# 14, .NET 10 LTS) Module 14 – LINQ
LINQ grouping and aggregation
Group C# data with GroupBy and ToLookup, total it with Count, Sum, Average and Aggregate, and use CountBy and AggregateBy to total without groups.
What you will learn
- Group data with GroupBy and ToLookup and shape each group into a result
- Aggregate with Count, Sum, Average, Min, Max and Aggregate, and handle empty sequences
- Group by two values at once with anonymous types or tuples
- Choose between GroupBy, ToLookup, CountBy and AggregateBy by what each keeps in memory
Before you start
What you’ll learn · Before you start
What you will learn
- Group data with GroupBy and ToLookup and shape each group into a result
- Aggregate with Count, Sum, Average, Min, Max and Aggregate, and handle empty sequences
- Group by two values at once with anonymous types or tuples
- Choose between GroupBy, ToLookup, CountBy and AggregateBy by what each keeps in memory
Before you start
On this page
Ten orders from four states, and a dashboard that needs each state’s number of orders, revenue and average order.
A loop needs a dictionary from state to running totals and a branch for every new state. LINQ grouping does it in
one query: GroupBy puts the orders of each state together, and aggregation: Folding many values into one, such as a count, a sum, an average, a minimum or a maximum, of a whole sequence or of each group of it. folds each
group into the numbers.
The runs below group the orders, total them, and then break the aggregates on purpose: an empty group, a key that
never appears, a total too large for an int, and a grouping that keeps far more in memory than its answer needs.
GroupBy puts items with the same key together, in the order the keys first appear
sales.cs holds ten made-up orders and groups them by state:
// Ten orders from four states, grouped and totalled. The orders are made up for this lesson.
Order[] orders =
[
new(1, "KA", "Jul", 1499m), new(2, "MH", "Jul", 899m), new(3, "KA", "Jul", 3250m),
new(4, "TN", "Aug", 2199m), new(5, "MH", "Aug", 1499m), new(6, "DL", "Aug", 1150m),
new(7, "KA", "Aug", 899m), new(8, "TN", "Aug", 1499m), new(9, "MH", "Sep", 3250m),
new(10, "KA", "Sep", 2199m),
];
// GroupBy: one group per state. A group is its key and its orders, in the order they came.
foreach (IGrouping<string, Order> g in orders.GroupBy(o => o.State))
{
Console.WriteLine($"{g.Key}: {g.Count()} order{(g.Count() == 1 ? "" : "s")}, ids {string.Join(" ", g.Select(o => o.Id))}");
}
// Fold each group into numbers, and sort the states by what they sold.
var perState = orders
.GroupBy(o => o.State)
.Select(g => new
{
State = g.Key,
Orders = g.Count(),
Revenue = g.Sum(o => o.Amount),
Average = Math.Round(g.Average(o => o.Amount), 2),
Largest = g.Max(o => o.Amount),
})
.OrderByDescending(s => s.Revenue);
Console.WriteLine("State Orders Revenue Average Largest");
foreach (var s in perState)
{
Console.WriteLine($"{s.State,-5} {s.Orders,6} {s.Revenue,7} {s.Average,7} {s.Largest,7}");
}
// The same grouping in query syntax: group ... by ... into names the group for the clauses after it.
var monthly =
from o in orders
group o by o.Month into g
select $"{g.Key}: {g.Count()} orders, Rs {g.Sum(o => o.Amount)}";
Console.WriteLine(string.Join(" | ", monthly));
// The aggregates work on the whole sequence too.
Console.WriteLine($"All orders: Count {orders.Length}, Sum {orders.Sum(o => o.Amount)}, Min {orders.Min(o => o.Amount)}, Max {orders.Max(o => o.Amount)}, Average {orders.Average(o => o.Amount)}");
record Order(int Id, string State, string Month, decimal Amount); Output
KA: 4 orders, ids 1 3 7 10 MH: 3 orders, ids 2 5 9 TN: 2 orders, ids 4 8 DL: 1 order, ids 6 State Orders Revenue Average Largest KA 4 7847 1961.75 3250 MH 3 5648 1882.67 3250 TN 2 3698 1849 2199 DL 1 1150 1150 1150 Jul: 3 orders, Rs 5648 | Aug: 5 orders, Rs 7246 | Sep: 2 orders, Rs 5449 All orders: Count 10, Sum 18343, Min 899, Max 3250, Average 1834.3
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run sales.cs
GroupBy(o => o.State) returned four groups. Each is an IGrouping<string, Order>, a
grouping: An IGrouping<TKey,TElement>, one of the results of GroupBy: a sequence of the items that share one key, with that key in its Key property.: a sequence of the orders that share a key, with the key in its Key property, so
g.Key printed the state and g.Count() and g.Select(o => o.Id) worked on its orders. Step through how it reads
them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Nothing yet. GroupBy has been called and nothing has run. The first time a loop asks for a group, GroupBy reads every order before it hands on the first group.
- Order 1, KA. Order 1 comes from KA. There is no group KA yet, so GroupBy starts one; KA will be the first group because its key appeared first.
- Orders 2 and 3. Order 2 starts group MH. Order 3 is from KA again and joins the group that exists, after order 1.
- Orders 4 to 6. Order 4 starts TN, order 5 joins MH, and order 6 starts DL, the fourth and last new key.
- Orders 7 to 10. The last four orders join groups that exist: 7 and 10 go to KA, 8 to TN and 9 to MH. No group was made for a key that never appeared.
- Four groups. GroupBy hands on 4 groups in the order their keys first appeared, KA, MH, TN, DL, each with its orders in source order. KA holds 4 orders worth Rs 7847.
The order of the groups is not an accident. The documentation of GroupBy says the groups come in the order in which
their keys first appear in the source, and the items of a group in source order: KA, MH, TN, DL, with KA holding 1,
3, 7 and 10. GroupBy is deferred, like Where, but it reads every order before it hands on the first group,
because the last order might belong to the first group. In query syntax, group o by o.Month into g makes the same
groups and names each one g for the clauses after it.
Key idea
GroupBy hands on one grouping per key, a sequence of that key’s items with the key in Key, in the order the
keys first appear and with each group’s items in source order.
Checkpoint
Check: GroupBy and the order of its groups
Count, Sum, Average, Min and Max fold each group into one number
Aggregation turns many values into one. The table in the run made one row per state with g.Count(),
g.Sum(o => o.Amount), g.Average(o => o.Amount) and g.Max(o => o.Amount), then sorted the rows by revenue: KA
sold the most, Rs 7847 in 4 orders, at Rs 1961.75 an order. The last line ran the same aggregates on all ten orders.
The amounts are decimal, the type for money, which adds rupees and paise exactly.
LINQ grouping has more tools than GroupBy, and aggregation more than the five operators:
| Operator | What it gives | When it runs |
|---|---|---|
GroupBy |
one group per key, with all of its items | when it is read |
ToLookup |
the same groups, kept, and found by key | at once |
CountBy (.NET 9) |
a count per key, without groups | when it is read |
AggregateBy (.NET 9) |
one folded value per key, without groups | when it is read |
Aggregate |
one value folded from the whole sequence, from a seed | at once |
CountBy(o => o.State) returns one pair per state: the state and its number of orders, with no group built.
AggregateBy folds any running value per key the same way, such as each state’s revenue from a seed of 0. Aggregate
is the general form of this section’s five aggregates: given a starting value and a function, it carries a running
result through the sequence, one item at a time, so Count, Sum, Average, Min and Max could each be written
with it.
Sum of an empty sequence is 0, while Average, Min and Max of an empty sequence of int, double or decimal
values throw InvalidOperationException, because there is no value to return.
Checkpoint
Check: Count, Sum, Average, Min and Max
Empty groups, missing keys and overflow: aggregates that fail, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Aggregates fail in three ways that tests with tidy data never reach: nothing to aggregate, a key that never shows up, and a total too large for its type. The run below triggers each one and fixes it.
In this part:
- Average, Min and Max need a value, while Sum and Count do not
- A key with no items gets no group
- Sum of int is checked, and money belongs in decimal
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Grouping by two values, shaping groups and folding with Aggregate Pro: opens with a Pro, Premium or Ultimate pass
Real reports group by more than one thing, and need each group shaped into a row, not handed on as a list of orders.
The run below groups by state and month, shapes the groups as it makes them, and folds a whole sequence with
Aggregate.
In this part:
- A composite key groups by two values at once
- Element and result selectors shape each group
- Keys that differ only in case
- Aggregate folds a sequence into any value, from a seed
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
CountBy, AggregateBy and ToLookup: what each keeps in memory, measured Pro: opens with a Pro, Premium or Ultimate pass
A grouping that only counts or sums keeps far more than it needs. The run below measures the bytes five ways on 100,000 orders and counts how often each way does its work.
In this part:
- GroupBy keeps every item, CountBy keeps one number per key
- GroupBy runs again for every read, ToLookup once
- A lookup answers every key, and a dictionary throws for a missing one
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a tax summary of invoice lines
tax_summary.cs holds eight invoice lines with item codes, tax rates and amounts, one of them a credit note, and
checks three methods for you to write: the totals per rate, the total tax, and the codes that carry the most tax.
Lines shaped like a GST invoice
The made-up lines are shaped like those of an Indian GST invoice, with a tax rate on each line. The rates, the codes
and the rule of rounding each line’s tax, as Tax does, belong to this exercise only: they are not the real rate of
any goods or a rule of GST law.
Until you write the three bodies, all 6 checks fail:
// Practice: write ByRate, TotalTax and TopCodes at the bottom so that every check passes, then run this file again.
// Eight lines of four invoices. Each line has its own tax rate (illustrative), and the tax of a line is worked out
// and rounded to two decimal places on the line, as Tax does. A negative line is a credit note for returned goods.
using System.Globalization;
Line[] lines =
[
new("INV-101", "P100", 5, 240.00m), new("INV-101", "P200", 18, 45.50m),
new("INV-102", "P300", 5, 610.25m), new("INV-102", "P400", 18, 99.90m),
new("INV-103", "P100", 5, 120.00m), new("INV-103", "P500", 18, 210.00m),
new("INV-104", "P200", 18, -45.50m), new("INV-104", "P600", 12, 550.00m),
];
var checks = new List<(string Name, Func<string> Run, string Expected)>
{
("one row per rate, lowest rate first", () => string.Join(" | ", ByRate(lines)),
"5%: taxable 970.25, tax 48.51, lines 3 | 12%: taxable 550.00, tax 66.00, lines 1 | 18%: taxable 309.90, tax 55.78, lines 4"),
("total tax", () => Money(TotalTax(lines)), "170.29"),
("top 3 codes by tax", () => string.Join(" | ", TopCodes(lines, 3)), "P600 66.00 | P500 37.80 | P300 30.51"),
("a credit note cancels its sale", () => TopCodes(lines, 6).Last(), "P200 0.00"),
("no lines: no rows", () => string.Join(" | ", ByRate([])), ""),
("no lines: no tax", () => Money(TotalTax([])), "0.00"),
};
int passed = 0;
foreach (var (name, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = e.GetType().Name;
}
string verdict = got == expected ? "ok" : $"FAIL, got \"{got}\"";
if (verdict == "ok")
{
passed++;
}
Console.WriteLine($"{name}: {verdict}");
}
Console.WriteLine($"{passed} of {checks.Count} checks pass");
return passed == checks.Count ? 0 : 1;
// The tax of one line, rounded to two decimal places, halves away from zero.
static decimal Tax(Line line) => Math.Round(line.Taxable * line.Rate / 100m, 2, MidpointRounding.AwayFromZero);
// Two decimal places with a point, the same on every computer.
static string Money(decimal amount) => amount.ToString("0.00", CultureInfo.InvariantCulture);
// "rate%: taxable T, tax X, lines N" for each rate, lowest rate first; T and X are sums over the rate's lines.
static IEnumerable<string> ByRate(IEnumerable<Line> lines) =>
throw new NotImplementedException("Group the lines by Rate, order the groups by their key, and Sum each one twice.");
// The sum of the taxes of all lines.
static decimal TotalTax(IEnumerable<Line> lines) =>
throw new NotImplementedException("Sum the lines with Tax as the selector.");
// "code X" for the n item codes with the most tax over all their lines; equal taxes by code.
static IEnumerable<string> TopCodes(IEnumerable<Line> lines, int n) =>
throw new NotImplementedException("Group by Code, total each group's Tax, sort by it, highest first, then by code, and Take n.");
record Line(string Invoice, string Code, int Rate, decimal Taxable); Output (exit status 1)
one row per rate, lowest rate first: FAIL, got "NotImplementedException" total tax: FAIL, got "NotImplementedException" top 3 codes by tax: FAIL, got "NotImplementedException" a credit note cancels its sale: FAIL, got "NotImplementedException" no lines: no rows: FAIL, got "NotImplementedException" no lines: no tax: FAIL, got "NotImplementedException" 0 of 6 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run tax_summary.cs
Write the summary
Copy tax_summary.cs. Group the lines by Rate, order the groups by their key and Sum each one twice, once for
Taxable and once with Tax; total the tax with lines.Sum(Tax); group by Code for the top codes, sort by the
total tax and then by code, and Take(n). Run dotnet run tax_summary.cs until it prints 6 of 6 checks pass.
The questions below stay with grouping and the five aggregates of the first two sections, and the idea your answers show you know least comes first:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between GroupBy and ToLookup?
Both put the items with the same key into groups, each with its key. GroupBy is deferred: it returns a query that
groups the source when it is read, every time it is read, and reads the whole source before it hands on the first
group. ToLookup groups at once and keeps the result in an ILookup, so you can read it many times, and find the
group of a key with lookup[key], without grouping again. Use GroupBy inside a query you read once, and
ToLookup when you need the groups again or by key.
-
Fresher to mid
A dashboard shows each store's average sale with stores.Select(s => new { s.Name, Avg = s.Sales.Average(x => x.Amount) }). The morning a new store opens, the whole page fails with InvalidOperationException. Explain the cause, fix it, and say what the page should show for that store.
-
Mid to senior
A support report groups the month's tickets with tickets.GroupBy(t => t.Status) and prints a count per status. In quiet months the Escalated row disappears instead of showing 0, and the chart built on it shifts its colours. Explain why and fix it.
-
Mid to senior
A nightly job reads 50 million log lines lazily, groups them with GroupBy(l => l.StatusCode) and counts each group. It fails with OutOfMemoryException. What does GroupBy keep, roughly how much memory is that, and what do you use instead?
Key takeaways
GroupBymakes one grouping per key, a sequence with aKey, in the order the keys first appear and with the items in source order.Count,Sum,Average,MinandMaxfold a group into one number;Sumof nothing is 0, whileAverage,MinandMaxof nothing throw.ToLookupkeeps the groups and finds them by key, andCountByandAggregateBytotal per key without building groups at all.
Terms in this lesson
- aggregation
- Folding many values into one, such as a count, a sum, an average, a minimum or a maximum, of a whole sequence or of each group of it.
- grouping
- An IGrouping<TKey,TElement>, one of the results of GroupBy: a sequence of the items that share one key, with that key in its Key property.
- lookup
- An ILookup<TKey,TElement>, made by ToLookup: all the groups of a sequence, built at once and kept, so the items of a key can be read with lookup[key] as often as needed.
References
- Grouping data (C#) (Microsoft Learn)
- Enumerable.GroupBy method (Microsoft Learn)
- Enumerable.ToLookup method (Microsoft Learn)
- Enumerable.CountBy method (Microsoft Learn)
- Enumerable.AggregateBy method (Microsoft Learn)
- Enumerable.Aggregate method (Microsoft Learn)
- Enumerable.Average method (Microsoft Learn)
- Enumerable.Sum method (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress