Aggregation, grouping and reporting
Most reports are three moves: group rows by a key into a hash map of accumulators, fold each row into its bucket (count, sum, max), then order and cut with a complete sort key, ties included. One pass to group is O(n); ordering g groups is O(g log g). “Top N per group” is the same thing with a second key: partition, sort inside each partition, take N.
The idea in plain words
Section titled “The idea in plain words”Picture a pile of receipts and a row of labelled envelopes. You pick up each receipt once, read its region, and drop it in the right envelope, creating a new envelope the first time you see a region. You don’t need to keep the receipts, only a running tally on each envelope: how many, how much in total, the biggest one.
That’s GROUP BY. The envelope label is the key. The tally is the accumulator. Folding one receipt into a tally is O(1), so the whole pile is O(n).
Then the report: “regions by total, highest first”. That’s a sort of the envelopes, not the receipts. And as soon as two envelopes have the same total, someone will ask which comes first. The answer must be in your sort key, not left to chance.
| Term | Meaning |
|---|---|
| Group key | What you bucket by. Can be a tuple: (region, month). |
| Aggregate | A summary of a bucket: COUNT, SUM, MIN, MAX, AVG. |
| Decomposable aggregate | Can be built from partial results: sum of sums, max of maxes. AVG is not, but (sum, count) is. |
| Tie-break | The extra sort key that makes the order total (then name ascending). |
| Window function | SQL that computes per-row values over a partition without collapsing rows: ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC). |
See it
Section titled “See it”Step through the fold: each row lights up its bucket and updates the running aggregate. Change the key or the aggregate and replay. Then switch the view to window: top N per group and edit amounts to create ties: watch ROW_NUMBER, RANK and DENSE_RANK disagree, and see which rows survive the filter.
| id | region | rep | product | amount |
|---|---|---|---|---|
| 1 | EU | ana | pen | |
| 2 | US | bob | ink | |
| 3 | EU | chen | ink | |
| 4 | APAC | dev | pad | |
| 5 | EU | ana | cap | |
| 6 | US | bob | pen | |
| 7 | EU | chen | pad | |
| 8 | APAC | dev | ink | |
| 9 | US | ana | cap |
| region | SUM(amount) |
|---|---|
| (no buckets yet) | |
When to reach for it
Section titled “When to reach for it”| The problem says… | What fits |
|---|---|
| “total / count / average per customer, merchant, day…” | Hash map from key to accumulator, one pass. |
| “top N overall, sorted by X descending, then by name” | Aggregate, then sort with a tuple key, then slice. Or a size-N heap if N ≪ groups. |
| “top N in each category” | Partition by category, rank inside each partition. In SQL, ROW_NUMBER() OVER (PARTITION BY …). |
| “only groups with more than 3 orders” | Filter after aggregating: SQL HAVING, not WHERE. |
| “running total”, “change since previous day” | Window functions (SUM() OVER (ORDER BY day), LAG), or prefix sums in code. |
| “per hour / per day buckets” | The key is a time bucket: ts - ts % 3600. See time handling. |
| “frequent queries over changing data” | Maintain aggregates incrementally on each write instead of recomputing. |
How it works
Section titled “How it works”Group and aggregate
Section titled “Group and aggregate”- Create a map from key to accumulator. Make an accumulator the first time a key appears.
- One pass: for each row, find its bucket and fold the row in (count += 1, total += amount, max = max(…)).
- Order the output explicitly. Hash maps give insertion order (Python, JS
Map) or no promised order at all (C#Dictionary).
from collections import defaultdictfrom dataclasses import dataclass
@dataclass(frozen=True)class Sale: region: str product: str cents: int
@dataclassclass Stats: count: int = 0 total: int = 0 max: int = 0
def by_region(sales: list[Sale]) -> dict[str, Stats]: groups: dict[str, Stats] = defaultdict(Stats) # 1. one accumulator per key, made on first sight for s in sales: # 2. one pass: fold each row into its bucket g = groups[s.region] g.count += 1 g.total += s.cents g.max = max(g.max, s.cents) return dict(sorted(groups.items())) # 3. sort keys: dict order is insertion order
if __name__ == "__main__": sales = [Sale("EU", "pen", 300), Sale("US", "ink", 1200), Sale("EU", "ink", 900), Sale("EU", "pen", 300)] stats = by_region(sales) assert list(stats) == ["EU", "US"] assert (stats["EU"].count, stats["EU"].total, stats["EU"].max) == (3, 1500, 900) assert stats["EU"].total // stats["EU"].count == 500 # average from sum and count, exact ints assert by_region([]) == {} # empty input -> no groups, not a zero row print("ok")import assert from "node:assert/strict";
interface Sale { region: string; product: string; cents: number }interface Stats { count: number; total: number; max: number }
export function byRegion(sales: Sale[]): Map<string, Stats> { const groups = new Map<string, Stats>(); for (const s of sales) { // 2. one pass: fold each row into its bucket let g = groups.get(s.region); if (!g) { g = { count: 0, total: 0, max: 0 }; groups.set(s.region, g); } // 1. accumulator on first sight g.count += 1; g.total += s.cents; g.max = Math.max(g.max, s.cents); } // 3. sort keys by code point (Map keeps insertion order; localeCompare would vary by locale) return new Map([...groups].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));}
const sales: Sale[] = [ { region: "EU", product: "pen", cents: 300 }, { region: "US", product: "ink", cents: 1200 }, { region: "EU", product: "ink", cents: 900 }, { region: "EU", product: "pen", cents: 300 },];const stats = byRegion(sales);assert.deepEqual([...stats.keys()], ["EU", "US"]);assert.deepEqual(stats.get("EU"), { count: 3, total: 1500, max: 900 });assert.equal(Math.trunc(stats.get("EU")!.total / stats.get("EU")!.count), 500);assert.equal(byRegion([]).size, 0); // empty input -> no groupsconsole.log("ok");Sale[] sales = [new("EU", "pen", 300), new("US", "ink", 1200), new("EU", "ink", 900), new("EU", "pen", 300)];
// 1-2. GroupBy buckets rows by key; each bucket is folded into one summary row.// 3. OrderBy with an ordinal comparer: deterministic, culture-independent order.var stats = sales .GroupBy(s => s.Region) .Select(g => new Stats(g.Key, g.Count(), g.Sum(s => s.Cents), g.Max(s => s.Cents))) .OrderBy(s => s.Region, StringComparer.Ordinal) .ToList();
if (string.Join(",", stats.Select(s => s.Region)) != "EU,US") throw new Exception("order");if (stats[0] != new Stats("EU", 3, 1500, 900)) throw new Exception(stats[0].ToString());if (stats[0].Total / stats[0].Count != 500) throw new Exception("average");if (Array.Empty<Sale>().GroupBy(s => s.Region).Any()) throw new Exception("empty -> no groups");Console.WriteLine("ok");
record Sale(string Region, string Product, long Cents);record Stats(string Region, int Count, long Total, long Max);Language notes worth knowing:
- Python:
collections.defaultdictandCountercover most cases.itertools.groupbyonly groups adjacent equal keys, so it needs sorted input; it’s a classic bug to use it on unsorted rows. - TypeScript:
Mapwithget ?? init. ES2024 addedObject.groupByandMap.groupBy(Node 21+), which build arrays of rows per key; you still fold them yourself. - C#: LINQ
GroupByyields groups in first-seen order of keys, each anIGrouping<TKey, T>.ToLookupis the eager version. .NET 9 addsCountByandAggregateBythat fold without materializing groups.
Top N per group
Section titled “Top N per group”- Aggregate to the grain you rank at, e.g. revenue per
(region, product). - Regroup by the partition key (
region). - In each partition, sort by the full key (
-revenue, thenproduct), then take the first N.
from collections import defaultdict
def top_n_per_group(rows: list[tuple[str, str, int]], n: int) -> dict[str, list[tuple[str, int]]]: """Top n products by revenue in each region. Ties: product name ascending.""" revenue: dict[tuple[str, str], int] = defaultdict(int) for region, product, cents in rows: # 1. aggregate to (group, item) totals first revenue[(region, product)] += cents per_group: dict[str, list[tuple[str, int]]] = defaultdict(list) for (region, product), total in revenue.items(): # 2. regroup by the partition key per_group[region].append((product, total)) return { region: sorted(items, key=lambda it: (-it[1], it[0]))[:n] # 3. full sort key, then cut for region, items in sorted(per_group.items()) }
if __name__ == "__main__": rows = [("EU", "pen", 300), ("EU", "ink", 500), ("EU", "cap", 500), ("EU", "pen", 300), ("US", "ink", 1200), ("US", "pad", 100)] top = top_n_per_group(rows, 2) assert top["EU"] == [("pen", 600), ("cap", 500)] # cap beats ink on the name tie-break assert top["US"] == [("ink", 1200), ("pad", 100)] assert top_n_per_group(rows, 0) == {"EU": [], "US": []} print("ok")import assert from "node:assert/strict";
type Row = [region: string, product: string, cents: number];const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
/** Top n products by revenue in each region. Ties: product name ascending. */export function topNPerGroup(rows: Row[], n: number): Map<string, [string, number][]> { const revenue = new Map<string, Map<string, number>>(); // region -> product -> total for (const [region, product, cents] of rows) { // 1. aggregate to (group, item) totals first const byProduct = revenue.get(region) ?? new Map<string, number>(); byProduct.set(product, (byProduct.get(product) ?? 0) + cents); revenue.set(region, byProduct); // 2. nested map = already regrouped by partition key } const out = new Map<string, [string, number][]>(); for (const region of [...revenue.keys()].sort(cmp)) { const items = [...revenue.get(region)!]; items.sort((a, b) => b[1] - a[1] || cmp(a[0], b[0])); // 3. full sort key, then cut out.set(region, items.slice(0, n)); } return out;}
const rows: Row[] = [["EU", "pen", 300], ["EU", "ink", 500], ["EU", "cap", 500], ["EU", "pen", 300], ["US", "ink", 1200], ["US", "pad", 100]];const top = topNPerGroup(rows, 2);assert.deepEqual(top.get("EU"), [["pen", 600], ["cap", 500]]); // cap beats ink on the tie-breakassert.deepEqual(top.get("US"), [["ink", 1200], ["pad", 100]]);assert.deepEqual(topNPerGroup(rows, 0).get("EU"), []);console.log("ok");(string Region, string Product, long Cents)[] rows =[ ("EU", "pen", 300), ("EU", "ink", 500), ("EU", "cap", 500), ("EU", "pen", 300), ("US", "ink", 1200), ("US", "pad", 100),];
var top = TopNPerGroup(rows, 2);if (Show(top["EU"]) != "pen:600,cap:500") throw new Exception(Show(top["EU"])); // name breaks the tieif (Show(top["US"]) != "ink:1200,pad:100") throw new Exception(Show(top["US"]));if (TopNPerGroup(rows, 0)["EU"].Count != 0) throw new Exception("n = 0");Console.WriteLine("ok");
// Top n products by revenue in each region. Ties: product name ascending (ordinal).static Dictionary<string, List<(string Product, long Total)>> TopNPerGroup( IEnumerable<(string Region, string Product, long Cents)> rows, int n) => rows.GroupBy(r => (r.Region, r.Product)) // 1. (group, item) totals .Select(g => (g.Key.Region, g.Key.Product, Total: g.Sum(r => r.Cents))) .GroupBy(x => x.Region) // 2. regroup by partition key .ToDictionary( g => g.Key, g => g.OrderByDescending(x => x.Total) // 3. full sort key, then cut .ThenBy(x => x.Product, StringComparer.Ordinal) .Take(n) .Select(x => (x.Product, x.Total)) .ToList());
static string Show(List<(string Product, long Total)> xs) => string.Join(",", xs.Select(x => $"{x.Product}:{x.Total}"));The same in SQL
Section titled “The same in SQL”-- Totals per region, only regions with more than 2 sales.SELECT region, COUNT(*) AS n, SUM(amount) AS total, MAX(amount) AS biggestFROM salesWHERE amount > 0 -- filters rows, before groupingGROUP BY regionHAVING COUNT(*) > 2 -- filters groups, after aggregatingORDER BY total DESC, region; -- explicit order, including the tie-break-- Top 2 products by revenue in each region.WITH revenue AS ( SELECT region, product, SUM(amount) AS total FROM sales GROUP BY region, product),ranked AS ( SELECT region, product, total, ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC, product) AS rn FROM revenue)SELECT region, product, totalFROM rankedWHERE rn <= 2 -- window results can't be filtered in the same SELECT's WHEREORDER BY region, rn;Swap ROW_NUMBER for RANK if tied rows should all be kept (“top 2, including ties”), or DENSE_RANK for “the top 2 distinct values”.
-- Running total per region and change from the previous sale.SELECT region, sold_at, amount, SUM(amount) OVER (PARTITION BY region ORDER BY sold_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running, amount - LAG(amount) OVER (PARTITION BY region ORDER BY sold_at) AS deltaFROM sales;What it costs
Section titled “What it costs”| Step | Time | Space | Why |
|---|---|---|---|
| Group + fold (hash map) | O(n) average | O(g) | One O(1) update per row; g = number of distinct keys. |
| Group by sorting first | O(n log n) | O(1) to O(n) | Sort, then scan runs of equal keys. Useful when data doesn’t fit a map, or is already sorted. |
| Order the groups | O(g log g) | O(g) | Sort the summaries, not the raw rows. |
| Top N of g with a heap | O(g log N) | O(N) | Keep the N best; Python heapq.nlargest(n, items, key=...). |
| Top N per group, p partitions | O(Σ gᵢ log gᵢ) ≤ O(g log g) | O(g) | Sort inside each partition independently. |
| Recompute on every query vs maintain | O(n) per query vs O(1) per write | O(g) | Keep running accumulators when reads are frequent. |
Decomposable aggregates (sum, count, min, max) also let you split work: aggregate each shard, then combine the partial results. That’s how databases parallelize GROUP BY and how MapReduce combiners work. AVG and MEDIAN need care: carry (sum, count) for the average; a median needs the values (or a sketch).
Variations you’ll meet
Section titled “Variations you’ll meet”- Multi-level keys.
(merchant, day)or nested mapsmerchant → day → total. Tuples are simpler; nested maps make “everything for one merchant” cheap. - Include empty groups. “Every account, with 0 if no activity” means you seed the map with all known keys first. SQL:
LEFT JOINfrom the dimension table plusCOALESCE(SUM(x), 0). - Distinct counts.
COUNT(DISTINCT customer): a set per bucket, or HyperLogLog at scale. - Gaps and islands. Group consecutive rows with the same state:
ROW_NUMBER() OVER (ORDER BY ts) - ROW_NUMBER() OVER (PARTITION BY state ORDER BY ts)is constant within each island: the classic SQL trick. - Sessionization. A new group starts when the gap since the previous event exceeds a threshold:
LAG+ runningSUMof “is new session” flags. - Rollups.
GROUP BY ROLLUP(region, product)adds subtotal rows.
Pitfalls
Section titled “Pitfalls”- No explicit order. Neither SQL nor C#
Dictionarypromises one. Always sort the output, with a tie-break. WHEREvsHAVING. Filtering on an aggregate inWHEREis an error. Row filters belong inWHERE, so rows are dropped before the grouping work, not after.- Average of averages. The mean of per-day averages isn’t the overall average unless days have equal counts. Carry sums and counts.
- Integer division.
SUM(cents) / COUNT(*)truncates in SQL on integers and in C# onlong. Decide the rounding (see money). - NULLs.
COUNT(col)skips NULLs,COUNT(*)doesn’t.SUMof no rows is NULL, not 0. itertools.groupbyon unsorted input produces repeated groups.- Locale-sensitive string sorts.
localeCompareand culture-awareCompareToorder"a"and"B"differently from code-point order. Use ordinal comparison when the spec says “codepoint order”. - Ranking with
ROW_NUMBERwhen ties should be kept. Pick the ranking function on purpose.
In the interview
Section titled “In the interview”- Ask: what exactly is the key? What’s the tie-break? Are groups with nothing included? Is
nlarger than the number of groups? Can amounts be zero or negative? - Ask whether the report is computed once or queried after every update. That decides recompute vs maintain.
- Say out loud: “One pass into a map of accumulators, O(n). Then sort the g summaries by (-total, name), O(g log g). I won’t rely on map order.”
- For SQL rounds: name the grain first (“one row per region per product”), then the window:
PARTITION BYwhat,ORDER BYwhat, which ranking function, and why. - Expect follow-ups: top N over a sliding time window, incremental updates, sharded data (combine partial aggregates), percentile instead of average.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Word frequencies in a text, top 10 with alphabetical tie-break.
- Second-highest salary per department (SQL, with ties handled two ways).
- Monthly active users from a login log: distinct users per calendar month.
- Longest streak of consecutive days with a sale per store (gaps and islands).
Go deeper
Section titled “Go deeper”- PostgreSQL: window functions tutorial and window function reference (
row_number,rank,dense_rank,lag). - Python
itertools.groupby,collectionsand the Sorting HOWTO (stable, multi-key sorts). - .NET
Enumerable.GroupByand MDNMap.groupBy. - Markus Winand, modern-sql.com and SQL Performance Explained for how databases execute grouping and windows.