SQL Aggregate Functions: COUNT, SUM, AVG, MIN and MAX
SQL aggregate functions turn many rows into one value: COUNT, SUM, AVG, MIN and MAX. How each treats NULL, COUNT DISTINCT, and what an empty table returns.
- Course: SQL study plan
- Module: Aggregation
- Kind: Lesson
- Reading time: 13 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What are aggregate functions in SQL?
Aggregate functions compute one value from many rows. The five standard ones are COUNT (how many), SUM (the total), AVG (the mean), MIN and MAX (the smallest and largest). Without GROUP BY they summarise the whole table into a single row. Every one of them except COUNT(*) ignores NULL values, which changes averages and counts in ways worth knowing.
Lesson
Every query so far has returned one output row per input row, possibly filtered and sorted. Many questions are not like that. How many orders came in? What was the revenue? What is the average rating, the cheapest product, the most recent sign-up? Each answer is a single value computed from many rows. That is what aggregate functions do, and they are the foundation of every report, dashboard and analytics question — and of a large share of SQL interview questions. This lesson covers the five standard aggregates on a whole table; the next adds GROUP BY to compute them per group.
The five aggregates
SELECT COUNT(*) AS orders,
SUM(amount) AS revenue,
AVG(amount) AS average_order,
MIN(amount) AS smallest,
MAX(amount) AS largest
FROM orders;
| orders | revenue | average_order | smallest | largest |
|---|---|---|---|---|
| 5 | 750 | 150 | 40 | 400 |
Five rows went in, one row came out. That is the defining property: an aggregate collapses its input rows into one value, so a query whose SELECT list holds only aggregates (and no GROUP BY) returns exactly one row — even when the table is empty.
COUNTcounts.SUMadds numbers.AVGisSUMdivided by the number of values it summed.MINandMAXfind the smallest and largest. They also work on text (alphabetically first and last) and dates (earliest and latest):MAX(order_date)is the most recent order.
Aggregates and WHERE
WHERE runs before aggregation: it decides which rows the aggregates see.
SELECT COUNT(*) AS march_orders, SUM(amount) AS march_revenue
FROM orders
WHERE order_date >= '2024-03-01' AND order_date < '2024-04-01';
The reverse is not allowed: an aggregate cannot appear in WHERE, because at that stage the rows have not been aggregated yet. WHERE SUM(amount) > 1000 is an error; the clause for conditions on aggregates is HAVING, two lessons on.
The three COUNTs
COUNT comes in three forms, and interviewers love the difference:
SELECT COUNT(*) AS all_rows,
COUNT(coupon_code) AS with_coupon,
COUNT(DISTINCT customer_id) AS customers
FROM orders;
COUNT(*)counts rows. NULLs do not matter; a row is a row.COUNT(expr)counts rows whereexpris not NULL.COUNT(DISTINCT expr)counts the different non-NULL values.
COUNT(1) is identical to COUNT(*) — the 1 is never NULL — and there is no performance difference in modern MySQL. A favourite trick question: "what does COUNT(NULL) return?" Always 0, because NULL is never counted.
NULLs are skipped
Every aggregate except COUNT(*) ignores NULL. That is usually what you want, and occasionally a trap.
Take five reviews where two have no rating:
| id | rating |
|---|---|
| 1 | 4 |
| 2 | NULL |
| 3 | 5 |
| 4 | NULL |
| 5 | 3 |
SELECT COUNT(*) AS reviews, -- 5
COUNT(rating) AS rated, -- 3
SUM(rating) AS total, -- 12
AVG(rating) AS avg_rated, -- 4 (12 / 3)
SUM(rating) / COUNT(*) AS avg_all, -- 2.4 (12 / 5)
AVG(COALESCE(rating, 0)) AS avg_missing_as_0 -- 2.4
FROM reviews;
AVG(rating) is the average of the reviews that have a rating. Whether that or 2.4 is right depends on what a missing rating means — "not given" (skip it) or "zero stars" (count it). The query has to say which; COALESCE inside the aggregate is how you say the second.
Empty input
When no rows reach the aggregates — an empty table or a WHERE that matches nothing — the query still returns one row:
SELECT COUNT(*) AS n, SUM(amount) AS total, AVG(amount) AS avg_amount, MAX(amount) AS biggest
FROM orders
WHERE amount > 1000000;
| n | total | avg_amount | biggest |
|---|---|---|---|
| 0 | NULL | NULL | NULL |
COUNT is 0, but SUM, AVG, MIN and MAX are NULL, not 0 — there is no total of nothing. A revenue report that prints NULL for a quiet day is usually a bug in the report; COALESCE(SUM(amount), 0) fixes it. The same NULL appears when every value in the column is NULL.
AVG and integer columns
MySQL's AVG of an integer column returns a decimal: the average of 1, 2 and 2 is 1.6667. PostgreSQL also returns a decimal for AVG. SQL Server, however, returns an integer average for an integer column — AVG of 1, 2, 2 is 1 there — which is why SQL Server queries often read AVG(score * 1.0) or AVG(CAST(score AS DECIMAL(10, 2))). Round averages for display: ROUND(AVG(score), 2).
AVG(DISTINCT col) and SUM(DISTINCT col) also exist and use each different value once. They are rarely what a question means — two orders of 100 are two orders, not one.
Aggregating expressions
The argument of an aggregate can be any expression, evaluated per row before aggregating:
SELECT SUM(price * qty) AS revenue,
MAX(DATEDIFF(shipped_on, ordered_on)) AS slowest_days,
COUNT(DISTINCT DATE(created_at)) AS active_days,
MIN(CHAR_LENGTH(name)) AS shortest_name
FROM order_lines;
And an aggregate can sit inside an expression, evaluated after aggregating: ROUND(100 * SUM(refunded) / COUNT(*), 1) is a refund percentage, and MAX(price) - MIN(price) is the price range.
Mixing aggregates with plain columns
A query that aggregates the whole table returns one row, so what should a plain column next to an aggregate show?
SELECT name, MAX(amount) FROM orders; -- which order's name?
There are five names and one row. Standard SQL rejects this query, and so does MySQL in its default mode (ONLY_FULL_GROUP_BY, the subject of this module's last lesson). Older MySQL versions accepted it and returned the name of an arbitrary row — not necessarily the one with the largest amount. Finding "the order with the largest amount" is a different question, answered with ORDER BY amount DESC LIMIT 1 or, in later modules, a subquery or window function.
Pitfalls
COUNT(col)skipping NULLs when you meant to count rows.AVGsilently ignoring NULLs that should have been zeros.SUMof no rows is NULL, not 0.- An aggregate in
WHERE— useHAVING. - A plain column next to aggregates without
GROUP BY. - SQL Server's integer
AVGon integer columns.
Key takeaways
COUNT,SUM,AVG,MINandMAXcollapse many rows into one value.- Only
COUNT(*)counts NULLs; every other aggregate skips them. COUNT(DISTINCT x)counts different values.- With no input rows,
COUNTis 0 and the others are NULL. WHEREfilters rows before aggregation; it cannot test an aggregate.
Common questions
Do SQL aggregate functions ignore NULL values?
Yes, all of them except COUNT(*). COUNT(col), SUM, AVG, MIN and MAX skip rows where the column is NULL. That means AVG(rating) is the average of the rows that have a rating, not of all rows; use AVG(COALESCE(rating, 0)) if missing values should count as zero.
What does SUM return when there are no rows?
SUM, AVG, MIN and MAX return NULL when no rows match, or when every value is NULL. COUNT returns 0. Wrap the total in COALESCE(SUM(amount), 0) when a report should show 0 instead of an empty cell.
What is the difference between COUNT(*) and COUNT(column)?
COUNT(*) counts rows, whatever they contain. COUNT(column) counts the rows where that column is not NULL. On a table with 10 orders of which 3 have no coupon, COUNT(*) is 10 and COUNT(coupon_code) is 7. COUNT(1) is the same as COUNT(*).
How do I count distinct values in SQL?
Use COUNT(DISTINCT column), which counts the different non-NULL values. COUNT(DISTINCT customer_id) on an orders table gives the number of customers who ordered, however many orders each placed. MySQL also accepts several columns, as in COUNT(DISTINCT city, country).
Can I use an aggregate function in a WHERE clause?
No. WHERE filters individual rows before any aggregation happens, so WHERE SUM(amount) > 100 is an error. Filter on an aggregate with HAVING, which runs after grouping, or compute the aggregate in a subquery and compare against it.
Exercises
An order summary
Summarise the whole orders table in one row.
| id | customer_id | amount | coupon |
|---|---|---|---|
| 1 | 10 | 120.00 | SAVE10 |
| 2 | 11 | 80.00 | NULL |
| 3 | 10 | 45.00 | NULL |
| 4 | 12 | 300.00 | WELCOME |
| 5 | 11 | 55.00 | SAVE10 |
Return orders (the number of orders), customers (how many different customers ordered), with_coupon (orders that used a coupon), revenue (the total amount), avg_order (the average amount, rounded to 2 decimals) and largest (the biggest amount). The query always returns exactly one row — also for an empty table.
orders | customers | with_coupon | revenue | avg_order | largest
5 | 3 | 3 | 600 | 120 | 300Ratings with gaps
Some reviews have no rating. Summarise the reviews of the product lamp only.
| id | product | rating |
|---|---|---|
| 1 | lamp | 4 |
| 2 | lamp | NULL |
| 3 | desk | 2 |
| 4 | lamp | 5 |
| 5 | lamp | NULL |
| 6 | lamp | 3 |
| 7 | desk | 5 |
Return one row: reviews (all lamp reviews), rated (those with a rating), avg_rated (the average of the given ratings), avg_all (the average counting a missing rating as 0) — both averages rounded to 2 decimals — and lowest (the lowest rating given).
reviews | rated | avg_rated | avg_all | lowest
5 | 3 | 4 | 2.4 | 3In this module: Aggregation
- Aggregate functions — COUNT, SUM, AVG, MIN and MAX (this lesson)
- GROUP BY — one summary row per group
- HAVING — filtering groups
- Conditional aggregation — SUM(CASE …) and pivots
- GROUP_CONCAT — a group's values as one string
- Grouping pitfalls — ONLY_FULL_GROUP_BY, COUNT and averages
- Checkpoint — Aggregation
← Checkpoint — Functions · GROUP BY — one summary row per group →