Aggregation and the GROUP BY Clause

A retail chain stores every sale in one giant table: store, product, date, quantity, price. A basic Basic SELECT Queries and Projection query can list rows. But it cannot answer "what is the total revenue per store?" It also cannot answer "which products sold more than 1000 units last month?" Answering questions like these requires collapsing many rows into one summary value per group. This is exactly what SQL aggregation and `GROUP BY` provide. Aggregation is the mechanism by which a database turns detail into insight. Nearly every dashboard, report, and analytics query is a `GROUP BY` query in disguise. This includes total sales by region, average latency by endpoint, and count of failed logins by user. Understanding how the database partitions rows is essential. It must also compute aggregate functions per partition, and filter those partitions. This matters not just for writing correct SQL, but for reasoning about performance. Aggregation is one of the most expensive operations a query engine performs. The big picture is a three-stage pipeline. Rows are logically partitioned into groups sharing the same key. An aggregate function reduces each group to a single value. An optional `HAVING` clause discards groups that fail a condition. This pipeline sits conceptually after `WHERE` (row filtering). It comes before the final `SELECT` list is projected. This explains many of the rules — and surprises — around what is legal to write in a `GROUP BY` query. This article develops the semantics of aggregate functions and `GROUP BY` precisely. It walks through the algorithms a real database uses to implement grouping (sort-based and hash-based). It analyzes their time and space complexity. It also works a full trace on a concrete sales table. It closes with the common mistakes students make (mixing aggregated and non-aggregated columns, confusing `WHERE` with `HAVING`). Finally, it connects the idea forward to Nested Subqueries and Views in SQL, where grouped queries become reusable building blocks.

