JOIN — Learn Table-Join Fundamentals from the Basics
BasicJOINTable joinsWeb developmentPostgreSQL-ready5 questions
QUESTION 6
SELF JOIN — Give One Table Two Aliases to Retrieve Employees and Managers in One Query
SELF JOINAliasesHierarchyOrg chart
Background

A SELF JOIN joins a table to itself under two different aliases. By treating one occurrence as the employee side (e) and the other as the manager side (m), you can retrieve relationships between rows in the same table.

FROM   employees AS e         -- read rows in the employee role
INNER JOIN employees AS m   -- read the same table again in the manager role
  ON e.manager_id = m.emp_id;  -- match each employee to their manager
Aliases are required: because the same table name appears twice, distinct aliases must identify which occurrence each column belongs to. Omitting them makes column references ambiguous.
Problem

The manager_id column in employees points to the employee ID of that person's manager; the CEO has NULL. Use a SELF JOIN to retrieve every non-CEO employee's name and manager's name.

Table used
▸ employees
emp_idnamemanager_id
1Alice (CEO)NULL
2Bob1
3Carol1
4David2
Expected Output
employeemanager
BobAlice (CEO)
CarolAlice (CEO)
DavidBob
Model Answer
SELECT
  e.name  AS employee,  -- name from the employee side (e)
  m.name  AS manager    -- name from the manager side (m)

FROM employees AS e         -- read all four rows as employees
INNER JOIN employees AS m   -- read the table again as managers
  ON e.manager_id = m.emp_id; -- retain employee-manager matches

/*
  Execution order:
  1. FROM employees AS e        → read employees in the subordinate role
  2. INNER JOIN employees AS m  → self-join the same table in the manager role
  3. SELECT e.name, m.name      → project the two names
*/
Explanation (table transitions & key points)
SELECT e.name AS employee, m.name AS manager FROM employees AS e INNER JOIN employees AS m ON e.manager_id = m.emp_id;
LEGEND
Rows read / loaded
① FROM
FROM employees AS eRead all four employees in the employee role (e). manager_id is the SELF JOIN key. Alice has manager_id = NULL, so the INNER JOIN removes her in the next step.
1 / 3
emp_idnamemanager_id
1Alice (CEO)NULL
2Bob1
3Carol1
4David2
All 4 rows read as employees
LEARNING POINTS
AB
INNER JOIN (SELF JOIN)
Join matching rows between two aliases of the same table.
A: employees(e) / B: employees(m)
The essence of SELF JOIN is giving one table two roles: unlike an ordinary join between different tables, a SELF JOIN introduces the same table twice under distinct aliases. The database treats the aliases as separate table instances, which makes it possible to compare related rows such as parents and children or managers and employees.
INNER JOIN removes the root row automatically: Alice's manager_id = NULL matches no emp_id because comparisons with NULL are UNKNOWN. Use LEFT JOIN instead if the CEO must remain with a NULL manager.
ANTI-PATTERNS
Omitting aliases: without aliases, columns such as name are ambiguous because both table instances have them. Give both sides explicit aliases.
Using aliases that hide each role: prefer e for employee and m for manager over arbitrary names such as a and b.
Field Notes
SELF JOIN commonly appears in organization-chart APIs, parent-child categories, and comparisons between rows in one table. Whenever relationships live inside a single table, consider a SELF JOIN.
QUESTION 7
LEFT JOIN ON vs WHERE — Filter Placement Changes Which Rows Survive
LEFT JOINON filterWHERE pitfallPreserve NULLs
Background

With LEFT JOIN, placing a filter in the ON clause or the WHERE clause produces different results. This is one of the most common join bugs in production queries.

-- ✓ ON filter: preserve every user and join only shipped orders
LEFT JOIN orders AS o
  ON  u.user_id = o.user_id
  AND o.status  = 'shipped'  -- part of matching; all left rows remain

-- ✗ WHERE filter: removes NULL-padded rows and behaves like INNER JOIN
LEFT JOIN orders AS o ON u.user_id = o.user_id
WHERE o.status = 'shipped'  -- NULL fails the comparison, so users without matches disappear
ON versus WHERE: an ON filter limits matching rows from the right table while preserving every left-table row. A WHERE filter runs after the join and removes NULL-padded rows from the joined result.
Problem

