SQL Aggregate Functions: COUNT, SUM, AVG, MIN and MAX
On this page 11 sections ▾
- All five at once
- COUNT(*) and COUNT(column) are not the same thing
- The AVG trap, made visible
- When no rows match: NULL, not zero
- Aggregates with GROUP BY: one value per group
- DISTINCT inside an aggregate
- Why WHERE cannot filter an aggregate
- Conditional aggregation: counting several things at once
- Practice this topic
- Frequently asked questions
- Related reading
An aggregate function takes many rows and returns one value. There are five you will use constantly — COUNT, SUM, AVG, MIN and MAX — and almost every reporting query in existence is some combination of them.
They are easy to start using and easy to get subtly wrong, because two of their behaviours are invisible until they bite: what they do with NULL, and when they are allowed to appear. This page covers both, with every example runnable against the sample database.
All five at once
The whole family, applied to the orders table:
SELECT COUNT(*) AS orders,
SUM(order_total) AS revenue,
AVG(order_total) AS average_order,
MIN(order_total) AS smallest,
MAX(order_total) AS largest
FROM Orders;
| orders | revenue | average_order | smallest | largest |
|---|---|---|---|---|
| 15 | 78651.31 | 5243.420667 | 1050 | 15588 |
● 1 row · produced by running this query on the sample database
One row out, however many rows went in. That is the defining property of an aggregate: it collapses a set of values into a single value.
| Function | Returns | Works on |
|---|---|---|
| COUNT(*) | how many rows | anything |
| COUNT(column) | how many rows have a value in that column | anything |
| SUM(column) | the total | numbers |
| AVG(column) | the mean | numbers |
| MIN(column) | the smallest value | numbers, text and dates |
| MAX(column) | the largest value | numbers, text and dates |
MIN and MAX on text give you alphabetical first and last, and on dates the earliest and latest — useful far more often than people expect:
SELECT MIN(hire_date) AS first_hired,
MAX(hire_date) AS last_hired,
MIN(last_name) AS first_alphabetically,
MAX(last_name) AS last_alphabetically
FROM Employees;
| first_hired | last_hired | first_alphabetically | last_alphabetically |
|---|---|---|---|
| 2016-05-20 | 2023-04-03 | Bennett | Wei |
● 1 row · produced by running this query on the sample database
The dates come out right because they are stored as YYYY-MM-DD text, and in that format alphabetical order is date order. SQLite, which runs the examples here, has no separate date type, so a date stored as 20/05/2016 would be compared as plain text and give the wrong answer.
COUNT(*) and COUNT(column) are not the same thing
This is the single most asked question about aggregates, and the answer is one word: NULL.
SELECT COUNT(*) AS all_employees,
COUNT(manager_id) AS have_a_manager
FROM Employees;
| all_employees | have_a_manager |
|---|---|
| 12 | 7 |
● 1 row · produced by running this query on the sample database
COUNT(*) counts rows. COUNT(manager_id) counts rows where manager_id actually has a value — the NULLs are skipped. The gap between the two numbers is exactly the number of employees with no manager, which you can confirm directly:
SELECT COUNT(*) AS no_manager
FROM Employees
WHERE manager_id IS NULL;
| no_manager |
|---|
| 5 |
● 1 row · produced by running this query on the sample database
Every aggregate except COUNT(*) ignores NULL
SUM, AVG, MIN, MAX and COUNT(column) all skip rows where the column is NULL. For SUM that is usually harmless, as long as at least one value is left to add. For AVG it is the trap: the divisor is the number of non-NULL values, not the number of rows, so a column that is half empty produces an average of the half that was filled in. If a missing value should count as zero, say so with AVG(COALESCE(column, 0)).
The AVG trap, made visible
Two ways of averaging the same column, side by side:
SELECT AVG(manager_id) AS avg_of_present_values,
SUM(manager_id) * 1.0 / COUNT(*) AS avg_over_every_row,
COUNT(manager_id) AS values_present,
COUNT(*) AS rows_total
FROM Employees;
| avg_of_present_values | avg_over_every_row | values_present | rows_total |
|---|---|---|---|
| 5.285714 | 3.083333 | 7 | 12 |
● 1 row · produced by running this query on the sample database
The two averages differ because they divide by different numbers. Neither is wrong — they answer different questions. What is wrong is not knowing which one you asked for. Averaging a manager id is meaningless in itself, of course; the point is that the same arithmetic applies to a column of prices or scores where the difference is money.
The * 1.0 is not decoration. Both SUM(manager_id) and COUNT(*) are whole numbers, and in SQLite, PostgreSQL and SQL Server dividing one integer by another gives an integer: without it, 37 / 12 comes back as 3, with the fraction simply dropped. Multiplying by 1.0, or casting one side to a decimal type, keeps the fractional part. MySQL is the odd one out, because its / keeps the fractional part even between two integers.
When no rows match: NULL, not zero
An aggregate over nothing is a special case, and it catches reports out. The sample orders are only ever Shipped, Pending or Cancelled, so asking about refunded orders leaves no rows to aggregate:
SELECT COUNT(*) AS orders,
SUM(order_total) AS revenue,
COALESCE(SUM(order_total), 0) AS revenue_or_zero
FROM Orders
WHERE status = 'Refunded';
| orders | revenue | revenue_or_zero |
|---|---|---|
| 0 | NULL | 0 |
● 1 row · produced by running this query on the sample database
COUNT says 0, because counting nothing gives zero. SUM says NULL: with no values there is no total, and the same goes for AVG, MIN and MAX. That is standard SQL, and PostgreSQL, MySQL, SQL Server and SQLite all behave this way. It matters when the number is added to something else, because anything plus NULL is NULL, or when it lands in a report column people read as money. When zero is the answer you mean, say so with COALESCE, as the third column does.
Aggregates with GROUP BY: one value per group
An aggregate on its own gives one row for the whole table. Add GROUP BY and you get one row per group:
SELECT d.dept_name,
COUNT(e.emp_id) AS headcount,
ROUND(AVG(e.salary), 0) AS avg_salary,
MAX(e.salary) AS top_salary
FROM Departments d
JOIN Employees e ON e.dept_id = d.dept_id
GROUP BY d.dept_name
ORDER BY headcount DESC;
| dept_name | headcount | avg_salary | top_salary |
|---|---|---|---|
| Engineering | 5 | 99200 | 142000 |
| Sales | 4 | 100000 | 131000 |
| Finance | 2 | 124000 | 156000 |
| Marketing | 1 | 68000 | 68000 |
● 4 rows · produced by running this query on the sample database
The rule that catches everyone: every column in the SELECT list that is not inside an aggregate must appear in the GROUP BY. If a group contains five different first names, the database has no way to choose one to show you, so it refuses. PostgreSQL, SQL Server and MySQL in its default mode raise an error. SQLite, which runs this site’s playground, is one of the few that accept the query and fill the column from an arbitrary row of each group, which is worse because nothing looks wrong. The playground adds a warning under the result when that happens.
DISTINCT inside an aggregate
COUNT(DISTINCT column) counts how many different values there are, not how many rows:
SELECT COUNT(*) AS employees,
COUNT(DISTINCT dept_id) AS departments_with_people
FROM Employees;
| employees | departments_with_people |
|---|---|
| 12 | 4 |
● 1 row · produced by running this query on the sample database
Twelve employees spread across a smaller number of departments. This is how you answer “how many customers ordered this month” when the orders table has one row per order rather than per customer — COUNT(DISTINCT customer_id) rather than COUNT(*). Getting that wrong overstates the number, sometimes by a lot.
SUM(DISTINCT ...) and AVG(DISTINCT ...) exist too, but they are rarely what anyone means. If you find yourself reaching for them, the data probably needs de-duplicating first — see finding and deleting duplicate rows.
Why WHERE cannot filter an aggregate
This query looks reasonable and every database rejects it:
SELECT dept_id, ROUND(AVG(salary), 0) AS avg_salary
FROM Employees
WHERE AVG(salary) >= 100000
GROUP BY dept_id;
The reason is the order in which the clauses run. WHERE is evaluated before the rows have been grouped, so at that moment there is no department average to compare against. Filtering on an aggregate is what HAVING is for:
SELECT dept_id, ROUND(AVG(salary), 0) AS avg_salary
FROM Employees
GROUP BY dept_id
HAVING AVG(salary) >= 100000
ORDER BY avg_salary DESC;
| dept_id | avg_salary |
|---|---|
| 50 | 124000 |
| 20 | 100000 |
● 2 rows · produced by running this query on the sample database
| Step | Clause | Can it see an aggregate? |
|---|---|---|
| 1 | FROM / JOIN | no |
| 2 | WHERE | no - rows are not grouped yet |
| 3 | GROUP BY | this is where groups are formed |
| 4 | HAVING | yes |
| 5 | SELECT | yes |
| 6 | ORDER BY | yes, including by alias |
Learn that sequence once and a whole category of errors stops being mysterious. Use WHERE to choose which rows go into the groups and HAVING to choose which groups survive — and put the row-level condition in WHERE even when both would work, because filtering earlier is usually cheaper. The GROUP BY vs HAVING guide takes this further, with both filters in one query and what each database allows in HAVING.
Conditional aggregation: counting several things at once
Put a CASE expression inside an aggregate and you can count or total different subsets in a single pass:
SELECT COUNT(CASE WHEN status = 'Shipped' THEN 1 END) AS shipped,
COUNT(CASE WHEN status = 'Pending' THEN 1 END) AS pending,
COUNT(CASE WHEN status = 'Cancelled' THEN 1 END) AS cancelled,
SUM(CASE WHEN status = 'Shipped' THEN order_total ELSE 0 END) AS shipped_revenue
FROM Orders;
| shipped | pending | cancelled | shipped_revenue |
|---|---|---|---|
| 10 | 3 | 2 | 66058.89 |
● 1 row · produced by running this query on the sample database
This works because a CASE with no ELSE produces NULL for the rows that do not match, and COUNT skips NULLs — the same behaviour that was a trap earlier on this page is the mechanism here. GROUP BY would give you the same numbers stacked vertically; this puts them side by side, which is the shape a dashboard wants.
Practice this topic
Reading explains the idea; producing it yourself is what makes it stick. These exercises use exactly what this page covers, and your answer is checked by running it against the same sample database:
- How many customers do we have?Easy
- Average salary, roundedEasy
- Cheapest and dearestEasy
- Headcount per departmentMedium
- Only the multi-person departmentsMedium
- One-row status reportMedium
Frequently asked questions
What are the five aggregate functions in SQL?
COUNT, SUM, AVG, MIN and MAX. COUNT returns how many, SUM totals a numeric column, AVG returns the mean, and MIN and MAX return the smallest and largest values - which works on text and dates as well as numbers.
What is the difference between COUNT(*) and COUNT(column)?
COUNT(*) counts rows. COUNT(column) counts only the rows where that column is not NULL. The difference between the two numbers is exactly how many rows have no value in that column, which makes the pair a quick way to measure missing data.
Do aggregate functions ignore NULL values?
All of them except COUNT(*). SUM, AVG, MIN, MAX and COUNT(column) skip NULLs entirely. This matters most for AVG, which divides by the number of non-NULL values rather than the number of rows, so a partly empty column gives the average of the values that are present.
Why does WHERE COUNT(*) > 2 give an error?
Because WHERE runs before the rows are grouped, so no count exists yet when it is evaluated. Use HAVING COUNT(*) > 2 after the GROUP BY instead. WHERE filters rows going in; HAVING filters groups coming out.
How do I count distinct values in SQL?
Put DISTINCT inside the aggregate: COUNT(DISTINCT customer_id) returns how many different customers appear, rather than how many rows. It is the right choice whenever a table has one row per event and you want to count the things behind the events.
Can I use two aggregate functions in the same query?
Yes, as many as you like - they are just expressions in the SELECT list. You can also nest a CASE inside each one to aggregate different subsets in a single pass over the table, which is how a one-row status report is built.
Related reading
About the author
SQL Practice
SQL Practice publishes free SQL tutorials, an in-browser playground and 42 practice exercises, built so you can run what you read. Every runnable example in our tutorials is executed against the site’s sample database before it is published. Read how we keep tutorials accurate, or report a mistake.