JOIN — Learn Practical Join Patterns from the Basics
BasicJOINJoin patternsWeb developmentPostgreSQL-ready5 questions
QUESTION 1
Composite-Key JOIN — Combine Multiple Conditions to Join the Correct Records
INNER JOINAND conditionsComposite keyPreventing duplicate matches
Background

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;
How this differs from filtering in the WHERE clause: You can also write join conditions in 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.
Problem

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.

Tables used
▸ attendance
user_idwork_datecheck_in
110-0108:50
110-0209:05
210-0108:55
▸ shifts
user_idwork_dateshift_type
110-01Early shift
110-02Regular
210-01Regular
210-02Early shift
Expected Output
user_idwork_dateshift_typecheck_in
110-01Early shift08:50
110-02Regular09:05
210-01Regular08:55
Model Answer
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
  */
Explanation (table transitions & key points)
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 AND a.work_date = s.work_date;
LEGEND
Rows read / loaded
① FROM
FROM attendance AS aRead the attendance table (3 rows). Each row is checked against the shifts table using the composite key.
1 / 3
user_idwork_datecheck_in
1'10-01''08:50'
1'10-02''09:05'
2'10-01''08:55'
All 3 rows read
LEARNING POINTS
JOIN PATTERN
Composite-key INNER JOIN
join rows where multiple columns all match via AND
a = attendance   s = shifts
Precise matching with a composite key: If you JOIN only on the user ID, all dates cross-join when one user has multiple shifts on multiple dates, causing the rows to be combined incorrectly. Connecting both user_id and work_date with AND precisely links only the data for the same user on the same date, one-to-one.
A common real-world scenario: Composite keys are required not only for historical data like this example, where the same ID appears multiple times, but also constantly in multi-tenant SaaS database designs such as tenant_id (company ID) plus user_id.
ANTI-PATTERNS
Splitting part of the condition into WHERE: Writing 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.
QUESTION 2
Many-to-Many JOIN — Link Two Tables Through a Junction Table
INNER JOINJunction tableBridge tableMany-to-many
Background

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)
Problem

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).

Tables used
▸ users
user_idname
1Tanaka
2Sato
▸ user_tags (junction table)
user_idtag_id
110
120
220
▸ tags
tag_idtag_name
10Python
20SQL
Expected Output
nametag_name
TanakaPython
TanakaSQL
SatoSQL
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT u.name, t.tag_name FROM users AS u INNER JOIN user_tags AS ut ON u.user_id = ut.user_id INNER JOIN tags AS t ON ut.tag_id = t.tag_id;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the users table (2 rows).
1 / 4
user_idname
1Tanaka
2Sato
All 2 rows read
LEARNING POINTS
JOIN PATTERN
JOIN through a junction table
resolve many-to-many relationships through a bridge table
u = users   ut = user_tags   t = tags
Resolving many-to-many relationships: Many-to-many associations such as blog posts and categories or users and permission roles appear frequently in real-world database design. To retrieve them with JOINs, the standard approach is to place a table containing only pairs of IDs — a junction table — between the two sides and use two INNER JOINs.
Watch for row multiplication: The users table starts with 2 rows, but after joining the junction table, each user is duplicated or expanded once per tag they have. If you eventually need one row per user again, combine the JOIN with GROUP BY or an aggregate such as STRING_AGG() to consolidate the tags into a comma-separated string.
ANTI-PATTERNS
Storing data as comma-separated values (anti-pattern): Skipping the junction table and storing a value such as 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.
QUESTION 3
Non-Equi JOIN — Join Tables with Conditions Other Than = (Equals)
INNER JOINBETWEENRange joinNon-equi
Background

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;
Problem

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).