Retrieve every row from users together with that user's orders whose status = 'shipped'. Show NULL for users with no shipped orders or no orders at all. Implement the condition by adding AND o.status = 'shipped' to the ON clause.

Alice has both a shipped order (id 1) and a pending order (id 2). The pending order fails the ON condition and is not joined.

Tables used
▸ users
user_idname
1Alice
2Bob
3Carol
▸ orders
order_iduser_idstatus
11shipped
21pending
32shipped
Expected Output
nameorder_idstatus
Alice1shipped
Bob3shipped
CarolNULLNULL
Model Answer
SELECT
  u.name,
  o.order_id,
  o.status

FROM users AS u
LEFT JOIN orders AS o
  ON  u.user_id = o.user_id   -- join key
  AND o.status  = 'shipped';  -- join only shipped orders while retaining every user

-- Moving AND to WHERE is incorrect here:
-- WHERE o.status = 'shipped' also removes Carol's NULL-padded row,
-- making the result equivalent to an INNER JOIN.

/*
  Execution order:
  1. FROM users AS u                      → read the left table
  2. LEFT JOIN orders AS o                → match user_id and status = 'shipped'
  3. SELECT u.name, o.order_id, o.status  → project the requested columns
*/
Explanation (table transitions & key points)
SELECT u.name, o.order_id, o.status FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id AND o.status = 'shipped';
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead all three users as the left table. LEFT JOIN guarantees that these three rows remain in the result.
1 / 3
user_idname
1Alice
2Bob
3Carol
All 3 rows read and preserved
LEARNING POINTS
AB
LEFT JOIN
Preserve every left row and join right rows satisfying all ON conditions.
A: users(u) / B: orders(o)
ON defines matches; WHERE filters the joined table: failed ON matches become NULL while the left row survives. A later WHERE predicate rejects that NULL-padded row. Put right-table conditions in ON when the requirement is “all left rows, with matching right rows when eligible.”
Use post-join WHERE deliberately: Part A's anti-join used WHERE o.order_id IS NULL specifically to retain unmatched rows. Choose ON for conditional matching and WHERE for intentional filtering after the join.
ANTI-PATTERNS
Putting the right-table filter in WHERE: WHERE o.status = 'shipped' removes Carol's NULL row, leaving only Alice and Bob—the same effective result as INNER JOIN.
Field Notes
A common production case is “all users plus orders from the last 30 days.” Put the date condition in ON so inactive users still appear as NULL. Putting it in WHERE instead returns only recently active users.
QUESTION 8
COALESCE + LEFT JOIN — Replace NULL Join Results with Default Values
LEFT JOINCOALESCENULL handlingDefaults
Background

If an API returns NULL columns produced by LEFT JOIN, every client must perform its own null checks. COALESCE lets the SQL query replace those NULLs with suitable default values.

COALESCE(expr, default_value)
-- Return default_value when expr is NULL; otherwise return expr.
-- More arguments are allowed: COALESCE(a, b, c) returns the first non-NULL value.
SELECT
  u.name,
  COALESCE(pr.bio, 'Not set')          AS bio,     -- NULL → 'Not set'
  COALESCE(pr.avatar_url, '/default.png') AS avatar  -- NULL → default image
FROM users AS u
LEFT JOIN profiles AS pr ON u.user_id = pr.user_id;
COALESCE versus IFNULL / ISNULL: MySQL offers IFNULL(expr, default), but COALESCE is standard SQL and works across PostgreSQL, MySQL, and SQLite. It is the more portable choice.
Problem

Retrieve every user and their profile fields bio and avatar_url. For a user with no profile, display 'Not set' for bio and '/img/default.png' for avatar_url.

Tables used
▸ users
user_idname
1Alice
2Bob
3Carol
▸ profiles
user_idbioavatar_url
1SQL specialist/img/alice.png
2Designer/img/bob.png
Expected Output
namebioavatar_url
AliceSQL specialist/img/alice.png
BobDesigner/img/bob.png
CarolNot set/img/default.png
Model Answer
SELECT
  u.name,
  COALESCE(pr.bio,        'Not set')           AS bio,           -- default when no profile bio exists
  COALESCE(pr.avatar_url, '/img/default.png') AS avatar_url  -- default when no avatar exists

FROM users AS u
LEFT JOIN profiles AS pr   -- preserve users without profiles
  ON u.user_id = pr.user_id;

