Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: GroupBy puts items with the same key together, in the order the keys first appear · Count, Sum, Average, Min and Max fold each group into one number. With a pass: 3 in-depth parts and 3 interview answers, about 9 minutes.

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

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:

sales.cs: orders grouped by state and by month, then totalled C# · sales/sales.cs
// 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:

Interactive · step by step GroupBy(o => o.State) reads 10 orders into 4 groups Step through the orders as GroupBy reads them. The order being read is on the left; each group on the right lists its order ids.
GroupBy(o => o.State) reads 10 orders into 4 groups, step 6 of 6. Showing orders (read: all 10), group KA (orders 1, 3, 7, 10), group MH (orders 2, 5, 9), group TN (orders 4, 8), group DL (orders 6).ordersread: all 10group KAorders 1, 3, 7, 10group MHorders 2, 5, 9group TNorders 4, 8group DLorders 6

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Orders 2 and 3. Order 2 starts group MH. Order 3 is from KA again and joins the group that exists, after order 1.
  4. Orders 4 to 6. Order 4 starts TN, order 5 joins MH, and order 6 starts DL, the fourth and last new key.
  5. 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.
  6. 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

  1. Question 1 of 2 In what order did GroupBy(o => o.State) hand on the groups of sales.cs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: KA, MH, TN, DL: the order in which each state first appears in the orders

    Groups come in the order of the first appearance of their keys, and the items of each group in source order: KA holds 1, 3, 7 and 10.

  2. Question 2 of 2 An eleventh order from GJ is added at the end of the orders of sales.cs. How many groups does GroupBy(o => o.State) return?

    Type a number.

    A hint

    One group per key that appears in the source.

    Show the answer: Question 2 of 2

    Answer: 5 groups

    The four states of the ten orders, plus GJ: 5 groups. GJ comes last, because its key appears last.

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.

Pivot Table Generator Pivot a CSV of orders by state with sum, count and average, and compare each cell with what GroupBy and Sum compute.

Checkpoint

Check: Count, Sum, Average, Min and Max

  1. Question 1 of 2 KA's orders in sales.cs are Rs 1499, 3250, 899 and 2199, and the run printed an average of 1961.75. A fifth KA order of Rs 1153 arrives. What is KA's new average, in rupees?

    Type a number.

    A hint

    Add the new order to KA's revenue of 7847 and divide by 5.

    Show the answer: Question 1 of 2

    Answer: 1800 rupees (anything from 1799.99 to 1800.01 counts)

    The revenue becomes 7847 + 1153 = 9000, over 5 orders: 9000 / 5 = 1800.

  2. Question 2 of 2 A state has no orders. What do Sum(o => o.Amount) and Average(o => o.Amount) on its orders do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Sum returns 0; Average throws InvalidOperationException

    Sum and Count of an empty sequence are 0; Average, Min and Max of an empty sequence of int, double or decimal values throw.

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.

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.

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.

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:

tax_summary.cs: six checks of a grouped tax summary C# · practice/tax_summary.cs
// 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.

GST Calculator (India) Work out the tax on one line by hand, then compare it with the line's Tax in the run.

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.

  1. Question 1 of 6 What is each item that GroupBy(o => o.State) hands on?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: An IGrouping<string, Order>: the orders of one state, with the state in Key

    GroupBy returns a sequence of IGrouping<TKey, TElement>. Shape each group into a row with Select.

  2. Question 2 of 6 Orders 2, 5 and 9 come from MH, in that order in the source. In what order does the MH group hold them?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: 2, 5, 9

    GroupBy keeps two orders: the groups in the order their keys first appear, and the items in source order.

  3. Question 3 of 6 A loop over orders.GroupBy(o => o.State) prints its first group. How many orders has GroupBy read by then?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: All of them, because a later order could still belong to the first group

    GroupBy is deferred, so writing it reads nothing, but it is nonstreaming: the first read pulls in the whole source.

  4. Question 4 of 6 Which aggregate gave the 7847 of KA's row?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: g.Sum(o => o.Amount)

    Count counts, Sum adds, Average divides the sum by the count, and Min and Max pick the extremes.

  5. Question 5 of 6 TN's orders in sales.cs are Rs 2199 and Rs 1499. What is g.Average(o => o.Amount) for TN?

    Type a number.

    A hint

    Add the two amounts and divide by 2.

    Show the answer: Question 5 of 6

    Answer: 1849 rupees (anything from 1848.99 to 1849.01 counts)

    (2199 + 1499) / 2 = 3698 / 2 = 1849, as the row for TN printed.

  6. Question 6 of 6 A new report sums payments that arrive as text such as "0.10". Which type should the amounts be parsed into before Sum?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: decimal

    The orders of sales.cs are decimal for this reason. Parse money into decimal at the edge, and every Sum after it stays exact.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • GroupBy makes one grouping per key, a sequence with a Key, in the order the keys first appear and with the items in source order.
  • Count, Sum, Average, Min and Max fold a group into one number; Sum of nothing is 0, while Average, Min and Max of nothing throw.
  • ToLookup keeps the groups and finds them by key, and CountBy and AggregateBy total 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.