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;
AS e (subordinate) and AS m (manager). Without aliases, the database cannot tell which instance of the table a column refers to.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.
| emp_id | name | manager_id |
|---|---|---|
| 1 | Tanaka | NULL |
| 2 | Sato | 1 |
| 3 | Yamada | 1 |
| 4 | Suzuki | 2 |
| employee | manager |
|---|---|
| Tanaka | NULL |
| Sato | Tanaka |
| Yamada | Tanaka |
| Suzuki | Sato |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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
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.There are a user list (users) and a purchase-history table (purchases).
Retrieve only the names of users who have never made a purchase.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| purchase_id | user_id | item |
|---|---|---|
| 1 | 1 | Item A |
| 2 | 1 | Item B |
| 3 | 2 | Item C |
| name |
|---|
| Yamada |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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;
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.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).
| emp_id | name | dept_id | salary |
|---|---|---|---|
| 1 | Tanaka | 10 | 500 |
| 2 | Sato | 10 | 700 |
| 3 | Yamada | 20 | 600 |
| 4 | Suzuki | 20 | 400 |
| name | salary | avg_salary |
|---|---|---|
| Tanaka | 500 | 600 |
| Sato | 700 | 600 |
| Yamada | 600 | 500 |
| Suzuki | 400 | 500 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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;
FROM is the most protected side. Even when a later table has no match, the original row always remains in the result.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.
| order_id | user_id |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| item_id | order_id | product_name | qty |
|---|---|---|---|
| 1 | 1 | T-shirt | 2 |
| 2 | 1 | Jeans | 1 |
| 3 | 2 | Sneakers | 1 |
| order_id | name | product_name | qty |
|---|---|---|---|
| 1 | Tanaka | T-shirt | 2 |
| 1 | Tanaka | Jeans | 1 |
| 2 | Sato | Sneakers | 1 |
| 3 | Yamada | NULL | NULL |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 ...;
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.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 5000 |
| 3 | 2 | 2000 |
| name | order_count | total_amount |
|---|---|---|
| Tanaka | 2 | 8000 |
| Sato | 1 | 2000 |
| Yamada | 0 | NULL |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free