When a single join key does not uniquely identify a record, use AND in the ON clause to combine multiple columns as the join condition (a composite key). This pattern is extremely common in practice, especially for historical data and multi-tenant designs with a tenant ID.
FROM table_a AS a INNER JOIN table_b AS b ON a.key1 = b.key1 AND a.key2 = b.key2;
WHERE, but putting all composite-key conditions in the ON clause makes the JOIN's purpose — linking the correct records — explicit and is the best practice.There are two tables: attendance history (attendance) and scheduled shifts (shifts). Join them only when both user_id and work_date match, then retrieve who checked in, on which date, for which shift, and at what time.
| user_id | work_date | check_in |
|---|---|---|
| 1 | 10-01 | 08:50 |
| 1 | 10-02 | 09:05 |
| 2 | 10-01 | 08:55 |
| user_id | work_date | shift_type |
|---|---|---|
| 1 | 10-01 | Early shift |
| 1 | 10-02 | Regular |
| 2 | 10-01 | Regular |
| 2 | 10-02 | Early shift |
| user_id | work_date | shift_type | check_in |
|---|---|---|---|
| 1 | 10-01 | Early shift | 08:50 |
| 1 | 10-02 | Regular | 09:05 |
| 2 | 10-01 | Regular | 08:55 |
SELECT a.user_id, a.work_date, s.shift_type, a.check_in FROM attendance AS a INNER JOIN shifts AS s ON a.user_id = s.user_id -- join only rows with the same user ID and the same date AND a.work_date = s.work_date; /* Execution order: 1. FROM attendance AS a → the attendance table 2. INNER JOIN shifts AS s → match shifts rows satisfying both ON conditions 3. SELECT a.user_id, ... → the joined virtual table */
LEGEND
① FROM
FROM attendance AS aRead the attendance table (3 rows). Each row is checked against the shifts table using the composite key.| user_id | work_date | check_in |
|---|---|---|
| 1 | '10-01' | '08:50' |
| 1 | '10-02' | '09:05' |
| 2 | '10-01' | '08:55' |
a = attendance s = shifts
user_id and work_date with AND precisely links only the data for the same user on the same date, one-to-one.tenant_id (company ID) plus user_id.ON a.user_id = s.user_id WHERE a.work_date = s.work_date is syntactically possible, but it scatters the JOIN intent — which columns define the match — and reduces readability. Put all join conditions in the ON clause, and put result filters such as WHERE s.shift_type = 'Early shift' in WHERE; that is a basic principle of clean SQL.In a relational database, a relationship in which one user can have multiple tags and one tag can be assigned to multiple users is called many-to-many. Many-to-many data cannot be joined directly, so place a junction table (bridge table) between the two sides and retrieve the data with two consecutive JOINs.
FROM users AS u INNER JOIN user_tags AS ut ON u.user_id = ut.user_id -- first JOIN (to the junction table) INNER JOIN tags AS t ON ut.tag_id = t.tag_id; -- second JOIN (to the destination table)
There are user information (users), tag information (tags), and a junction table (user_tags) that manages their associations.
Join these three tables and retrieve a list of user names (name) and tag names (tag_name).
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| user_id | tag_id |
|---|---|
| 1 | 10 |
| 1 | 20 |
| 2 | 20 |
| tag_id | tag_name |
|---|---|
| 10 | Python |
| 20 | SQL |
| name | tag_name |
|---|---|
| Tanaka | Python |
| Tanaka | SQL |
| Sato | SQL |
SELECT u.name, t.tag_name FROM users AS u INNER JOIN user_tags AS ut -- ① first join the junction table to find each user's tag_id ON u.user_id = ut.user_id INNER JOIN tags AS t -- ② use the junction table's tag_id to find the tag name in tags ON ut.tag_id = t.tag_id; /* Execution order: 1. FROM users AS u → read users (2 rows) 2. INNER JOIN user_tags AS ut→ match by user_id. Tanaka (1) expands to 2 rows and Sato (2) to 1 row (3-row intermediate result) 3. INNER JOIN tags AS t → match the 3 expanded rows with tags by tag_id 4. SELECT u.name, t.tag_name → project only the required text columns and output them */
LEGEND
① FROM
FROM users AS uRead the users table (2 rows).| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
u = users ut = user_tags t = tags
GROUP BY or an aggregate such as STRING_AGG() to consolidate the tags into a comma-separated string.tags = "10,20" in the users table — the Jaywalking anti-pattern — is a poor design. Indexes cannot work when searching for users with a particular tag, so large datasets fall back to a full scan such as a LIKE search and suffer a fatal performance decline.A JOIN's ON clause can use non-equality conditions such as BETWEEN and >=, not only = (equality). This is called a non-equi JOIN.
It is a powerful technique when classifying sales amounts into ranks (A–C), checking whether a date falls within a campaign period, or joining to a range master in another table.
FROM scores AS s INNER JOIN scales AS g -- join rows where score falls between min and max ON s.score BETWEEN g.min_score AND g.max_score;
There are student test scores (test_scores) and a master table of grading criteria (grading_scales). Join them and retrieve the student (student), score (score), and grade rank (grade).
| student | score |
|---|---|
| Tanaka | 85 |
| Sato | 55 |
| Yamada | 72 |
| grade | min_score | max_score |
|---|---|---|
| A | 80 | 100 |
| B | 60 | 79 |
| C | 0 | 59 |
| student | score | grade |
|---|---|---|
| Tanaka | 85 | A |
| Sato | 55 | C |
| Yamada | 72 | B |
SELECT ts.student, ts.score, gs.grade FROM test_scores AS ts INNER JOIN grading_scales AS gs -- join the master row whose range contains the score, not an equal value ON ts.score BETWEEN gs.min_score AND gs.max_score; /* Execution order: 1. FROM test_scores AS ts → read the score table (3 rows) 2. INNER JOIN grading_scales → check whether each ts.score falls within a gs min–max range Tanaka's 85 matches 80–100 (A) Sato's 55 matches 0–59 (C) Yamada's 72 matches 60–79 (B) 3. SELECT ts.student, ... → project 3 columns from the joined virtual table */
LEGEND
① FROM
FROM test_scores AS tsRead the test score table (3 rows). The score value is the JOIN evaluation target.| student | score |
|---|---|
| Tanaka | 85 |
| Sato | 55 |
| Yamada | 72 |
ts = test_scores gs = grading_scales
=; any expression with a boolean result, including BETWEEN (a range) or >=, can be written there.CASE WHEN score >= 80 THEN 'A' ... END, but a non-equi JOIN enables a maintainable, flexible design: when the grading criteria (master data) change, you only need to update the table values.grading_scales here — overlap, such as 80–100 and 75–85, one score can match multiple grades and unnecessarily multiply the result rows. Enforce strict constraints against gaps and overlaps in the master ranges.FULL OUTER JOIN combines the characteristics of both LEFT JOIN and RIGHT JOIN.
It keeps every row from both the left and right tables in the result, and fills the columns on the missing side with NULL whenever the data does not match.
FROM table_a AS a FULL OUTER JOIN table_b AS b ON a.id = b.id;
There are a department master table (departments) and an employee table (employees).
Join them on dept_id and output both departments with no employees and employees whose department has not been assigned.
| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Development |
| 3 | HR |
| emp_id | name | dept_id |
|---|---|---|
| 101 | Tanaka | 1 |
| 102 | Sato | 2 |
| 103 | Suzuki | NULL |
| dept_name | name |
|---|---|
| Sales | Tanaka |
| Development | Sato |
| HR | NULL |
| NULL | Suzuki |
SELECT d.dept_name, e.name FROM departments AS d FULL OUTER JOIN employees AS e -- keep both the HR department and Suzuki with no department ON d.dept_id = e.dept_id; /* Execution order: 1. FROM departments AS d → read the department table (3 rows) 2. FULL OUTER JOIN employees e → match both sides by dept_id ・matched: Sales (Tanaka), Development (Sato) ・left side only: HR (the e side is NULL-padded) ・right side only: Suzuki (the d side is NULL-padded) 3. SELECT d.dept_name, e.name → project all rows (4 total) and output them */
LEGEND
① FROM
FROM departments AS dRead the departments table (3 rows) as the left table.| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Development |
| 3 | HR |
d = departments e = employees
FULL OUTER JOIN picks up both kinds of isolated data without discarding them, filling the missing portions with NULL before output.LEFT JOIN and a RIGHT JOIN with UNION.The table you join does not have to be a physical table. You can first aggregate data with GROUP BY inside (), define the result as a derived table (inline view), and JOIN it to the main table.
FROM users AS u LEFT JOIN ( -- aggregate totals or other values by user in advance SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ) AS o ON u.user_id = o.user_id;
GROUP BY in the main query, the overall rows collapse. If you aggregate first in a subquery and attach it with LEFT JOIN, you can keep every user row while adding the aggregate value beside it.There are a user table (users) and an order history table (orders).
First use a subquery to create a virtual table containing each user's latest order date (last_order), then LEFT JOIN it to users and output the latest order date for every user. Users with no orders should have NULL.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | order_date |
|---|---|---|
| 1 | 1 | 2023-10-01 |
| 2 | 1 | 2023-10-05 |
| 3 | 2 | 2023-10-02 |
| name | last_order |
|---|---|
| Tanaka | 2023-10-05 |
| Sato | 2023-10-02 |
| Yamada | NULL |
SELECT u.name, lo.last_order FROM users AS u LEFT JOIN ( -- virtual table: aggregate orders by user and obtain the maximum date SELECT user_id, MAX(order_date) AS last_order FROM orders GROUP BY user_id ) AS lo ON u.user_id = lo.user_id; -- join users to the virtual table (lo) by user_id /* Execution order: 1. Subquery FROM orders → read orders 2. Subquery aggregation → calculate MAX date per user (lo) 3. FROM users AS u → read users 4. LEFT JOIN ... AS lo → join lo (unmatched users become NULL) 5. SELECT u.name, ... → project the columns */
LEGEND
① Subquery FROM
FROM ordersThe subquery inside the parentheses runs first. Read the order history table (orders, 3 rows).| order_id | user_id | order_date |
|---|---|---|
| 1 | 1 | '2023-10-01' |
| 2 | 1 | '2023-10-05' |
| 3 | 2 | '2023-10-02' |
u = users sub = virtual table
LEFT JOIN orders on the full dataset and then GROUP BY u.name, but when the aggregate logic such as SUM or MAX becomes complicated, first creating a per-user aggregate table in a () subquery makes the mental model and SQL line up more easily and improves readability.SELECT u.name, (SELECT MAX(order_date) FROM orders WHERE user_id = u.user_id) FROM users in the SELECT clause, but a correlated subquery may run once per row and hurt performance. The JOIN + derived-table approach completes the work as one bulk operation and is recommended in practice.WITH last_orders AS (SELECT ...) SELECT ... FROM users LEFT JOIN last_orders avoids deep nesting and lets you describe the processing from top to bottom, making the code dramatically easier to read. After understanding subqueries (derived tables), be sure to master CTEs as well.