How to buildOctober 11, 202610 min read
SQL and PostgreSQLStep 3 of 9

SQL: JOIN, GROUP BY and aggregates

In short

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.

TutorialLevel: BeginnerSQLPractice: ~45–90 min

After this you will be able to

  • Join two or more tables with INNER JOIN and LEFT JOIN and find rows without a match, such as clients with no orders
  • Compute totals with count, sum, avg, min, max and explain how they treat NULL
  • Group rows with GROUP BY, filter groups with HAVING and explain how HAVING differs from WHERE
  • Split a complex report into steps with WITH and spot duplicate rows after a JOIN

Verified with

PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29

Checked against docs: October 11, 2026

Contents
  1. 01Why JOIN: the data lives in different tables
  2. 02INNER JOIN and LEFT JOIN, side by side
  3. 03Aggregates: COUNT, SUM, AVG, MIN, MAX and NULL
  4. 04GROUP BY and HAVING
  5. 05Subqueries and CTEs (WITH)
  6. 06Common mistakes: duplicates after JOIN and COUNT(*) vs COUNT(column)
  7. 07Exercises: revenue by product and clients without orders

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:

bash
# 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=NULL
SQL
SELECT 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
Real psql output. Shortened: first 5 of 10 rows.

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:

SQL
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
Lines of orders 1 and 5 with product names and line totals.

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:

SQL
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;
INNER JOIN: 10 rows; clients 7 and 8 are missing because they have no orders.
SQL
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
LEFT JOIN: 12 rows. The last two are clients without orders; the order columns are NULL.

INNER JOIN

  • Only rows that found a match by the ON condition
  • 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.

SQL
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
Clients without a single order.
SQL
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
A product nobody has ordered.

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.

SQL
-- 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
Wrong: 3 rows left; clients without paid orders are gone.
SQL
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
Condition in ON: all 8 clients; Aigerim has two paid orders — 9 rows.

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.

SQL
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
Real output: 9 products, weight set for 5.

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
SQL
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)
Checking the table above on the training database.

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:

SQL
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)
sum over an empty set is NULL, coalesce turns it into 0, count returns 0.

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:

SQL
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
Total, cancelled and promo orders in one pass.

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.

SQL
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
Revenue by category. There is no gifts row: the Gift Box was never sold.

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:

SQL
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:

SQL
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
Madina has two orders, but one is cancelled, so she does not pass the filter.

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:

SQL
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
--               ^
FROM and JOINWHEREGROUP BY and HAVINGSELECTORDER BY and LIMIT
The logical order of SELECT processing per the PostgreSQL documentation: rows are filtered before grouping, groups after it.

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:

SQL
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
NULL in city is a separate group of one client.

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:

SQL
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
Products priced above the catalogue average (8366.67 KZT).

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.

SQL
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
Revenue by month: September and October 2026.

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:

SQL
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
Step 1: order totals; step 2: per-client results. The average order value is rounded.
SQL
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)
Three queries in a row; real output in the comments.

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:

SQL
-- 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
Wrong: Aigerim has 3 orders, not 5 — they contain 5 lines.
SQL
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
count(DISTINCT o.id) counts orders. Daniyar has 1, not 2: order 10 has no lines and dropped out in the INNER JOIN with order_items.

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:

SQL
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
Clients without orders: count(o.id) = 0, while count(*) = 1.

A sum or count is larger than expected

Cause
A one-to-many JOIN multiplied the rows
Fix
count(DISTINCT …), or aggregate first in WITH and join afterwards

Clients without orders vanished from the report

Cause
INNER JOIN instead of LEFT JOIN
Fix
LEFT JOIN from 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(*) after LEFT JOIN
Fix
count(right-table column)

A total is empty instead of 0

Cause
sum over an empty set is NULL
Fix
coalesce(sum(…), 0)

must appear in the GROUP BY clause

Cause
A non-grouped column in SELECT
Fix
Add it to GROUP BY or 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.