INNER, LEFT, RIGHT, and FULL joinsPlannedSelf joins and many-to-many dataPlannedSubqueries and correlated subqueriesPlannedUNION, INTERSECT, and EXCEPTPlanned
Designing reliable schemas
CREATE TABLE and schema designPlannedConstraints and data integrityPlannedNormalization and intentional denormalizationPlannedViews and materialized viewsPlanned
Writing and protecting data
INSERT, UPDATE, and DELETEPlannedUpserts and conflict handlingPlannedTransactions and savepointsPlannedIsolation, locks, and concurrencyPlanned
Performance and administration
Indexes and access pathsPlannedEXPLAIN and query plansPlannedQuery tuning patternsPlannedBackups, restores, and migrationsPlanned
Security and production workflow
Users, roles, and least privilegePlannedPreventing SQL injectionPlannedStored procedures, functions, and triggersPlannedProduction data projectPlanned
Reports, aggregation, and analytics · Lesson 09 125 min
Aggregate Functions
An aggregate turns several input rows into one answer. A count answers how many, a sum answers how much, and an average answers how much per known value. The SQL is short; the hard part is saying exactly which rows and values the number represents.
One result row can summarize many input rows
PopulationDecide which rows qualify before calculating a metric.
MeasureChoose the aggregate that matches the question and its unit.
Missing valuesKnow which functions ignore NULL and what an empty input returns.
ReviewCompare the answer with source rows and a manual calculation.
SOURCE8 orders5 paid · 2 pending · 1 refunded
Two pending amounts are NULL; a refunded amount is zero.
FILTERWHERE status = 'paid'5 qualifying rows
Filtering happens before the aggregate reads the values.
RESULT12,000 centsSUM(amount_cents)
That paid-order total is one result value.
COUNT(*) counts rows
COUNT(*) counts every input row, regardless of which columns are NULL. This table has eight orders, so the result is eight. Without GROUP BY, an aggregate query returns one summary row for all qualifying input rows. The next lesson splits that population into groups.
Edit the query, predict the rows it will return, then run it.
COUNT(column) skips NULL values
COUNT(amount_cents) returns six because two pending orders have NULL amounts. The refunded zero still counts: zero is a value. COUNT(DISTINCT customer_id) counts four customers rather than eight order entries.
SQL BROWSER RUNNER
Compare rows, known amounts, and customers
See how the argument to COUNT changes what gets counted.
Edit the query, predict the rows it will return, then run it.
SUM and AVG ignore NULL, but not zero
SUM(amount_cents) totals the six known amounts: 12,000 cents. AVG(amount_cents) divides by six, producing 2,000 cents. It does not divide by all eight rows. The zero refund contributes zero to the sum and one known value to the average denominator.
Edit the query, predict the rows it will return, then run it.
WHERE defines the aggregate population
The paid-only query has five input rows, a 12,000-cent sum, and a 2,400-cent average. Its total matches the all-status total because the other known amount is zero. Its average differs because that zero no longer affects the denominator. Equal totals can still describe different populations.
SQL BROWSER RUNNER
Summarize only paid orders
Filter rows first, then count and average paid amounts.
Edit the query, predict the rows it will return, then run it.
MIN and MAX find extremes among known values
MIN(amount_cents) is zero and MAX(amount_cents) is 4,000 over all orders. Both ignore null amounts. MIN(placed_on) and MAX(placed_on) return the first and last ISO dates. An extreme value alone does not identify the row it came from; selecting unrelated columns beside an aggregate is not a portable way to find its owner.
Edit the query, predict the rows it will return, then run it.
An empty input still produces one summary row
No order has status cancelled. In an ungrouped aggregate query, COUNT(*) returns zero while SUM, AVG, MIN, and MAX return NULL. If a dashboard deliberately displays zero for an empty sum, write COALESCE(SUM(amount_cents), 0) and make that choice visible.
SQL BROWSER RUNNER
Observe aggregates with no matches
Compare COUNT with SUM and AVG for an empty input.
Edit the query, predict the rows it will return, then run it.
DISTINCT changes the values being measured
COUNT(DISTINCT customer_id) answers how many customers appear, not how many orders they made. SUM(DISTINCT amount_cents) is legal, but it discards repeated equal amounts even when they came from separate valid orders. Do not use it to hide duplicate rows caused by a bad join.
SQL BROWSER RUNNER
Compare entries with unique values
Count order entries and unique customers, then inspect a distinct amount sum.
Edit the query, predict the rows it will return, then run it.
Conditional counts answer several questions at once
A CASE expression turns a true condition into one and other rows into zero. Summing those values counts paid and pending orders in one result row: five paid and two pending. GROUP BY is better when a report needs one row per status.
SQL BROWSER RUNNER
Count statuses in one summary
Use SUM and CASE to count paid and pending orders.
Edit the query, predict the rows it will return, then run it.
Common aggregate mistakes
COUNT(amount_cents) = all orders
NULL values omitted
Use COUNT(*) for rows; use COUNT(column) for known values.
AVG = SUM / COUNT(*)
Wrong denominator
AVG(column) divides by non-null values, including zero.
SUM(all statuses) = revenue
Undefined population
Filter to the business event the metric represents before naming the result.
SUM(DISTINCT amount_cents)
Lost legitimate repeats
Equal amounts can represent separate orders. DISTINCT works on values, not identity.
Independent lab: audit a paid-order snapshot
Build one September paid-order result with order count, paying customer count, total cents, average cents, and smallest and largest paid amounts. The starter returns five orders, four customers, 12,000 total cents, 2,400 average cents, 1,000 minimum, and 4,000 maximum. Move the lower date boundary to September 3 and predict every metric before running it.
SQL BROWSER RUNNER
Audit a six-metric paid-order report
Combine a half-open month filter with counts, totals, averages, and extremes.
CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULL,statusTEXTNOTNULL,
amount_cents INTEGER,
placed_on TEXTNOTNULL);INSERTINTO orders (order_id, customer_id,status, amount_cents, placed_on)VALUES(1,10,'paid',1000,'2026-09-01'),(2,11,'paid',2500,'2026-09-02'),(3,10,'pending',NULL,'2026-09-03'),(4,12,'paid',4000,'2026-09-04'),(5,11,'refunded',0,'2026-09-05'),(6,13,'paid',1500,'2026-09-06'),(7,10,'paid',3000,'2026-09-07'),(8,13,'pending',NULL,'2026-09-08');-- September paid-order snapshot: one result row, not one row per order.SELECTCOUNT(*)AS paid_orders,COUNT(DISTINCT customer_id)AS paying_customers,SUM(amount_cents)AS revenue_cents,ROUND(AVG(amount_cents),2)AS average_order_cents,MIN(amount_cents)AS smallest_paid_cents,MAX(amount_cents)AS largest_paid_cents
FROM orders
WHEREstatus='paid'AND placed_on >='2026-09-01'AND placed_on <'2026-10-01';
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Lesson review
COUNT(*) counts rows, while COUNT(column) counts non-null values. SUM and AVG ignore nulls but include zero; MIN and MAX find extremes. An empty population gives zero for count and null for the other common aggregates. A useful metric has a clear population, unit, and treatment of missing values.
I can distinguish orders from known amounts.
I can calculate an average using the non-null denominator.
I can filter a population before naming a metric.
I can explain empty-input aggregate results.
I know when DISTINCT changes a metric.
I can check a summary against source rows.
KNOWLEDGE CHECK
Check aggregate reasoning
Answer all ten questions, then rerun any example whose population or denominator surprised you.