Aggregation and Grouping
Table of Contents
- From Rows to Answers
- The Aggregate Functions
- COUNT(*) vs COUNT(column)
- GROUP BY: One Result Row Per Group
- The Golden Rule of GROUP BY
- WHERE vs HAVING
- Aggregating Across Joins: Revenue Reports
- Keeping Customers With Zero
- Grouping by Month With date_trunc
- How NULL Behaves in Aggregates
- string_agg and the FILTER Clause
- Counting Correctly With DISTINCT
- Order of Evaluation, Again
- Try It Yourself
1. From Rows to Answers
So far each query returned individual rows: a customer, an order, a line. But business questions are usually about totals: How many orders did we get? What is our revenue? Which category sells best? Nobody wants to scan thousands of rows and add them in their head.
Aggregation collapses many rows into one value (or one value per group). It is the difference between a list of orders and a sales report, and it is done by the database in one query, far faster than pulling every row into your program and looping.
Save this chapter's queries in shopdb/queries/08_aggregation.sql.
Run it with:
docker exec -i shopdb-pg psql -U postgres -d shopdb < queries/08_aggregation.sql
2. The Aggregate Functions
An aggregate function takes a set of values and returns a single value. The five you will use every day:
| Function | Returns |
|---|---|
COUNT(...) | how many rows (or non-null values) |
SUM(x) | total of x |
AVG(x) | average of x |
MIN(x) | smallest x |
MAX(x) | largest x |
Without GROUP BY, the aggregate treats the whole table as one
group:
SELECT count(*) AS products,
sum(price) AS total_price,
round(avg(price), 2) AS avg_price,
min(price) AS cheapest,
max(price) AS priciest
FROM products;
products | total_price | avg_price | cheapest | priciest
----------+-------------+-----------+----------+----------
8 | 197.50 | 24.69 | 5.50 | 79.00
(1 row)
Eight products whose prices add up to 197.50, so the average is
197.5 / 8 = 24.6875, which we rounded to 24.69 for display. Always
round(avg(...), 2) for money: avg on a numeric column returns a long
tail of decimals.
Notice the result is one row, however many products there are. That is what aggregation does.
MIN and MAX also work on text and dates: the alphabetically first
product name, or the earliest ordered_at.
3. COUNT(*) vs COUNT(column)
These look similar and give different answers when there are NULLs.
COUNT(*)counts rows.COUNT(city)counts rows wherecityis not NULL.
SELECT count(*) AS customers,
count(city) AS with_city
FROM customers;
customers | with_city
-----------+-----------
5 | 4
(1 row)
Maly Ros has no city, so count(city) skips her. This is the standard
way to ask "how many rows have this field filled in?"
A third form, COUNT(DISTINCT city), counts the number of different
non-null values. Here that is 3 (Phnom Penh, Siem Reap, Battambang),
even though four customers have a city.
4. GROUP BY: One Result Row Per Group
The real power comes with GROUP BY. It splits the rows into groups
that share the same value, then runs the aggregates once per group.
How many orders are in each status?
SELECT status, count(*) AS orders
FROM orders
GROUP BY status
ORDER BY status;
status | orders
-----------+--------
cancelled | 1
paid | 2
pending | 1
shipped | 1
(4 rows)
Think of it as three steps. PostgreSQL gathers the five orders, sorts
them into buckets by status, then counts each bucket. Four distinct
statuses, four rows, and the counts add up to the original 5.
You can group by several columns, and by expressions. The same idea applied to customers by city:
SELECT city, count(*) AS customers
FROM customers
GROUP BY city
ORDER BY city;
city | customers
------------+-----------
Battambang | 1
Phnom Penh | 2
Siem Reap | 1
| 1
(4 rows)
NULL is treated as its own group, so Maly appears as a row with a
blank city. By default PostgreSQL sorts NULL last in ascending order.
5. The Golden Rule of GROUP BY
Here is the rule that causes most beginner errors:
Every column in
SELECTmust either be in theGROUP BYor be inside an aggregate function.
Why? After grouping, each result row stands for many original rows. If
you ask for name but group by city, which of the several names in
the group should be shown? The database refuses to guess:
SELECT city, name, count(*)
FROM customers
GROUP BY city;
ERROR: column "customers.name" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT city, name, count(*)
^
(The exact wording differs slightly between PostgreSQL versions.) Fix it
by thinking about what you really want: add name to the GROUP BY
(then each group is one customer, which is not a useful summary), or
aggregate it with something like min(name) or string_agg.
A convenient special case: if you group by a table's primary key,
PostgreSQL knows every other column of that table is determined by it,
so GROUP BY c.id lets you select c.name too. Many people still list
both (GROUP BY c.id, c.name), which works everywhere and is always
safe.
6. WHERE vs HAVING
Both filter, but at different moments:
WHEREfilters rows before grouping.HAVINGfilters groups after aggregation.
You cannot use an aggregate in WHERE because the groups do not exist
yet. This fails:
SELECT customer_id, count(*)
FROM orders
WHERE count(*) >= 2 -- wrong
GROUP BY customer_id;
ERROR: aggregate functions are not allowed in WHERE
The right tool is HAVING. Which customers have placed at least two
orders?
SELECT c.name, count(o.id) AS orders
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
HAVING count(o.id) >= 2
ORDER BY c.name;
name | orders
------------+--------
Dara Kim | 2
Sokha Chan | 2
(2 rows)
When both are needed, use each for its own job: WHERE to drop rows you
never want counted (for example cancelled orders), HAVING to keep only
the groups that pass a threshold. Filtering rows with WHERE first is
also faster, since there is less to group.
7. Aggregating Across Joins: Revenue Reports
Now combine chapter 7 with this one. A single order's total is the sum
of its lines, each line being quantity * unit_price:
SELECT order_id,
sum(quantity * unit_price) AS total
FROM order_items
GROUP BY order_id
ORDER BY order_id;
order_id | total
----------+-------
1 | 26.00
2 | 79.00
3 | 60.50
4 | 57.50
5 | 22.00
(5 rows)
Check order 1 by hand: 1 x 15.00 plus 2 x 5.50 is 15.00 + 11.00 = 26.00.
Revenue per customer needs three tables: customers, orders, lines.
SELECT c.name,
sum(oi.quantity * oi.unit_price) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY revenue DESC;
name | revenue
------------+---------
Sokha Chan | 105.00
Dara Kim | 82.50
Linda Vong | 57.50
(3 rows)
Sokha: 26.00 + 79.00 = 105.00. Dara: 60.50 + 22.00 = 82.50 (this includes her cancelled order, a point we fix below). Linda: 57.50.
Notice we can ORDER BY revenue, the alias, because ORDER BY runs
after SELECT. And revenue per category goes through products:
SELECT cat.name AS category,
sum(oi.quantity * oi.unit_price) AS revenue,
sum(oi.quantity) AS units_sold
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.name
ORDER BY revenue DESC;
category | revenue | units_sold
-------------+---------+------------
Electronics | 135.00 | 6
Books | 70.00 | 3
Home | 40.00 | 4
(3 rows)
Note we used oi.unit_price (the price at the time of sale) and not
p.price (today's price). If a price changes next month, old revenue
should not change. That is exactly why order_items stores its own copy,
a point chapter 10 returns to.
Cancelled orders should not count as revenue. That is a WHERE
condition, since it filters rows before grouping:
SELECT c.name,
sum(oi.quantity * oi.unit_price) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status <> 'cancelled'
GROUP BY c.id, c.name
ORDER BY revenue DESC;
Dara's revenue drops from 82.50 to 60.50, because her cancelled order (22.00) is gone.
8. Keeping Customers With Zero
The revenue report above lists only 3 of the 5 customers. Vuthy and Maly
have no orders, so the inner joins removed them. To list everyone,
use LEFT JOIN as you learned last chapter. But then SUM over no rows
returns NULL, not zero, so wrap it in COALESCE:
SELECT c.name,
coalesce(sum(oi.quantity * oi.unit_price), 0) AS revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY revenue DESC, c.name;
name | revenue
------------+---------
Sokha Chan | 105.00
Dara Kim | 82.50
Linda Vong | 57.50
Maly Ros | 0.00
Vuthy Sok | 0.00
(5 rows)
Both joins must be LEFT. If the second were a plain JOIN, rows with
no order would be dropped again at that step.
The extra c.name in ORDER BY breaks the tie between the two zero
rows, so the output order is stable.
9. Grouping by Month With date_trunc
Reports by month are everywhere. date_trunc('month', ts) rounds a
timestamp down to the first moment of its month, giving you one value
per month to group on:
SELECT date_trunc('month', o.ordered_at)::date AS month,
sum(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status <> 'cancelled'
GROUP BY date_trunc('month', o.ordered_at)
ORDER BY month;
month | revenue
------------+---------
2026-01-01 | 105.00
2026-02-01 | 118.00
(2 rows)
Check January: orders 1 and 2, 26.00 + 79.00 = 105.00. February without
the cancelled order 5: order 3 (60.50) plus order 4 (57.50) is 118.00.
The ::date cast just trims the time of day for display. (Including the
cancelled order would make February 140.00.)
The same function takes 'day', 'week', 'year' and others. In
PostgreSQL you may also write GROUP BY 1 to mean "the first column in
the SELECT list", a shortcut that is handy but less readable.
10. How NULL Behaves in Aggregates
Aggregate functions ignore NULL values (except COUNT(*), which
counts rows). This is usually what you want, but it can mislead.
SELECT avg(x) AS average
FROM (VALUES (1), (NULL), (3)) AS t(x);
average
--------------------
2.0000000000000000
(1 row)
The average is 2, not 4/3, because the NULL row was skipped: (1 + 3)
divided by 2 values. If you meant "treat missing as zero", say so
explicitly with avg(coalesce(x, 0)), which would give 1.33.
Two more facts to remember:
SUMof an empty set (no rows, or allNULL) isNULL, not 0. Usecoalesce(sum(...), 0).COUNTof an empty set is 0, neverNULL.
When a column can be null, decide deliberately what the aggregate should do about it.
11. string_agg and the FILTER Clause
Two PostgreSQL features worth knowing early.
string_agg(expr, separator) joins the values of a group into one
text value. Which products are in each category?
SELECT cat.name AS category,
string_agg(p.name, ', ' ORDER BY p.name) AS products
FROM categories cat
JOIN products p ON p.category_id = cat.id
GROUP BY cat.name
ORDER BY cat.name;
category | products
-------------+------------------------------------------------
Books | Clean Code, Notebook Pack, SQL Cookbook
Electronics | Mechanical Keyboard, USB-C Cable, Wireless Mouse
Home | Coffee Mug, Desk Lamp
(3 rows)
The ORDER BY inside the parentheses controls the order within each
joined string. (Spacing in your terminal will differ slightly with the
widest value in the column.)
FILTER (WHERE ...) attaches a condition to a single aggregate, so
you can compute several differently-filtered numbers in one pass. Total
and paid-only revenue per month, side by side:
SELECT date_trunc('month', o.ordered_at)::date AS month,
sum(oi.quantity * oi.unit_price) AS total,
sum(oi.quantity * oi.unit_price)
FILTER (WHERE o.status = 'paid') AS paid_only
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY date_trunc('month', o.ordered_at)
ORDER BY month;
month | total | paid_only
------------+--------+-----------
2026-01-01 | 105.00 | 26.00
2026-02-01 | 140.00 | 57.50
(2 rows)
January: orders 1 (paid, 26.00) and 2 (shipped, 79.00), so total 105.00 and paid 26.00. February: orders 3, 4, 5 total 60.50 + 57.50 + 22.00 = 140.00, of which only order 4 is paid.
Without FILTER you would write SUM(CASE WHEN status = 'paid' THEN ... ELSE 0 END), which does the same thing in a clunkier way.
12. Counting Correctly With DISTINCT
Last chapter warned about row multiplication. Here is how it bites in an aggregate. We want "number of orders per customer", joining down to the lines:
SELECT c.name,
count(o.id) AS wrong,
count(DISTINCT o.id) AS right
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;
name | wrong | right
------------+-------+-------
Sokha Chan | 3 | 2
Dara Kim | 3 | 2
Linda Vong | 3 | 1
(3 rows)
Sokha has 2 orders but 3 lines (order 1 has two, order 2 has one), so
count(o.id) counted each order once per line. count(DISTINCT o.id)
counts each order id once. If you only need orders, do not join to the
lines at all. If you need both orders and line-level numbers, use
DISTINCT or aggregate lines in a subquery first (next chapter).
The same trap applies to SUM: summing an order-level column after
joining to lines counts it once per line. Aggregate at the level the
column belongs to.
13. Order of Evaluation, Again
Chapter 5 gave the logical order of a query. Now it has more steps:
1. FROM / JOIN build the combined rows
2. WHERE drop rows
3. GROUP BY make groups
4. HAVING drop groups
5. SELECT compute output columns (aggregates, aliases)
6. ORDER BY sort
7. LIMIT cut
This explains the rules you met:
WHEREcannot use aggregates: groups do not exist yet at step 2.HAVINGcan use aggregates: it runs after step 3.ORDER BYcan use column aliases, butWHEREandHAVINGcannot, because aliases are created in step 5, after them.
When a query fails with a puzzling error, walk through these steps and
ask which step needs the thing you are using, and whether it exists yet.
Save all your queries from this chapter into
shopdb/queries/08_aggregation.sql.
14. Try It Yourself
Check each result against the seed data by hand where you can.
- Find the average, minimum and maximum price of products in the
Bookscategory. (Join tocategories, or usecategory_id = 2.) Round the average to 2 decimals. - Count the products that are out of stock (
stock = 0) and the total units in stock across all products. Which products are they? - List every customer with the number of orders they placed (including
0 for those with none). Use
LEFT JOINandcount(o.id). Then changecount(o.id)tocount(*)and explain why Vuthy and Maly now show 1. - Show only the customers whose total revenue (excluding cancelled
orders) is above 60.00 using
HAVING. Who qualifies? - For each category, show the number of distinct products and the list
of product names with
string_agg. Add a column with the average price rounded to 2 decimals. - For each order status, show
count(DISTINCT customer_id)and the number of order lines. Which status has the most lines, and why does it not match the number of orders?
Where to go next. You can now build solid reports in one query. In Subqueries, CTEs and Window Functions you will learn to break big questions into named steps, save a report as a view, and rank rows or compute running totals without collapsing them into groups.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!