/*
  Execution order:
  1. FROM users AS u           → read all three users
  2. LEFT JOIN profiles AS pr  → match profile rows by user_id
  3. SELECT + COALESCE(...)    → replace NULL profile values
*/
Explanation (table transitions & key points)
SELECT u.name, COALESCE(pr.bio, 'Not set') AS bio, COALESCE(pr.avatar_url, '/img/default.png') AS avatar_url FROM users AS u LEFT JOIN profiles AS pr ON u.user_id = pr.user_id;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead all three users as the left table. LEFT JOIN preserves every user.
1 / 4
user_idname
1Alice
2Bob
3Carol
All 3 rows read
LEARNING POINTS
AB
LEFT JOIN (+ COALESCE)
Preserve all left rows, NULL-pad missing right rows, then substitute defaults.
A: users(u) / B: profiles(pr)
COALESCE returns the first non-NULL argument: COALESCE(pr.bio, 'Not set') returns the real bio when present and the fallback otherwise. More fallbacks can be chained from left to right.
Argument types must be compatible: use numeric defaults for numeric columns and text defaults for text columns. PostgreSQL rejects incompatible combinations such as an integer column with the text value '0'.
ANTI-PATTERNS
Wrapping NOT NULL columns unnecessarily: COALESCE adds no value where the schema already guarantees a value. Reserve it for nullable columns and outer-join results.
Wrapping an indexed filter column: WHERE COALESCE(col, 0) = 0 can prevent a normal index from being used. Prefer WHERE col IS NULL OR col = 0 when appropriate.
Field Notes
COALESCE with LEFT JOIN is common in backend APIs for profile defaults, zero-filled aggregates such as COALESCE(SUM(amount), 0), and locale fallbacks such as COALESCE(t.en, t.default_label, 'N/A').
QUESTION 9
JOIN + HAVING — Filter After GROUP BY and Understand Why WHERE Is Different
INNER JOINHAVINGGROUP BYPost-aggregate filter
Background

HAVING filters aggregate results after GROUP BY. WHERE filters individual rows before aggregation, so aggregate functions such as COUNT or SUM cannot be used directly in WHERE.

SELECT   u.name, COUNT(o.order_id) AS order_count
FROM     users AS u
INNER JOIN orders AS o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name
HAVING   COUNT(o.order_id) >= 2;  -- filter after aggregation
Logical execution order: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. HAVING can use aggregate functions because it runs after GROUP BY. WHERE COUNT(...) >= 2 is invalid because WHERE runs before aggregation.
Problem

Join users and orders, then retrieve the name and order count for users who have at least two orders. Sort by order count descending.

Tables used
▸ users
user_idname
1Alice
2Bob
3Carol
▸ orders
order_iduser_idamount
114200
211800
323500
432000
53900
Expected Output
nameorder_count
Alice2
Carol2
Model Answer
SELECT
  u.name,
  COUNT(o.order_id)  AS order_count  -- count orders in each user group

FROM users AS u
INNER JOIN orders AS o
  ON u.user_id = o.user_id

GROUP BY u.user_id, u.name  -- include every non-aggregate SELECT column

HAVING COUNT(o.order_id) >= 2  -- filter aggregate values after GROUP BY

ORDER BY order_count DESC;  -- sort the aggregate result descending

