#Aggregate functions
AddedEvery query you've seen so far returns one output row per input row (or per joined row). An aggregate function does the opposite: it reads a whole set of rows and returns one value. COUNT counts them, SUM adds them up, AVG averages them, MIN and MAX find the extremes. Reports, dashboards, "how many", "how much", "what's the latest" — all of it is aggregation.
A good mental model is a funnel. Rows pour in at the top, the aggregate keeps a tiny running state (a counter, a running total, the smallest value seen so far), and when the last row has gone through, one number comes out at the bottom. Without GROUP BY there is exactly one funnel for the whole table, so you always get exactly one row back — even if no rows went in.
Two rules explain almost every surprise: aggregates skip NULL (only COUNT(*) counts rows regardless of their contents), and over an empty set, COUNT returns 0 but everything else returns NULL.
SELECT COUNT(*) AS employees,
SUM(salary) AS payroll,
AVG(salary) AS avg_salary,
MIN(salary) AS lowest,
MAX(salary) AS highest,
MIN(hired_on) AS first_hire,
MAX(hired_on) AS latest_hire
FROM employees;+-----------+------------+---------------+----------+-----------+------------+-------------+
| employees | payroll | avg_salary | lowest | highest | first_hire | latest_hire |
+-----------+------------+---------------+----------+-----------+------------+-------------+
| 10 | 1162000.00 | 116200.000000 | 72000.00 | 185000.00 | 2018-03-01 | 2025-06-16 |
+-----------+------------+---------------+----------+-----------+------------+-------------+
1 row in set (0.000 sec)- There's no
WHEREand noGROUP BY, so all 10 rows ofemployeesgo into one funnel and the query returns exactly one row. COUNT(*)counts rows:10.SUM(salary)adds the ten salaries:1162000.00— the result keeps the column'sDECIMAL(10,2)scale, so money stays exact.AVG(salary)isSUM / COUNT=1162000 / 10=116200. It prints as116200.000000: for exact (DECIMAL/integer) input MySQL returns aDECIMALwith extra scale (the column's 2 decimals plusdiv_precision_increment, which is 4 by default).MINandMAXwork on anything that can be ordered, not just numbers: onhired_onthey return the earliest (2018-03-01, Asha) and the latest (2025-06-16, Jonas) dates. On strings they use the column's collation.- Notice what's missing: the answer has no employee names. An aggregate returns a value about the set, not "the row with the highest salary". Getting the row behind
MAXis a different problem (see Top N per group).
What happens when the funnel is empty? Research (department 4) has no employees:
SELECT COUNT(*), SUM(salary), AVG(salary), MAX(salary)
FROM employees
WHERE department_id = 4;+----------+-------------+-------------+-------------+
| COUNT(*) | SUM(salary) | AVG(salary) | MAX(salary) |
+----------+-------------+-------------+-------------+
| 0 | NULL | NULL | NULL |
+----------+-------------+-------------+-------------+
1 row in set (0.000 sec)WHERE department_id = 4matches no rows, so nothing reaches the aggregates.- You still get one row. An aggregate query without
GROUP BYalways returns one row; it's the "summary of nothing". COUNT(*)is0— counting nothing gives zero.SUM,AVGandMAXareNULL, not0. The SQL standard reasons that the sum of no values is unknown, not zero. Your application code (or report) then shows a blank or crashes onnull.- The fix when you want a zero is
COALESCE(SUM(salary), 0). Do that only when zero is really the right answer: an average of no salaries is not zero.
Aggregates skip NULL inputs, which changes averages. Here's a tiny inline table (built with VALUES, available since MySQL 8.0.19) of review scores where one reviewer didn't give a score:
SELECT COUNT(*) AS reviews,
COUNT(score) AS scored,
SUM(score) AS total,
AVG(score) AS avg_skipping_null,
SUM(score) / COUNT(*) AS avg_as_zero,
AVG(COALESCE(score, 0)) AS avg_coalesced
FROM (VALUES ROW(4), ROW(5), ROW(NULL), ROW(3)) AS r(score);+---------+--------+-------+-------------------+-------------+---------------+
| reviews | scored | total | avg_skipping_null | avg_as_zero | avg_coalesced |
+---------+--------+-------+-------------------+-------------+---------------+
| 4 | 3 | 12 | 4.0000 | 3.0000 | 3.0000 |
+---------+--------+-------+-------------------+-------------+---------------+
1 row in set (0.000 sec)- The derived table has 4 rows; one
scoreisNULL.COUNT(*)counts all 4 rows;COUNT(score)counts only the 3 non-NULL scores. SUM(score)skips theNULLand adds4 + 5 + 3 = 12. It does not becomeNULLjust because one input is — unlike4 + NULL, which isNULL.AVG(score)is12 / 3 = 4.0000: theNULLrow isn't part of the average at all.SUM(score) / COUNT(*)is12 / 4 = 3.0000, andAVG(COALESCE(score, 0))gives the same: those treat "no score" as a zero score.- Which one is right is a business decision, not a SQL one. Just know that
AVGsilently picks "ignore the missing ones".
SELECT SUM(price), COUNT(*), COALESCE(MAX(price), 0)
FROM products
WHERE price > 5000;Show answer
One row: NULL, 0, 0.
- No product costs more than 5000 (the most expensive is the MacBook Air at 1299.00), so the
WHERElets zero rows through. - There's no
GROUP BY, so the query still returns exactly one row. SUM(price)over nothing isNULL;COUNT(*)is0.MAX(price)isNULLtoo, andCOALESCEreplaces it with0.- If you added
GROUP BY category_id, you'd get no rows at all: there would be zero groups, and grouping never invents an empty one.