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
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.
| emp_id | name | manager_id |
|---|---|---|
| 1 | Alice (CEO) | NULL |
| 2 | Bob | 1 |
| 3 | Carol | 1 |
| 4 | David | 2 |
| employee | manager |
|---|---|
| Bob | Alice (CEO) |
| Carol | Alice (CEO) |
| David | Bob |
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 */
LEGEND
① 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.| emp_id | name | manager_id |
|---|---|---|
| 1 | Alice (CEO) | NULL |
| 2 | Bob | 1 |
| 3 | Carol | 1 |
| 4 | David | 2 |
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.name are ambiguous because both table instances have them. Give both sides explicit aliases.e for employee and m for manager over arbitrary names such as a and b.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
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.
| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| order_id | user_id | status |
|---|---|---|
| 1 | 1 | shipped |
| 2 | 1 | pending |
| 3 | 2 | shipped |
| name | order_id | status |
|---|---|---|
| Alice | 1 | shipped |
| Bob | 3 | shipped |
| Carol | NULL | NULL |
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 */
LEGEND
① FROM
FROM users AS uRead all three users as the left table. LEFT JOIN guarantees that these three rows remain in the result.| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
WHERE o.order_id IS NULL specifically to retain unmatched rows. Choose ON for conditional matching and WHERE for intentional filtering after the join.WHERE o.status = 'shipped' removes Carol's NULL row, leaving only Alice and Bob—the same effective result as INNER JOIN.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;
IFNULL(expr, default), but COALESCE is standard SQL and works across PostgreSQL, MySQL, and SQLite. It is the more portable choice.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.
| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| user_id | bio | avatar_url |
|---|---|---|
| 1 | SQL specialist | /img/alice.png |
| 2 | Designer | /img/bob.png |
| name | bio | avatar_url |
|---|---|---|
| Alice | SQL specialist | /img/alice.png |
| Bob | Designer | /img/bob.png |
| Carol | Not set | /img/default.png |
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 */
LEGEND
① FROM
FROM users AS uRead all three users as the left table. LEFT JOIN preserves every user.| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
COALESCE(pr.bio, 'Not set') returns the real bio when present and the fallback otherwise. More fallbacks can be chained from left to right.'0'.WHERE COALESCE(col, 0) = 0 can prevent a normal index from being used. Prefer WHERE col IS NULL OR col = 0 when appropriate.COALESCE(SUM(amount), 0), and locale fallbacks such as COALESCE(t.en, t.default_label, 'N/A').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
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.Join users and orders, then retrieve the name and order count for users who have at least two orders. Sort by order count descending.
| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 4200 |
| 2 | 1 | 1800 |
| 3 | 2 | 3500 |
| 4 | 3 | 2000 |
| 5 | 3 | 900 |
| name | order_count |
|---|---|
| Alice | 2 |
| Carol | 2 |
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 */
LEGEND
① FROM
FROM users AS uRead the three users as the left table.| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
category = 'food' should normally run before GROUP BY. Filtering earlier reduces the number of rows to aggregate and communicates intent clearly.WHERE COUNT(o.order_id) >= 2 is invalid because WHERE is evaluated before groups and their counts exist.HAVING order_count >= 2 here. Repeat the aggregate expression or wrap the query in a subquery.HAVING COUNT(*) >= 2.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.
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.
| color_id | color_name |
|---|---|
| 1 | Red |
| 2 | Blue |
| 3 | Green |
| size_id | size_name |
|---|---|
| 1 | S |
| 2 | M |
| 3 | L |
| color_name | size_name |
|---|---|
| Red | S |
| Red | M |
| Red | L |
| Blue | S |
| Blue | M |
| Blue | L |
| Green | S |
| Green | M |
| Green | L |
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 */
LEGEND
① 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.| color_id | color_name |
|---|---|
| 1 | Red |
| 2 | Blue |
| 3 | Green |
FROM colors, sizes is the older SQL-89 form. Explicit CROSS JOIN communicates intent and avoids bugs caused by a forgotten WHERE join predicate.