/*
  Execution order:
  1. FROM users AS u           → read three users
  2. INNER JOIN orders AS o    → create a five-row joined table
  3. WHERE                     → none
  4. GROUP BY u.user_id        → form three groups: Alice 2, Bob 1, Carol 2
  5. HAVING COUNT >= 2         → remove Bob's one-row group
  6. SELECT u.name, COUNT(...) → project the two remaining groups
  7. ORDER BY order_count DESC → sort; tied rows have database-dependent order
*/
Explanation (table transitions & key points)
SELECT u.name, COUNT(o.order_id) AS order_count FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name HAVING COUNT(o.order_id) >= 2 ORDER BY order_count DESC;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the three users as the left table.
1 / 5
user_idname
1Alice
2Bob
3Carol
All 3 rows read
LEARNING POINTS
AB
INNER JOIN
Join matching keys, then aggregate the joined rows with GROUP BY.
A: users(u) / B: orders(o)
HAVING is the post-GROUP BY filter: because HAVING runs after grouping, it can test COUNT, SUM, AVG, and other aggregate results. WHERE runs earlier and therefore cannot test an aggregate that does not exist yet.
Put non-aggregate predicates in WHERE: row-level conditions such as category = 'food' should normally run before GROUP BY. Filtering earlier reduces the number of rows to aggregate and communicates intent clearly.
ANTI-PATTERNS
Writing an aggregate in WHERE: WHERE COUNT(o.order_id) >= 2 is invalid because WHERE is evaluated before groups and their counts exist.
Assuming SELECT aliases work in HAVING everywhere: PostgreSQL does not accept HAVING order_count >= 2 here. Repeat the aggregate expression or wrap the query in a subquery.
Field Notes
Typical HAVING uses include finding heavy users with at least three monthly purchases, filtering salespeople above a revenue threshold, and detecting duplicate emails with HAVING COUNT(*) >= 2.
QUESTION 10
CROSS JOIN — Generate Every Row-by-Row Combination Without an ON Clause
CROSS JOINCartesian productNo ON clauseVariant generation
Background

CROSS JOIN has no ON clause. It generates every combination of every left-table row with every right-table row. The result contains “left row count × right row count” rows.

FROM   colors AS c
CROSS JOIN sizes AS s;
-- No ON clause: 3 colors × 3 sizes = 9 rows.
Beware of row explosion: CROSS JOIN multiplies row counts. Joining 10,000 rows to 10,000 rows produces 100 million rows, so never use it casually with large tables. Accidentally omitting a join condition can also create an unintended Cartesian product.
Problem

Generate every product variant formed from color and size. CROSS JOIN the three rows in colors with the three rows in sizes, and return all nine color_name and size_name combinations.

Tables used
▸ colors
color_idcolor_name
1Red
2Blue
3Green
▸ sizes
size_idsize_name
1S
2M
3L
Expected Output
color_namesize_name
RedS
RedM
RedL
BlueS
BlueM
BlueL
GreenS
GreenM
GreenL
Model Answer
SELECT
  c.color_name,
  s.size_name

FROM colors AS c
CROSS JOIN sizes AS s  -- no ON clause: 3 × 3 produces 9 combinations

ORDER BY c.color_id, s.size_id; -- order by color, then size

/*
  Execution order:
  1. FROM colors AS c         → read three colors
  2. CROSS JOIN sizes AS s    → combine each color with all three sizes
                                 Red   × (S, M, L) → 3 rows
                                 Blue  × (S, M, L) → 3 rows
                                 Green × (S, M, L) → 3 rows
                                 total: 3 × 3 = 9 rows
  3. SELECT c.color_name, s.size_name → project two columns
  4. ORDER BY c.color_id, s.size_id   → sort by color and size IDs
*/
Explanation (table transitions & key points)
SELECT c.color_name, s.size_name FROM colors AS c CROSS JOIN sizes AS s ORDER BY c.color_id, s.size_id;
LEGEND
Rows read / loaded
① FROM
FROM colors AS cRead the three colors as the left table. CROSS JOIN will combine each one with every row from the right table.
1 / 3
color_idcolor_name
1Red
2Blue
3Green
All 3 rows read
LEARNING POINTS
×AB
CROSS JOIN
Generate the Cartesian product of all left and right rows without ON.
A: colors(c) / B: sizes(s)
CROSS JOIN is an all-combinations generator: each left row pairs with every right row, so the result count is the product of the input counts. It creates new combinations rather than merely finding existing key matches.
The comma syntax is an implicit CROSS JOIN: FROM colors, sizes is the older SQL-89 form. Explicit CROSS JOIN communicates intent and avoids bugs caused by a forgotten WHERE join predicate.
ANTI-PATTERNS
CROSS JOIN on large tables: row counts multiply rapidly—10,000 × 10,000 creates 100 million rows. Restrict this pattern to small master sets such as colors, sizes, or weekdays.
Creating a Cartesian product by forgetting a join condition: some databases interpret a missing condition as a cross join; PostgreSQL rejects some such forms. When a multi-table query explodes in size, check every join condition first.
Field Notes
Useful CROSS JOIN cases include product variants, calendar matrices built from small series, and complete A/B-test combinations. It is powerful when the goal is to generate combinations from compact input sets.