JOIN combines rows of two tables by a condition: INNER JOIN keeps only the pairs that found each other, while LEFT JOIN keeps every row of the left table and fills in NULL when there is no match. Aggregate functions (count, sum, avg, min, max) fold many rows into one value and skip NULL, except count(*). GROUP BY computes aggregates separately for each group; WHERE filters rows before grouping, and HAVING filters the finished groups after it.
After this you will be able to
- Join two or more tables with
INNER JOINandLEFT JOINand find rows without a match, such as clients with no orders - Compute totals with
count,sum,avg,min,maxand explain how they treatNULL - Group rows with
GROUP BY, filter groups withHAVINGand explain howHAVINGdiffers fromWHERE - Split a complex report into steps with
WITHand spot duplicate rows after aJOIN
Know beforehand
Verified with
PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29
Checked against docs: October 11, 2026
Contents
Why JOIN: the data lives in different tables
In "SQL from scratch" we worked with one table at a time. But in the training store database an order keeps only the client number client_id, while the client's name lives in clients. That is by design: client data is written once, and orders reference it. To see an order together with the name, the tables are combined with a JOIN query.
If the training database container has already been stopped, start it again with the same shop.sql script:
# shop.sql is the script from the "SQL from scratch" lesson
docker run -d --rm --name shop-db -e POSTGRES_PASSWORD=test postgres:17-alpine
docker exec -i shop-db psql -U postgres < shop.sql
docker exec -it shop-db psql -U postgres -P null=NULLSELECT o.id, o.ordered_on, o.status, c.name
FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
ORDER BY o.id;1
- ordered_on
- 2026-09-02
- status
- shipped
- name
- Aigerim Seitova
2
- ordered_on
- 2026-09-05
- status
- shipped
- name
- Daniyar Akhmetov
3
- ordered_on
- 2026-09-14
- status
- paid
- name
- Aigerim Seitova
4
- ordered_on
- 2026-09-20
- status
- cancelled
- name
- Madina Omarova
5
- ordered_on
- 2026-09-28
- status
- shipped
- name
- Timur Kim
How to read it: for each row of orders the database looks for rows of clients for which the condition after ON is true, and glues them into one result row. AS o and AS c are short table aliases. The PostgreSQL documentation considers it good style to qualify every column in a join query: if both tables end up with a column of the same name, such as id or name, an unqualified query breaks.
A query can contain several joins. An order line references both an order and a product, so a receipt needs all four tables:
SELECT o.id AS order_id, c.name AS client, p.name AS product,
oi.quantity, oi.price, oi.quantity * oi.price AS line_total
FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
JOIN order_items AS oi ON oi.order_id = o.id
JOIN products AS p ON p.id = oi.product_id
WHERE o.id IN (1, 5)
ORDER BY o.id, p.name;1
- client
- Aigerim Seitova
- product
- Ceramic Mug 350 ml
- quantity
- 2
- price
- 2500.00
- line_total
- 5000.00
1
- client
- Aigerim Seitova
- product
- Espresso Blend 1 kg
- quantity
- 1
- price
- 12900.00
- line_total
- 12900.00
5
- client
- Timur Kim
- product
- Ceramic Mug 350 ml
- quantity
- 1
- price
- 2500.00
- line_total
- 2500.00
5
- client
- Timur Kim
- product
- Ethiopia Yirgacheffe 250 g
- quantity
- 1
- price
- 5600.00
- line_total
- 5600.00
5
- client
- Timur Kim
- product
- Masala Chai 200 g
- quantity
- 2
- price
- 3800.00
- line_total
- 7600.00
INNER JOIN and LEFT JOIN, side by side
A bare JOIN means INNER JOIN. Only pairs that found each other make it into the result. Clients with no orders at all silently disappear from such a query:
SELECT c.id, c.name, o.id AS order_id, o.status
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
ORDER BY c.id, o.id;SELECT c.id, c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
ORDER BY c.id, o.id;1
- name
- Aigerim Seitova
- order_id
- 1
- status
- shipped
1
- name
- Aigerim Seitova
- order_id
- 3
- status
- paid
1
- name
- Aigerim Seitova
- order_id
- 9
- status
- paid
2
- name
- Daniyar Akhmetov
- order_id
- 2
- status
- shipped
2
- name
- Daniyar Akhmetov
- order_id
- 10
- status
- new
3
- name
- Madina Omarova
- order_id
- 4
- status
- cancelled
3
- name
- Madina Omarova
- order_id
- 7
- status
- shipped
4
- name
- Timur Kim
- order_id
- 5
- status
- shipped
5
- name
- Aliya Bekova
- order_id
- 6
- status
- paid
6
- name
- Ruslan Ivanov
- order_id
- 8
- status
- new
7
- name
- Zarina Kaliyeva
- order_id
- NULL
- status
- NULL
8
- name
- Sergey Pak
- order_id
- NULL
- status
- NULL
INNER JOIN
- Only rows that found a match by the
ONcondition - Rows without a match in either table are dropped
- Report: "orders with client names"
LEFT JOIN
- Every row of the left table at least once
- With no match, the right table's columns are
NULL - Report: "all clients and their orders, if any"; finding rows without a match
The documentation describes LEFT JOIN exactly this way: an inner join is performed first, then for each left-table row without a match a row with NULL in the right-table columns is added. Hence the classic technique for finding rows without a match: keep only the rows where the right table's key is NULL.
SELECT c.id, c.name, c.created_on
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
WHERE o.id IS NULL
ORDER BY c.id;7
- name
- Zarina Kaliyeva
- created_on
- 2026-09-10
8
- name
- Sergey Pak
- created_on
- 2026-09-30
SELECT p.id, p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.product_id IS NULL;9
- name
- Gift Box
There are also RIGHT JOIN (like LEFT, but keeps the right table) and FULL JOIN (keeps rows of both). In practice RIGHT JOIN is usually rewritten as LEFT JOIN with the tables swapped — the query is easier to read.
-- Intent: every client plus their paid orders
SELECT c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id, o.id;Aigerim Seitova
- order_id
- 3
- status
- paid
Aigerim Seitova
- order_id
- 9
- status
- paid
Aliya Bekova
- order_id
- 6
- status
- paid
SELECT c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id AND o.status = 'paid'
ORDER BY c.id, o.id;Aigerim Seitova
- order_id
- 3
- status
- paid
Aigerim Seitova
- order_id
- 9
- status
- paid
Daniyar Akhmetov
- order_id
- NULL
- status
- NULL
Madina Omarova
- order_id
- NULL
- status
- NULL
Timur Kim
- order_id
- NULL
- status
- NULL
Aliya Bekova
- order_id
- 6
- status
- paid
Ruslan Ivanov
- order_id
- NULL
- status
- NULL
Zarina Kaliyeva
- order_id
- NULL
- status
- NULL
Sergey Pak
- order_id
- NULL
- status
- NULL
Aggregates: COUNT, SUM, AVG, MIN, MAX and NULL
An aggregate function computes a single value from many rows. Without GROUP BY the whole result is one group, and the query returns exactly one row.
SELECT count(*) AS products,
count(weight_g) AS with_weight,
min(price) AS cheapest,
max(price) AS most_expensive,
round(avg(price), 2) AS avg_price,
avg(weight_g) AS avg_weight
FROM products;9
- with_weight
- 5
- cheapest
- 2500.00
- most_expensive
- 18500.00
- avg_price
- 8366.67
- avg_weight
- 360.0000000000000000
This shows the main rule: aggregates skip NULL. count(*) counts rows, while count(weight_g) counts only rows where the weight is set. avg(weight_g) is the average over the five products with a known weight: (1000 + 250 + 250 + 100 + 200) / 5 = 360, not the sum divided by 9. That is almost always what you want, but keep it in mind when NULL means "zero" rather than "unknown".
count(*)
- What it counts
- All rows in the group
- Result on orders
- 10
count(promo_code)
- What it counts
- Rows where the value is not
NULL - Result on orders
- 3
count(DISTINCT promo_code)
- What it counts
- Distinct values, excluding
NULL - Result on orders
- 2
SELECT count(*) AS orders, count(promo_code) AS with_promo,
count(DISTINCT promo_code) AS distinct_promos
FROM orders;
-- orders | with_promo | distinct_promos
-- --------+------------+-----------------
-- 10 | 3 | 2
-- (1 row)The second trap: according to the documentation, all aggregates except count return NULL when there are no rows at all. The sum of an empty set is not zero. Order 10 has no lines yet:
SELECT sum(quantity * price) AS revenue
FROM order_items
WHERE order_id = 10;
SELECT coalesce(sum(quantity * price), 0) AS revenue
FROM order_items
WHERE order_id = 10;
SELECT count(*) AS lines
FROM order_items
WHERE order_id = 10;
-- revenue
-- ---------
-- NULL
-- (1 row)
--
-- revenue
-- ---------
-- 0
-- (1 row)
--
-- lines
-- -------
-- 0
-- (1 row)Another useful tool is FILTER: it limits the input rows of one particular aggregate without touching the others. That way one query computes several metrics:
SELECT count(*) AS orders,
count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
count(*) FILTER (WHERE promo_code IS NOT NULL) AS with_promo
FROM orders;10
- cancelled
- 1
- with_promo
- 3
GROUP BY and HAVING
GROUP BY splits rows into groups with equal values and computes aggregates for each group separately. Revenue by category means joining order lines with orders and products, filtering by status and grouping by category. In this lesson revenue counts paid and shipped orders: new ones are not paid yet, cancelled ones did not happen.
SELECT p.category,
sum(oi.quantity) AS units,
sum(oi.quantity * oi.price) AS revenue
FROM order_items AS oi
JOIN orders AS o ON o.id = oi.order_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status IN ('paid', 'shipped')
GROUP BY p.category
ORDER BY revenue DESC;coffee
- units
- 9
- revenue
- 77500.00
equipment
- units
- 2
- revenue
- 27400.00
tea
- units
- 3
- revenue
- 10800.00
tableware
- units
- 3
- revenue
- 7500.00
The grouping rule: SELECT may output only the GROUP BY columns and aggregates. If you output a column with many values inside a group, the database does not know which one to pick and refuses to run the query:
SELECT p.category, p.name, sum(oi.quantity) AS units
FROM order_items AS oi
JOIN products AS p ON p.id = oi.product_id
GROUP BY p.category;
-- ERROR: column "p.name" must appear in the GROUP BY clause or be used in an aggregate function
-- LINE 1: SELECT p.category, p.name, sum(oi.quantity) AS units
-- ^There is one exception: when you group by the primary key, the other columns of that table may be output — the key determines them unambiguously. So GROUP BY c.id is enough to output c.name. Our examples write GROUP BY c.id, c.name, which is clearer and portable to other DBMSs.
HAVING filters groups that are already built. Let us find clients with two or more orders, not counting cancelled ones:
SELECT c.name, count(*) AS orders
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
WHERE o.status <> 'cancelled'
GROUP BY c.id, c.name
HAVING count(*) >= 2
ORDER BY orders DESC, c.name;Aigerim Seitova
- orders
- 3
Daniyar Akhmetov
- orders
- 2
The difference between WHERE and HAVING follows from the execution order. WHERE decides which rows go into the groups, so no aggregate exists at that point yet:
SELECT client_id, count(*)
FROM orders
WHERE count(*) >= 2
GROUP BY client_id;
-- ERROR: aggregate functions are not allowed in WHERE
-- LINE 3: WHERE count(*) >= 2
-- ^The documentation recommends putting everything that does not need an aggregate into WHERE: that way the database does not spend work grouping rows it will drop anyway. HAVING keeps only conditions on aggregates.
When grouping, all NULL values fall into one group. The client without a city forms a separate row:
SELECT city, count(*) AS clients
FROM clients
GROUP BY city
ORDER BY clients DESC, city;Almaty
- clients
- 3
Astana
- clients
- 2
Karaganda
- clients
- 1
Shymkent
- clients
- 1
NULL
- clients
- 1
Subqueries and CTEs (WITH)
A subquery is a SELECT inside another query. You cannot write an aggregate in WHERE, but you can compare with a value computed by a separate subquery. The PostgreSQL tutorial shows this technique too:
SELECT name, price
FROM products
WHERE price > (SELECT avg(price) FROM products)
ORDER BY price DESC;Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
Espresso Blend 1 kg
- price
- 12900.00
French Press 600 ml
- price
- 8900.00
You can also group by an expression. date_trunc('month', …) truncates a date to the first day of the month, and ::date casts the result to date. Here GROUP BY uses the output column name month — PostgreSQL allows that.
SELECT date_trunc('month', o.ordered_on)::date AS month,
count(DISTINCT o.id) AS orders,
sum(oi.quantity * oi.price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status IN ('paid', 'shipped')
GROUP BY month
ORDER BY month;2026-09-01
- orders
- 4
- revenue
- 55500.00
2026-10-01
- orders
- 3
- revenue
- 67700.00
When a report takes several steps, WITH helps. The documentation describes a CTE (common table expression) as an auxiliary statement that acts like a temporary table for the duration of one query. First we compute each order's total, then revenue and the average order value per client from those totals:
WITH order_totals AS (
SELECT o.id, o.client_id, sum(oi.quantity * oi.price) AS total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.id, o.client_id
)
SELECT c.name,
count(*) AS orders,
sum(t.total) AS revenue,
round(avg(t.total)) AS avg_check
FROM order_totals AS t
JOIN clients AS c ON c.id = t.client_id
GROUP BY c.id, c.name
ORDER BY revenue DESC;Aigerim Seitova
- orders
- 3
- revenue
- 58200.00
- avg_check
- 19400
Aliya Bekova
- orders
- 1
- revenue
- 25800.00
- avg_check
- 25800
Timur Kim
- orders
- 1
- revenue
- 15700.00
- avg_check
- 15700
Daniyar Akhmetov
- orders
- 1
- revenue
- 13000.00
- avg_check
- 13000
Madina Omarova
- orders
- 1
- revenue
- 10500.00
- avg_check
- 10500
SELECT name FROM products
WHERE id NOT IN (SELECT product_id FROM order_items);
SELECT name FROM products
WHERE id NOT IN (1, 2, 3, 4, 5, 6, 7, 8, NULL);
SELECT p.name FROM products AS p
WHERE NOT EXISTS (
SELECT 1 FROM order_items AS oi WHERE oi.product_id = p.id
);
-- name
-- ----------
-- Gift Box
-- (1 row)
--
-- name
-- ------
-- (0 rows)
--
-- name
-- ----------
-- Gift Box
-- (1 row)Common mistakes: duplicates after JOIN and COUNT(*) vs COUNT(column)
A one-to-many join multiplies rows: each order repeats as many times as it has lines. Count count(*) after that and you get the number of lines, not orders:
-- Wrong: counts order lines, not orders
SELECT c.name, count(*) AS orders
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 5
Daniyar Akhmetov
- orders
- 2
Madina Omarova
- orders
- 3
Timur Kim
- orders
- 3
Aliya Bekova
- orders
- 1
Ruslan Ivanov
- orders
- 1
SELECT c.name,
count(DISTINCT o.id) AS orders,
sum(oi.quantity * oi.price) AS total
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 3
- total
- 58200.00
Daniyar Akhmetov
- orders
- 1
- total
- 13000.00
Madina Omarova
- orders
- 2
- total
- 29000.00
Timur Kim
- orders
- 1
- total
- 15700.00
Aliya Bekova
- orders
- 1
- total
- 25800.00
Ruslan Ivanov
- orders
- 1
- total
- 9600.00
Note the second detail in the result: the inner join with order lines dropped the empty order 10. If empty orders matter for the report, use LEFT JOIN and coalesce for the sum.
The other common mistake is count(*) after LEFT JOIN. A client without orders still has one row in the result (with NULL in the order columns), and count(*) counts it. Count a column of the right table instead:
SELECT c.name, count(o.id) AS orders, count(*) AS rows_in_group
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 3
- rows_in_group
- 3
Daniyar Akhmetov
- orders
- 2
- rows_in_group
- 2
Madina Omarova
- orders
- 2
- rows_in_group
- 2
Timur Kim
- orders
- 1
- rows_in_group
- 1
Aliya Bekova
- orders
- 1
- rows_in_group
- 1
Ruslan Ivanov
- orders
- 1
- rows_in_group
- 1
Zarina Kaliyeva
- orders
- 0
- rows_in_group
- 1
Sergey Pak
- orders
- 0
- rows_in_group
- 1
A sum or count is larger than expected
- Cause
- A one-to-many
JOINmultiplied the rows - Fix
count(DISTINCT …), or aggregate first inWITHand join afterwards
Clients without orders vanished from the report
- Cause
INNER JOINinstead ofLEFT JOIN- Fix
LEFT JOINfrom the table whose rows you need in full
LEFT JOIN behaves like INNER
- Cause
- A condition on the right table sits in
WHERE - Fix
- Move the condition into
ON
A client with no orders shows 1 order
- Cause
count(*)afterLEFT JOIN- Fix
count(right-table column)
A total is empty instead of 0
- Cause
sumover an empty set isNULL- Fix
coalesce(sum(…), 0)
must appear in the GROUP BY clause
- Cause
- A non-grouped column in
SELECT - Fix
- Add it to
GROUP BYor wrap it in an aggregate
Exercises: revenue by product and clients without orders
The exercises run on the same training database. Revenue is the sum of quantity * price over orders with status paid or shipped. The answers were checked on PostgreSQL 17.11; the expected output is in the comments of each solution.
Next in the path: running PostgreSQL for your own project and schema design — how to choose tables and links so that reports like these stay simple.
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.