Key takeaways
  • Aggregate functions (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`) reduce a set of rows to a single scalar value.
  • `GROUP BY` partitions a relation into groups sharing equal values on the grouping attributes, one output row per group.
  • `HAVING` filters groups after aggregation; `WHERE` filters rows before aggregation — they operate at different stages.
  • The `SELECT` list in a grouped query may only contain grouping attributes and aggregate expressions, not raw non-grouped columns.
  • Real engines implement grouping via sort-based (`O(n log n)`) or hash-based (`O(n)` average) algorithms; the optimizer picks based on data size and available indexes.
  • `NULL` values are excluded by most aggregates (except `COUNT(*)`) and form their own group under `GROUP BY`.
The core insight

`GROUP BY` partitions rows into equivalence classes on a key, and every aggregate function then reduces each partition, independently, to one row.

The idea

An aggregate function takes a multiset of values and returns a single value summarizing them. SQL's standard aggregates are `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. Applied without `GROUP BY`, an aggregate function collapses the entire result of the `FROM`/`WHERE` clause into one row — for example, `SELECT AVG(price) FROM sales` returns a single number, the mean price across all rows. The `GROUP BY` clause generalizes this by first partitioning the rows into groups based on equality of one or more attributes (the grouping attributes), and then applying the aggregate function independently to each group, producing one output row per group. Formally, if `G` is the set of grouping attributes, `GROUP BY G` defines an equivalence relation on rows: two rows are in the same group if and only if they agree on every attribute in `G`. The output relation has exactly one row per equivalence class, and every expression in the `SELECT` list must be either (a) a grouping attribute, whose value is constant within a group, or (b) an aggregate expression, which reduces the group to a scalar. This restriction is not a stylistic preference — it is a semantic necessity, because any other expression would not have a well-defined single value per group. The `HAVING` clause is a post-aggregation filter: it evaluates a condition over the aggregated values of each group and discards groups that fail it, analogous to how `WHERE` discards individual rows before grouping happens. Because `HAVING` operates after aggregation, its condition may reference aggregate functions (`HAVING COUNT(*) > 5`), while `WHERE`, evaluated before any grouping, may not.

How it works

Conceptually, SQL defines the evaluation order of a query with `GROUP BY` as a strict pipeline, even though a real optimizer may reorder operations internally as long as the result is equivalent: 1. Compute `FROM` (joins, cross products) to build the working relation. 2. Apply `WHERE` to filter individual rows. 3. Apply `GROUP BY` to partition the surviving rows into groups by the grouping attributes. 4. Apply aggregate functions to each group, producing one summary row per group. 5. Apply `HAVING` to filter out groups whose aggregate values fail the condition. 6. Apply `SELECT` to project the final columns (and any final expressions), and `ORDER BY` to sort. Two dominant physical algorithms implement step 3–4 inside a real query engine.

Sort-based grouping. Sort all rows by the grouping attributes; then scan the sorted output once, and every time the grouping-attribute value changes, a new group boundary is detected. Because equal keys are contiguous after sorting, a single linear scan can accumulate the aggregate for each group as it goes.

Hash-based grouping. Build an in-memory hash table keyed by the grouping attributes; for each row, hash its group key, look up (or create) the accumulator entry, and update it incrementally (e.g., add to a running sum, increment a running count). At the end, each hash bucket holds one finished group. ``` // Hash-based GROUP BY + aggregate + HAVING function GROUP_BY_AGGREGATE(rows, groupCols, aggSpecs, havingPred): table = empty hash map // key: tuple of groupCols -> accumulator state for row in rows: // WHERE has already been applied to `rows` key = extract(row, groupCols) if key not in table: table[key] = init_accumulators(aggSpecs) // e.g. count=0, sum=0, min=+inf, max=-inf for spec in aggSpecs: update_accumulator(table[key][spec], row, spec) // COUNT(): count += 1 // SUM(x): sum += row.x (skip if row.x is NULL, except COUNT()) // AVG(x): sum += row.x; cnt += 1 (avg computed at finalize) // MIN(x): min = row.x if row.x < min // MAX(x): max = row.x if row.x > max results = [] for key, accs in table: finalValues = finalize(accs) // e.g. avg = sum / cnt if havingPred(key, finalValues): // HAVING filters groups results.append(build_row(key, finalValues)) return results ``` ``` // Sort-based GROUP BY + aggregate + HAVING (streaming variant) function GROUP_BY_SORTED(rows, groupCols, aggSpecs, havingPred): sortedRows = external_sort(rows, key = groupCols) // O(n log n) results = [] currentKey = null accs = null for row in sortedRows: key = extract(row, groupCols) if key != currentKey: if currentKey != null: emit_if_passes(currentKey, accs, havingPred, results) currentKey = key accs = init_accumulators(aggSpecs) for spec in aggSpecs: update_accumulator(accs[spec], row, spec) if currentKey != null: emit_if_passes(currentKey, accs, havingPred, results) return results ``` Both algorithms only ever hold one accumulator per group in memory (not the raw rows), which is why aggregation can process arbitrarily many rows using space proportional to the number of distinct groups, not the number of rows.

Figure
Pipeline: rows are filtered, partitioned into groups, aggregated, then group-filtered by HAVING
Raw rows (sales) store product qty A shoes 3 A shoes 2 A socks 5 B shoes 4 B socks 1 B socks 1 C shoes 9 WHERE filters rows GROUP BY store, product (A, shoes): {3,2} 2 rows -> SUM=5 (A, socks): {5} -> SUM=5 (B, shoes): {4} -> SUM=4 (B, socks): {1,1} 2 rows -> SUM=2 (C, shoes): {9} -> SUM=9 aggregate each group HAVING SUM(qty) >= 5 A, shoes SUM=5 keep A, socks SUM=5 keep B, shoes SUM=4 drop B, socks SUM=2 drop C, shoes SUM=9 keep Result A shoes 5 A socks 5 C shoes 9

Complexity & correctness

Time complexity.

  • Sort-based grouping: dominated by the sort, so `O(n log n)` comparisons for `n` input rows, plus one linear pass `O(n)` to accumulate aggregates. If the sort must spill to disk (external merge sort, since aggregation is often used on tables far larger than memory), the cost is `O((n / B) log_{M/B}(n / B))` I/Os, where `M` is memory size and `B` is the block size — the standard external-sort bound.
  • Hash-based grouping: expected `O(n)` time overall, since each row does `O(1)` expected work for a hash lookup/insert (assuming a good hash function and load factor bounded by a constant); worst case `O(n^2)` under adversarial hash collisions, though practical implementations resize and rehash to keep this from happening. If the table of groups does not fit in memory, a hash-based external algorithm partitions rows into buckets by a first-level hash (like external hashing used in hash joins), recursing until each partition's group table fits in memory.

Space complexity. The crucial fact is that grouping algorithms need space proportional to the number of distinct groups, `g`, not the number of rows `n`, because only one accumulator (a few numbers: running sum, count, min, max) is kept per group — never the raw member rows. So space is `O(g)` for hash-based in-memory grouping, and `O(g)` auxiliary plus `O(n)` for the sort buffer in sort-based grouping (or `O(M)` per pass if external sorting is used, with total I/O as above). Since typically `g ≪ n` (e.g., grouping millions of sales rows into a few hundred stores), aggregation queries are far cheaper than sorting all columns of the full table would suggest.

Correctness argument. The correctness of `GROUP BY` rests on it defining a genuine partition (equivalence relation) of the input rows:

  • Reflexivity/symmetry/transitivity of "agrees on all grouping attributes" is immediate from equality being an equivalence relation, so the groups are well-defined, disjoint, and exhaustive — every row belongs to exactly one group.
  • Because groups are disjoint and exhaustive, applying an aggregate function to each group independently and unioning the results is equivalent to applying it to the whole relation partitioned that way — no row is double-counted or omitted, provided every row that survives `WHERE` is assigned to some group (this holds by construction, since the group key is total over the surviving rows, with `NULL` grouping values treated as a distinguished group per the SQL standard, since two `NULL`s are considered equal for grouping purposes though not for `=` comparison elsewhere).
  • The accumulator update rule (init identity element, then fold rows in) is a commutative, associative fold for `SUM`, `COUNT`, `MIN`, `MAX` (and `AVG` derived from `SUM`/`COUNT` at the end), so the order in which rows are streamed into an accumulator does not affect the final result. This is exactly why both the sort-based algorithm (rows arrive sorted) and the hash-based algorithm (rows arrive in arbitrary order) produce identical, correct answers — associativity and commutativity make the fold order-independent.
  • The restriction that non-aggregated, non-grouping columns cannot appear in `SELECT` is a soundness requirement, not a stylistic rule: such a column generally has multiple distinct values within a single group, so no single well-defined value exists to output for that row.

Tradeoffs. Sort-based grouping is preferable when the input is already sorted (e.g., via an index on the grouping column) — then the sort step is free and the algorithm becomes a single linear scan. Hash-based grouping is preferable when no such order exists and the number of distinct groups is small enough to fit a hash table in memory, since it avoids the `log n` sorting factor entirely. When the number of distinct groups is very large (close to `n`), hash-based grouping loses its main advantage and may thrash memory with a huge hash table, at which point sort-based (or hybrid hash/sort) strategies become competitive again — real optimizers (e.g., PostgreSQL, SQL Server) choose between `HashAggregate` and `GroupAggregate` (sort-based) plans using estimated group cardinalities.

Worked example

Consider a `sales` table:

storeproductqtyprice
Ashoes350
Ashoes250
Asocks58
Bshoes445
Bsocks18
Bsocks18
Cshoes960

Query: ```sql SELECT store, product, SUM(qty) AS total_qty, AVG(price) AS avg_price FROM sales WHERE qty > 0 GROUP BY store, product HAVING SUM(qty) >= 5; ```

Step 1 — `WHERE qty > 0`. All 7 rows satisfy `qty > 0`; none are filtered.

Step 2 — `GROUP BY store, product`. Partition rows by the pair `(store, product)`:

Group keyMember rows
(A, shoes)qty=3, price=50; qty=2, price=50
(A, socks)qty=5, price=8
(B, shoes)qty=4, price=45
(B, socks)qty=1, price=8; qty=1, price=8
(C, shoes)qty=9, price=60

Step 3 — apply aggregates per group.

Group keySUM(qty)AVG(price)
(A, shoes)3+2 = 5(50+50)/2 = 50
(A, socks)58
(B, shoes)445
(B, socks)1+1 = 2(8+8)/2 = 8
(C, shoes)960

Step 4 — `HAVING SUM(qty) >= 5`. Keep groups with `SUM(qty) >= 5`: `(A, shoes)` with 5 ✓, `(A, socks)` with 5 ✓, `(B, shoes)` with 4 ✗ (dropped), `(B, socks)` with 2 ✗ (dropped), `(C, shoes)` with 9 ✓.

Final result:

storeproducttotal_qtyavg_price
Ashoes550
Asocks58
Cshoes960

Tracing the hash-based pseudocode on this input confirms the same result: processing the 7 rows in order, the hash table accumulates exactly these five entries, and only three survive the `HAVING` predicate at finalize time — matching the table above regardless of row-processing order, precisely because `SUM` is a commutative/associative fold.

Common pitfalls & traps

PitfallWhy it happensFix
Selecting a non-grouped, non-aggregated columnThe value is not unique within a group, so SQL rejects it (or, in lenient dialects, silently picks an arbitrary row)Add the column to `GROUP BY`, or wrap it in an aggregate like `MIN`/`MAX`/`ANY_VALUE`
Using `WHERE` to filter on an aggregate`WHERE` runs before grouping, so `COUNT`/`SUM` are not yet computedUse `HAVING` for conditions on aggregate values
Using `HAVING` where `WHERE` would sufficeFiltering individual rows in `HAVING` forces the engine to group first, wasting workPut row-level filters in `WHERE` so fewer rows enter grouping
Forgetting `NULL`s are excluded from `SUM`/`AVG`/`COUNT(col)`Aggregates other than `COUNT(*)` skip `NULL` inputs by definitionUse `COUNT(*)` to count all rows, `COUNT(col)` to count non-null values, and `COALESCE` if a default is needed
Assuming `GROUP BY` output order matches group-key orderSQL does not guarantee any output order without `ORDER BY`Add an explicit `ORDER BY` clause to the final result
Grouping by an expression but selecting a different onee.g., `GROUP BY YEAR(date)` but `SELECT date`Select the same expression used in `GROUP BY` (or an aggregate)

Pioneers & history

Aggregation and grouping trace back to the relational model itself and to the first SQL implementations that needed to support business reporting, historically the dominant use case for databases.

Origins · 1 minEdgar F. Codd and how this was discovered
EF
Edgar F. Codd
1923–2003 · Mathematician and IBM researcher who invented the relational model of data

Codd's 1970 paper defined relations, tuples, and the algebraic operations that make aggregation formally meaningful: grouping rows by shared attribute values is a direct descendant of his relational algebra's emphasis on set-based, order-independent operations over tables, rather than record-at-a-time navigation.

History
1970
— Codd publishes the relational model, establishing set-based operations as the foundation for query languages.
1974
— Chamberlin and Boyce design SEQUEL (later SQL) at IBM, including early aggregate and grouping constructs.
1979
— Oracle ships the first commercial SQL-based relational database.
1986
— ANSI standardizes SQL (SQL-86), formalizing `GROUP BY` and `HAVING` semantics.
1992
— SQL-92 refines aggregate function and grouping rules still used, largely unchanged, in modern SQL dialects.

Where this matters

`GROUP BY` and aggregation are the computational core of business intelligence and analytics platforms: every "revenue by region," "daily active users," or "average response time by endpoint" dashboard compiles down to a grouped aggregate query. OLAP systems and data warehouses (star-schema fact/dimension designs) are built almost entirely around efficient large-scale grouping, often precomputing aggregates via materialized Views in SQL so expensive `GROUP BY` queries need not be recomputed from scratch on every request. Columnar analytical engines (e.g., those used in modern data warehouses) are specifically optimized for exactly this workload, since scanning a single column and grouping it is far cheaper than reading full rows. Query optimizers in systems like PostgreSQL, MySQL, and SQL Server choose between hash-based and sort-based aggregation plans automatically, and understanding this choice helps developers write queries (and design indexes) that steer the optimizer toward the cheaper plan. Grouped queries also frequently appear as the inner query of a Nested Subqueries pattern — for example, filtering outer rows based on a computed per-group aggregate — making a solid grasp of `GROUP BY` a prerequisite for more advanced SQL composition.

Ask the tutor: How would this query's execution plan change if there were a B+-tree index on the grouping column?

Can I use HAVING without GROUP BY?

Yes. If a query has no `GROUP BY`, the entire result set is treated as a single implicit group, so `HAVING` can filter that one group based on an aggregate over the whole table — for example, `SELECT COUNT() FROM sales HAVING COUNT() > 100` returns one row if there are more than 100 sales rows, and zero rows otherwise. This is rare in practice but legal, and it highlights that `HAVING` conceptually always filters groups, whether there is one group or many.

Why can't I write WHERE COUNT() > 5 instead of HAVING COUNT() > 5?

`WHERE` is evaluated while individual rows are being scanned and filtered, before any grouping or aggregation has happened, so `COUNT()` has no meaning yet at that stage — there is nothing to count per group. `HAVING` runs after groups have been formed and aggregates computed, so it has access to `COUNT()`, `SUM(...)`, and other aggregate values. Some engines give a clear error for `WHERE COUNT(*) > 5`; others may reject it silently as invalid syntax, but the underlying reason is always this ordering of evaluation stages.

Do all databases evaluate GROUP BY the same way internally?

No. The SQL standard specifies only the logical result — which rows form which groups and what the aggregate values must be — not the physical algorithm. Different database engines (and even different query plans within the same engine, chosen by the optimizer) may use sort-based grouping, hash-based grouping, or hybrid strategies depending on data size, available indexes, and memory. As long as the aggregate function is a well-defined, order-independent fold (true for standard SQL aggregates), all correct implementations must return the same logical result even though their internal execution differs.

What happens if I GROUP BY a column that contains NULL values?

All rows whose grouping-attribute value is `NULL` are placed into a single group together, since SQL treats two `NULL`s as equal for grouping purposes even though `NULL = NULL` evaluates to unknown elsewhere in SQL (e.g., in a `WHERE` clause). This is a deliberate special case in the standard: grouping needs a total equivalence relation over all values including `NULL`, whereas three-valued logic in comparisons is designed for uncertainty handling, a different purpose entirely.

Is GROUP BY always more expensive than a plain SELECT?

Almost always, yes, because grouping requires either sorting the rows or building a hash table, whereas a plain filtered `SELECT` can often stream rows through with `O(1)` extra work each. However, the relative cost depends heavily on the number of distinct groups: grouping millions of rows into a handful of groups is comparatively cheap (small hash table, or a cheap partial sort with an index), while grouping into near-unique keys approaches the cost of sorting the entire table. Indexes on the grouping columns can make sort-based grouping essentially free by delivering pre-sorted input.

Can I nest an aggregate function inside another aggregate function, like SUM(AVG(x))?

Standard SQL disallows nesting aggregate functions directly within a single `SELECT`'s scope, because it is ambiguous and rarely meaningful — an aggregate function already reduces a group to one value, and applying another aggregate to a single value is either a no-op or undefined without a further grouping level. To achieve a "group of groups" computation (e.g., average of per-store totals), you nest a grouped query inside another query, a pattern developed further in Nested Subqueries.

What is the difference between DISTINCT and GROUP BY?

`SELECT DISTINCT col FROM t` simply removes duplicate rows from the projected output — it has no notion of aggregation. `GROUP BY col` also produces one row per distinct value of `col`, but it exists specifically to let you compute an aggregate (`SUM`, `COUNT`, etc.) per group; without any aggregate function in the `SELECT` list, `GROUP BY col` and `SELECT DISTINCT col` produce the same set of rows, though `GROUP BY` signals intent to summarize and typically pairs with an aggregate.

Before this

Where this leads