Aggregating: Intermediate
There are 2 families of expression in a SELECT list, and the family decides how many rows come back. A row-level expression reads 1 row and returns 1 value for that row: amount + 5, CAST(amount AS DECIMAL(10,2)), a CASE expression. Feed it 6 rows and it hands back 6 rows. An aggregate reads a set of rows and returns 1 value for the whole set: SUM(amount), COUNT(*), MAX(amount). Feed it the same 6 rows and it hands back 1 row.
- Reads 1 row, returns 1 value
- Output row count equals input row count
- Runs during SELECT, after WHERE has filtered
- amount + 5, CAST, CASE
- Reads a set of rows, returns 1 value
- Output row count equals the number of groups
- Runs after GROUP BY and feeds HAVING
- SUM, COUNT, AVG, MIN, MAX
Where the collapse happens
Mixing the families
SELECTregion,MIN(manager) AS first_manager,COUNT(*) AS managersFROM region_managersGROUP BY region
Row-level inside aggregate
The 2 families nest. An aggregate can wrap a row-level expression: SUM(amount + 5) adds 5 to each of the 6 amounts first, then folds the 6 results into 720. A row-level expression can wrap aggregates, SUM(amount) / COUNT(*), because by then each side is a single value per group. What fails is a bare reference to a column that the collapse removed, which is the rejected query above.
How many rows does this query return on the 6-row orders table above?
Writing a grouped query runs the same reasoning in reverse. Start from the row the result needs: 1 row per product means the product column goes in GROUP BY and appears in SELECT as it is. Every other column has to arrive through an aggregate, so a number of sales becomes COUNT(*) and the largest single sale becomes MAX wrapped around the amount column.
What cardinality does to a row count
| customer_id | name | city |
|---|---|---|
| C1 | Ana | Berlin |
| C2 | Ben | Lisbon |
| C3 | Cara | Oslo |
| order_id | customer_id | amount |
|---|---|---|
| 1 | C1 | 120 |
| 2 | C2 | 80 |
| 3 | C1 | 60 |
| 4 | C3 | 300 |
| 5 | C2 | 40 |
| 6 | C1 | 90 |
| order_id | customer_id | name | amount |
|---|---|---|---|
| 1 | C1 | Ana | 120 |
| 2 | C2 | Ben | 80 |
| 3 | C1 | Ana | 60 |
| 4 | C3 | Cara | 300 |
| 5 | C2 | Ben | 40 |
| 6 | C1 | Ana | 90 |
| customer rows for C1 | orders for C1 | paired rows for C1 | total rows | SUM(amount) |
|---|---|---|---|---|
| 1 | 3 | 3 | 6 | 690 |
| 2 | 3 | 6 | 9 | 960 |
- ▸1:1: the key appears once on each side. Paired rows equal the rows on either side.
- ▸1:N: the key appears once on one side and N times on the other. Paired rows equal the N side, so 1 row per order.
- ▸N:M: the key repeats on both sides. Each key yields N times M rows, and the result is larger than either table.
- ▸For every key value, paired rows equal its count on the left multiplied by its count on the right; the total is the sum over all keys.
A products table has 10,000 rows and 10,000 distinct product_id values. A sales table has 50,000 rows and 10,000 distinct product_id values. Each sale is paired with its product on product_id. How many rows does the pairing produce?
The duplicate detector is the cardinality check turned into a list. GROUP BY on the key makes 1 row per value, COUNT(*) says how many rows share that value, and the condition COUNT(*) > 1 belongs in HAVING, because WHERE runs before the groups exist and has no count to compare. Sorting by the count puts the worst offenders at the top.
> A customers table should hold 1 row per customer_id, and a reload may have duplicated some of them. List each customer_id that appears more than once with how many times it appears, most repeated first.
Counting without remembering
| user_id | hash, first 8 bits | leading zeros |
|---|---|---|
| u1 | 10110010 | 0 |
| u2 | 00101101 | 2 |
| u3 | 01110100 | 1 |
| u2 | 00101101 | 2 |
| u4 | 00011011 | 3 |
Hashes shortened to 8 bits for the page. The longest run is 3, and the repeat of u2 changes nothing.
- Use APPROX_DISTINCT for any count you would round anyway, such as a dashboard trend line
- Keep the error argument constant across the queries you compare
- Report approximate figures as approximate: 48.9M, not 48,912,377
- Reach for it on billing counts, or on any figure an audit or legal filing depends on
- Compare an approximate count against an exact one and read the gap as a change
- Expect a tighter error to come free: more buckets mean a bigger sketch per group
A daily dashboard reports monthly active users from about 2 billion events a day, and the figure moves by a few percent from day to day. Which count fits?
One query can carry both kinds of count side by side. COUNT(*) per group counts events and keeps only a running total; APPROX_DISTINCT(user_id, e) in the same SELECT list counts the people behind those events, with e written as a fraction, so 0.01 asks for a 1% standard error. Dividing the first by the second gives events per user.
SELECTcustomer_id,COUNT(*) AS order_count,SUM(amount) AS total_spentFROM ordersGROUP BY customer_idORDER BY total_spent DESC
4 ways to name the sort key
Position numbers refer to the SELECT list of the same query, so adding a column before total_spent silently changes what ORDER BY 3 sorts by.
Top N and ties
HAVING cannot see the alias
Which customer_id is on the first row of this result?
> A sales table records the region and revenue of each sale. Report the total revenue of the 3 highest-earning regions among those over 10,000, highest first, with region breaking ties.
| rank | latency_ms | share of rows at or below |
|---|---|---|
| 1 | 95 | 14% |
| 2 | 120 | 29% |
| 3 | 150 | 43% |
| 4 | 160 | 57% |
| 5 | 180 | 71% |
| 6 | 210 | 86% |
| 7 | 2400 | 100% |
What the accuracy argument promises
- 1 outlier moves it for everyone
- Says nothing about how many users were slow
- Cheap: 1 sum and 1 count per group
- p50 is a typical experience, p99 the worst 1%
- Tails stay visible however small they are
- Exact needs a full sort, approximate needs a fixed sketch
A p99 latency over 10 million requests uses APPROX_PERCENTILE(latency_ms, 0.99) with the default accuracy. What does the result guarantee?
Several percentiles can come out of one pass over the rows. Each APPROX_PERCENTILE call takes the rank it reads as a fraction, 0.5 for the median and 0.95 for p95, and an optional accuracy as its third argument. Dashboards usually set p50 and p95 next to each other, because the gap between them shows how long the tail is.
> Spotify's artist dashboard for Wrapped shows every artist their distinct listeners for the year, their rank among all artists, and how long a typical stream of their tracks lasted. The team builds it from a year of stream events, each with a user_id, an artist_id, a track_id and a seconds_played, combined with an artist dimension table.
Predict when rows collapse, count the uncountable, and rank groups by what they add up to.
AVG lied to you. Meet the percentiles.
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 33 minutes
- Challenges
- 21 hands-on challenges
Topics covered: Row-level vs aggregate behavior, Understanding cardinality, APPROX_DISTINCT (HyperLogLog), ORDER BY with aggregates, APPROX_PERCENTILE at Scale
Lesson Sections
- Row-level vs aggregate behavior (concepts: Counting)
Where the collapse happens GROUP BY sets how many rows survive: 1 per distinct combination of the grouped columns, which is why 6 orders from 3 customers group into 3 rows. With no GROUP BY, a single aggregate in the SELECT list collapses the whole table into exactly 1 row, even an empty table, where COUNT(*) returns 0 on its 1 row. If a query has neither a GROUP BY nor an aggregate, no rows collapse and the row count is whatever WHERE let through. The 3 tabs below run on the same 6 orders. Only
- Understanding cardinality
A column's cardinality is how many distinct values it holds against how many rows the table has. In the 6-row orders table, order_id has 6 distinct values, so every row has one of its own: it's a key. customer_id has 3 distinct values across the same 6 rows, so each customer appears 2 times on average, and C1 appears 3 times. The row count and the distinct count are the whole measurement, and aggregates are how you take it. Read the result as ratios. row_count divided by distinct_orders is 1, so
- APPROX_DISTINCT (HyperLogLog) (concepts: Approximate Aggregates)
COUNT(DISTINCT user_id) over a year of streams has to remember every user id it has seen, so that the next occurrence of the same id adds nothing. The structure that remembers is a set of the ids themselves, and its size grows with the number of distinct ids: 500 million ids at 8 bytes each is 4 GB before any overhead, held in memory for the duration of the query. Group by artist and there's a set per artist. Spread the work over many machines and every set has to be shipped to one place and mer
- ORDER BY with aggregates (concepts: Sorting Results)
The engine builds groups before it sorts them. Execution starts with FROM and WHERE. GROUP BY and HAVING come next, then SELECT, and ORDER BY runs after SELECT with LIMIT last. By the time ORDER BY runs, each group is 1 row carrying its aggregates and the aliases the SELECT list gave them, so an aggregate is a sort key like any other column. The animation groups 6 orders by customer and computes a count and a total for each group before sorting the 3 groups by total. Predict the order before you
- APPROX_PERCENTILE at Scale (concepts: Approximate Aggregates)
An average answers the question of what a typical request costs in total, and it answers it badly for anything with a long tail. Take 7 searches whose times climb from 95 ms through 120, 150, 160 and 180 to 210 before jumping to 2,400 ms. Their average is about 474 ms, a figure no search actually took, because 6 of the 7 finished under 211 ms and 1 took 2.4 seconds and dragged the mean up. A percentile answers a different question: p50 is the value that half the rows are at or below, p95 the val