Aggregation

SQL Aggregate Functions: COUNT, SUM, AVG, MIN and MAX

7 min readUpdated September 10, 2026Every example verified
On this page 10 sections ▾
  1. All five at once
  2. COUNT(*) and COUNT(column) are not the same thing
  3. The AVG trap, made visible
  4. Aggregates with GROUP BY: one value per group
  5. DISTINCT inside an aggregate
  6. Why WHERE cannot filter an aggregate
  7. Conditional aggregation: counting several things at once
  8. Practice this topic
  9. Frequently asked questions
  10. 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;
ordersrevenueaverage_ordersmallestlargest
1578651.315243.420667105015588

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.

What each function does
FunctionReturnsWorks on
COUNT(*)how many rowsanything
COUNT(column)how many rows have a value in that columnanything
SUM(column)the totalnumbers
AVG(column)the meannumbers
MIN(column)the smallest valuenumbers, text and dates
MAX(column)the largest valuenumbers, 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.

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_employeeshave_a_manager
127

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 harmless. 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) / COUNT(*)      AS avg_over_every_row,
       COUNT(manager_id)               AS values_present,
       COUNT(*)                        AS rows_total
FROM Employees;
avg_of_present_valuesavg_over_every_rowvalues_presentrows_total
5.2857143.083333712

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.

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_nameheadcountavg_salarytop_salary
Engineering599200142000
Sales4100000131000
Finance2124000156000
Marketing16800068000

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. Most databases raise an error; a few pick a value arbitrarily, which is worse.

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;
employeesdepartments_with_people
124

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, COUNT(*) AS headcount
FROM Employees
WHERE COUNT(*) > 2
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 count to compare against. Filtering on an aggregate is what HAVING is for:

SELECT dept_id, COUNT(*) AS headcount
FROM Employees
GROUP BY dept_id
HAVING COUNT(*) > 2
ORDER BY headcount DESC;
dept_idheadcount
105
204

2 rows · produced by running this query on the sample database

The order the clauses actually run in
StepClauseCan it see an aggregate?
1FROM / JOINno
2WHEREno - rows are not grouped yet
3GROUP BYthis is where groups are formed
4HAVINGyes
5SELECTyes
6ORDER BYyes, 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 always cheaper.

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;
shippedpendingcancelledshipped_revenue
103266058.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 two sections ago 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:

See all 42 exercises →

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.

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.