JOIN — Learn Practical Join Patterns in Practice
AdvancedAdvanced JOINJoin patternsWeb developmentPostgreSQL-ready5 questions
QUESTION 1
SELF JOIN — Join One Table in Two Roles to Represent a Hierarchical Relationship
SELF JOINSelf-joinOrganization hierarchyAliases required
Background

A SELF JOIN references the same table through two different aliases and joins rows to one another.
A common example is an organization hierarchy where employees and their managers are stored in the same employees table and manager_id references an emp_id in that table.

FROM   employees AS e   -- reference the subordinate side
LEFT JOIN employees AS m  -- reference the manager side
  ON e.manager_id = m.emp_id;
Aliases are essential: In a SELF JOIN, you must use different aliases, such as AS e (subordinate) and AS m (manager). Without aliases, the database cannot tell which instance of the table a column refers to.
Problem

The employees table stores each employee's manager_id, which is the emp_id of their direct manager.

SELF JOIN this table and retrieve a list of each employee's name (employee) and their direct manager's name (manager). Include employees with no manager (the company president) in the output.

Tables used
▸ employees
emp_idnamemanager_id
1TanakaNULL
2Sato1
3Yamada1
4Suzuki2
Expected Output
employeemanager
TanakaNULL
SatoTanaka
YamadaTanaka
SuzukiSato
QUESTION 2
Anti-JOIN — Find Users Who Have Never Made a Purchase with LEFT JOIN + IS NULL
LEFT JOINIS NULLAnti-JOINSet difference
Background

The pattern for retrieving “records that exist in table A but do not exist in table B” is called an anti-JOIN.
It uses two stages: keep every row with LEFT JOIN, then use WHERE to retain only rows where the right-table side is NULL.

FROM   users AS u
LEFT JOIN purchases AS p ON u.user_id = p.user_id
WHERE  p.user_id IS NULL;   -- keep only rows with no match in the right table
Choosing between this and NOT IN: WHERE u.user_id NOT IN (SELECT user_id FROM purchases) can return the same result, but NOT IN risks returning no rows if the right-side list contains NULL. The anti-JOIN pattern is safer and faster, so it is recommended in practical work.
Problem

There are a user list (users) and a purchase-history table (purchases).

Retrieve only the names of users who have never made a purchase.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ purchases
purchase_iduser_iditem
11Item A
21Item B
32Item C
Expected Output
name
Yamada
QUESTION 3
Aggregate JOIN — Calculate Department Averages in a Subquery First, Then Compare Individual Salaries
INNER JOINGROUP BYSubqueryCompare with aggregate
Background

To determine whether each employee's salary is above or below the average for their department, first aggregate the data and then join it to the individual records.
Use a subquery (or the CTE described later) to perform the GROUP BY aggregation first, then JOIN that result table so you can attach the aggregate value while retaining each individual row.

FROM employees AS e
INNER JOIN (
  SELECT dept_id, AVG(salary) AS avg_salary
  FROM   employees
  GROUP BY dept_id
) AS da ON e.dept_id = da.dept_id;
Why can’t AVG be written in WHERE: WHERE salary > AVG(salary) is syntactically invalid. WHERE evaluates individual rows before aggregation, so aggregate functions cannot be used there. “Aggregate first → JOIN the result table” is the correct design.
Problem

The employees table stores salary (salary) and department ID (dept_id).

Retrieve a list of each employee's name, salary, and the average salary for their department (avg_salary).

Tables used
▸ employees
emp_idnamedept_idsalary
1Tanaka10500
2Sato10700
3Yamada20600
4Suzuki20400
Expected Output
namesalaryavg_salary
Tanaka500600
Sato700600
Yamada600500
Suzuki400500
QUESTION 4
Chained LEFT JOIN Across Three Tables — Combine Orders, Users, and Items in an E-Commerce Query
LEFT JOIN ×2Chained joinsE-commerce designPreserve NULLs
Background

Practical web applications frequently need to join three or more tables in one query.
When LEFT JOINs are chained, the result of all joins up to that point is always the “left table.” The key to preserving NULLs is that a missing match in a right-side table must not remove the existing rows.

FROM         orders      AS o
LEFT JOIN    users       AS u  ON o.user_id    = u.user_id
LEFT JOIN    order_items AS oi ON o.order_id   = oi.order_id;
JOIN order determines the result: When LEFT JOINs are chained, the table named in FROM is the most protected side. Even when a later table has no match, the original row always remains in the result.
Problem

There are three tables: orders (orders), users (users), and order details (order_items).

Join all three tables and retrieve the order ID, user name, product name, and quantity. Include the order with no registered product (order_id=3) in the output.

Tables used
▸ orders
order_iduser_id
11
22
33
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ order_items
item_idorder_idproduct_nameqty
11T-shirt2
21Jeans1
32Sneakers1
Expected Output
order_idnameproduct_nameqty
1TanakaT-shirt2
1TanakaJeans1
2SatoSneakers1
3YamadaNULLNULL
QUESTION 5
CTE + JOIN — Pre-Aggregate with WITH, Then Join User Information in a Modern SQL Pattern
WITH (CTE)LEFT JOINCOALESCEImprove readability
Background

A CTE (Common Table Expression) defines a named virtual table at the beginning of a SQL statement in the form WITH name AS (subquery).
Instead of nesting complex subqueries, you can write a structure that reads from top to bottom, so code readability improves dramatically.

WITH cte_name AS (
  SELECT ...  -- pre-aggregation or shaping
)
SELECT ...
FROM   main_table
LEFT JOIN cte_name ON ...;
A recommended modern SQL style: CTEs are supported by all major RDBs and warehouses, including PostgreSQL, MySQL 8.0, BigQuery, and Snowflake. The main advantage is that the same result as the Q3 subquery JOIN can be written more readably.
Problem

There are a users table (users) and an orders table (orders).

Use a WITH clause to aggregate each user's order count (order_count) and total amount (total_amount), then join the result with the user information and output it.
Also output the user with no orders (Yamada): show the order count as 0 and the total amount as NULL.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamount
113000
215000
322000
Expected Output
nameorder_counttotal_amount
Tanaka28000
Sato12000
Yamada0NULL