Tables used
▸ test_scores
studentscore
Tanaka85
Sato55
Yamada72
▸ grading_scales
grademin_scoremax_score
A80100
B6079
C059
Expected Output
studentscoregrade
Tanaka85A
Sato55C
Yamada72B
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT ts.student, ts.score, gs.grade FROM test_scores AS ts INNER JOIN grading_scales AS gs ON ts.score BETWEEN gs.min_score AND gs.max_score;
LEGEND
Rows read / loaded
① FROM
FROM test_scores AS tsRead the test score table (3 rows). The score value is the JOIN evaluation target.
1 / 3
studentscore
Tanaka85
Sato55
Yamada72
All 3 rows read
LEARNING POINTS
JOIN PATTERN
Non-Equi JOIN
join rows that satisfy conditions other than equals (=)
ts = test_scores   gs = grading_scales
The ON clause is simply a TRUE/FALSE test: The ON clause evaluates whether a combination of joined rows satisfies a condition (TRUE) or does not (FALSE). It does not have to use =; any expression with a boolean result, including BETWEEN (a range) or >=, can be written there.
Avoid hard-coding with a CASE expression: You could hard-code the grade in SQL as 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.
ANTI-PATTERNS
Overlapping ranges in the master: If the ranges in a non-equi JOIN master table — 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.
QUESTION 4
FULL OUTER JOIN — Keep Every Row from Both Tables, Including Missing Data
FULL OUTER JOINFull outer joinKeep both sidesFinding missing data
Background

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;
Practical uses: Although it is not common in everyday web development, FULL OUTER JOIN is extremely useful for data analysis and auditing tasks such as reconciling old and new systems to find missing records, or comparing budgets and actuals to show departments that exist on only one side.
Problem

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.

Tables used
▸ departments
dept_iddept_name
1Sales
2Development
3HR
▸ employees
emp_idnamedept_id
101Tanaka1
102Sato2
103SuzukiNULL
Expected Output
dept_namename
SalesTanaka
DevelopmentSato
HRNULL
NULLSuzuki
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT d.dept_name, e.name FROM departments AS d FULL OUTER JOIN employees AS e ON d.dept_id = e.dept_id;
LEGEND
Rows read / loaded
① FROM
FROM departments AS dRead the departments table (3 rows) as the left table.
1 / 3
dept_iddept_name
1Sales
2Development
3HR
All 3 rows read
LEARNING POINTS
JOIN PATTERN
FULL OUTER JOIN — full outer join
combine both tables without losing any data
d = departments   e = employees
A fusion of LEFT JOIN and RIGHT JOIN: LEFT JOIN loses Suzuki, who has no department, while RIGHT JOIN loses the HR department, which has no employees. FULL OUTER JOIN picks up both kinds of isolated data without discarding them, filling the missing portions with NULL before output.
Support differs by RDBMS: PostgreSQL, SQL Server, and Oracle support it as standard. MySQL did not support it for a long time, so the usual technique for obtaining the same result in MySQL is to combine the results of a LEFT JOIN and a RIGHT JOIN with UNION.
ANTI-PATTERNS
Using FULL OUTER casually: It is an anti-pattern to use FULL OUTER JOIN everywhere just because it never loses data. It can cause row counts to explode and introduce unintended NULL data that distorts later aggregates such as COUNT. Use INNER or LEFT for a clear purpose such as a master-data join, and reserve FULL for specific purposes such as reconciliation.
QUESTION 5
JOIN + Subquery — Join a Pre-Aggregated Virtual Table
LEFT JOINSubqueryDerived tableAggregate join
Background

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;
The strength of a subquery JOIN: If you use 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.
Problem

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.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idorder_date
112023-10-01
212023-10-05
322023-10-02
Expected Output
namelast_order
Tanaka2023-10-05
Sato2023-10-02
YamadaNULL
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.name, lo.last_order FROM users AS u LEFT JOIN ( SELECT user_id, MAX(order_date) AS last_order FROM orders GROUP BY user_id ) AS lo ON u.user_id = lo.user_id;
LEGEND
Rows read / loaded
① Subquery FROM
FROM ordersThe subquery inside the parentheses runs first. Read the order history table (orders, 3 rows).
1 / 5
order_iduser_idorder_date
11'2023-10-01'
21'2023-10-05'
32'2023-10-02'
All 3 rows read
LEARNING POINTS
JOIN PATTERN
LEFT JOIN with a subquery (derived table)
attach precomputed results to align data granularity
u = users   sub = virtual table
A design approach of aggregating first: You could run 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.
Faster than a correlated subquery: You could write 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.
FIELD NOTES
Generating a virtual table with a subquery has evolved into the modern SQL standard of writing a CTE (a WITH clause). Writing 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.