SQL and querying skill 01 of 8

SQL joins

The SQL Joins skill makes Claude, Codex or another AI agent write joins that return the right rows at the right grain. It chooses the join type by intent, keeps each filter in the clause that preserves the rows you want, guards against fan-out and silent inner joins, and verifies the row count before handing the query over.

sql-joins/SKILL.md, version 1.0.0, about 2,800 words.

Download .mdSkill folder (.zip)

When to use this, and when not to

Use this skill when the task is to combine rows from two or more tables on a related key, or to fix a join that has gone wrong. The clearest triggers are a query that already contains JOIN, LEFT JOIN, ON or USING; a request to match one table to another; and a result whose row count, missing rows or totals look wrong after tables were combined. A join is also the right tool for finding records in one table that have no match in another, for comparing a table to itself, and for attaching reference data to a fact table.

Do not use it for a query that reads a single table, for stacking two result sets with UNION, EXCEPT or INTERSECT, for deciding how to nest subqueries or common table expressions (that is subqueries-and-ctes), or for per-row running totals and ranking (that is window-functions). It does not design schemas or foreign keys. It writes and repairs the join itself, and it leaves the business decision to a person.

Inputs the agent must have before starting

Ask for anything on this list that is missing. Do not assume cardinality, because the whole correctness of a join turns on it.

  1. The tables involved, their columns and the type of each join key. A join between a text key and an integer key, or between two different collations, will either error or match nothing.
  2. The join key or keys, and the relationship between the tables on those keys: one-to-one, one-to-many, or many-to-many. If the person does not know, treat it as many-to-many until proven otherwise and verify with a grouped count (step 2 below).
  3. The grain of the result they want, stated as "one row per ...". This single sentence decides whether a join has multiplied rows by accident.
  4. The SQL engine and version. Support for FULL OUTER JOIN, RIGHT JOIN, LATERAL, USING and update-with-join syntax differs by engine (see the appendix).
  5. Whether the join keys can contain NULL, and whether unmatched rows must be kept.
  6. The actual question the result feeds. "Total revenue per customer" and "list of customers and their orders" need different joins and different grains.

If the data is live and the join feeds an UPDATE, DELETE or MERGE, also confirm who approves the change. Default to a SELECT that previews the affected rows; never run a destructive statement without explicit human confirmation.

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as sql-joins/SKILL.md.

---
name: sql-joins
description: >
  Write, review and debug SQL joins that return the right rows at the right grain across PostgreSQL, MySQL, SQLite, SQL Server and other engines. Use it when an agent is asked to combine two or more tables, when a query uses JOIN, LEFT JOIN, INNER JOIN, OUTER JOIN, USING or ON, when a result has missing rows, duplicate rows or an inflated SUM or COUNT after a join, when someone asks to "join these tables", "match customers to orders", "find records with no match", "why are my totals doubled" or "why did my LEFT JOIN drop rows", and when writing an anti-join, self-join or many-to-many join. Do NOT use it for single-table queries with no join, for stacking rows with UNION or UNION EXCEPT INTERSECT set operators, for subquery or CTE structure (see subqueries-and-ctes), for running totals and per-partition ranking (see window-functions), or for schema and foreign-key design.
license: Free to use and adapt under CC BY 4.0, with credit to Afeez Adebayo. Link afeezadebayo.com, https://www.linkedin.com/in/afeez-adebayo/ and this skill's page at https://afeezadebayo.com/skills-md/data/sql-querying/sql-joins/.
---

# SQL joins

## When to use this, and when not to

Use this skill when the task is to combine rows from two or more tables on a related key, or to fix a join that has gone wrong. The clearest triggers are a query that already contains `JOIN`, `LEFT JOIN`, `ON` or `USING`; a request to match one table to another; and a result whose row count, missing rows or totals look wrong after tables were combined. A join is also the right tool for finding records in one table that have no match in another, for comparing a table to itself, and for attaching reference data to a fact table.

Do not use it for a query that reads a single table, for stacking two result sets with `UNION`, `EXCEPT` or `INTERSECT`, for deciding how to nest subqueries or common table expressions (that is `subqueries-and-ctes`), or for per-row running totals and ranking (that is `window-functions`). It does not design schemas or foreign keys. It writes and repairs the join itself, and it leaves the business decision to a person.

## Inputs the agent must have before starting

Ask for anything on this list that is missing. Do not assume cardinality, because the whole correctness of a join turns on it.

1. The tables involved, their columns and the type of each join key. A join between a text key and an integer key, or between two different collations, will either error or match nothing.
2. The join key or keys, and the relationship between the tables on those keys: one-to-one, one-to-many, or many-to-many. If the person does not know, treat it as many-to-many until proven otherwise and verify with a grouped count (step 2 below).
3. The grain of the result they want, stated as "one row per ...". This single sentence decides whether a join has multiplied rows by accident.
4. The SQL engine and version. Support for `FULL OUTER JOIN`, `RIGHT JOIN`, `LATERAL`, `USING` and update-with-join syntax differs by engine (see the appendix).
5. Whether the join keys can contain `NULL`, and whether unmatched rows must be kept.
6. The actual question the result feeds. "Total revenue per customer" and "list of customers and their orders" need different joins and different grains.

If the data is live and the join feeds an `UPDATE`, `DELETE` or `MERGE`, also confirm who approves the change. Default to a `SELECT` that previews the affected rows; never run a destructive statement without explicit human confirmation.

## The workflow, step by step

Follow these in order. Each step has a decision rule, not a suggestion.

### 1. Write down the target grain first

Before any SQL, write one sentence: "The result has one row per ___." Everything after this checks against that sentence. If you cannot state the grain, stop and ask.

### 2. Establish each table's grain and the join cardinality

For each table, confirm how many rows share a given join key:

```sql
SELECT join_key, COUNT(*) AS n
FROM the_table
GROUP BY join_key
HAVING COUNT(*) > 1
LIMIT 10;
```

If this returns rows, that table has many rows per key. Decision rule: if both sides return rows here, the join is many-to-many and will produce a cross product per key. If exactly one side does, the join is one-to-many and will multiply the "one" side. Only if neither side returns rows is the join one-to-one and safe to treat as a simple lookup.

### 3. Choose the join type by intent

| You need | Use | Note |
|---|---|---|
| Only rows that match in both tables | `INNER JOIN` | Unmatched rows on either side are dropped |
| Every row of the primary table, with matches where they exist | `LEFT JOIN` | Make the primary table the left table |
| Rows from both tables even when unmatched | `FULL OUTER JOIN` | Reconciliation; not in MySQL or old SQLite (see appendix) |
| Every combination on purpose | `CROSS JOIN` | A calendar times a category list, for example |

Decision rule: never use `RIGHT JOIN`. Rewrite it as a `LEFT JOIN` with the tables swapped, which reads in the same order as the result. A missing `ON` clause silently becomes a cross product, so every inner or outer join must carry a join condition.

### 4. Put join conditions in ON, row filters in WHERE

`ON` decides which rows match. `WHERE` decides which rows survive into the result, and it runs after the join has built its rows.

Decision rule for outer joins: a condition on the nullable (outer) side belongs in `ON` when it should only limit which rows match, and in `WHERE` when it should remove rows from the result. Put a filter on the right table of a `LEFT JOIN` in `WHERE` and you drop every unmatched left row, because its right-side columns are `NULL` and `NULL` fails the test. The query then returns an inner join while still reading as a left join. If you want all left rows kept, move that condition into `ON`.

### 5. Guard against fan-out before aggregating

When a one-to-many join feeds `SUM`, `COUNT` or `AVG`, the aggregate counts the "one" side once per matching "many" row. An order with three line items has its order total added three times.

Decision rule: if step 2 found a one-to-many or many-to-many relationship and the query aggregates, do one of these before trusting the number:

- Aggregate the many side down to the target grain in a subquery or CTE, then join the already-collapsed result.
- Use `WHERE EXISTS (...)` when you only need to know a match is present, not to add its values.
- Use `COUNT(DISTINCT key)` when you are counting the "one" side through a fanned-out join.

Then verify: the row count of the final result must equal the count of distinct keys at the target grain. If it is higher, fan-out is still in the query.

### 6. Write anti-joins NULL-safely

To find rows in A with no match in B, use one of:

```sql
-- Preferred: NULL-safe and reads as intent
SELECT a.*
FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);

-- Equivalent: the LEFT JOIN anti-join idiom
SELECT a.*
FROM a
LEFT JOIN b ON b.a_id = a.id
WHERE b.a_id IS NULL;
```

Decision rule: never write `NOT IN (SELECT col FROM b)` when `col` can be `NULL`. A single `NULL` in the subquery makes the whole `NOT IN` evaluate to unknown and the query returns zero rows, with no error. `NOT EXISTS` handles `NULL` correctly.

### 7. Join on the full key

Join on every column that defines the relationship. A join on part of a composite key fans out across the missing column. If two tables relate on `(tenant_id, user_id)`, joining on `user_id` alone mixes tenants.

### 8. Handle self-joins deliberately

Alias both copies of the table. For a hierarchy such as employee to manager, use a `LEFT JOIN` so the root row, whose parent key is `NULL`, is kept rather than dropped. For comparing rows within a group, add `a.id < b.id` to return each pair once instead of twice and to exclude a row paired with itself.

### 9. Make the join perform

Index the join key on at least the table being looked up, so the planner can use an index join rather than scanning. The planner chooses join order, and the number of orderings grows factorially with the number of tables, so join only the tables the question needs. Push selective filters as early as the logic allows.

### 10. Verify before handing over

Run `EXPLAIN` (or `EXPLAIN ANALYZE` where safe) and confirm the join uses an index where one exists. Check the final row count against the target grain from step 1. Test at least one unmatched case and one `NULL`-key case. For a destructive join, show the `SELECT` preview and its row count, and stop for confirmation.

## Worked example, end to end

Three tables: `customers` (one row per customer), `orders` (one row per order, many per customer), `order_items` (one row per line, many per order). The question: total revenue and order count per customer for 2026, including customers who placed no order.

A first attempt looks reasonable and is wrong twice over:

```sql
SELECT c.id, c.name,
       SUM(oi.line_total) AS revenue,
       COUNT(o.id)        AS orders
FROM customers c
LEFT JOIN orders o      ON o.customer_id = c.id
LEFT JOIN order_items oi ON oi.order_id   = o.id
WHERE o.created_at >= '2026-01-01'
GROUP BY c.id, c.name;
```

Two bugs. First, `WHERE o.created_at >= '2026-01-01'` sits on the outer side of the `LEFT JOIN`, so every customer with no 2026 order is dropped: the query silently became an inner join, and the "including customers who placed no order" requirement is lost. Second, `COUNT(o.id)` is inflated by the second join: each order is repeated once per line item, so a three-line order counts as three orders.

The corrected version collapses the many side first and filters in the right place:

```sql
WITH order_revenue AS (
    SELECT o.id, o.customer_id, o.created_at,
           SUM(oi.line_total) AS order_total
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    GROUP BY o.id, o.customer_id, o.created_at
)
SELECT c.id, c.name,
       COALESCE(SUM(orv.order_total), 0) AS revenue,
       COUNT(orv.id)                     AS orders
FROM customers c
LEFT JOIN order_revenue orv
       ON orv.customer_id = c.id
      AND orv.created_at >= '2026-01-01'
GROUP BY c.id, c.name;
```

The date filter moved into `ON`, so unmatched customers survive with `revenue = 0` and `orders = 0`. The line items are summed to one row per order inside the CTE, so `COUNT` and `SUM` now count orders once. Verify it: the result row count must equal `SELECT COUNT(*) FROM customers`, because the grain is one row per customer.

## Templates you can use verbatim

```sql
-- Lookup (one-to-one or many-to-one): attach reference data
SELECT f.*, d.name AS dim_name
FROM fact f
JOIN dim d ON d.id = f.dim_id;

-- Keep all primary rows, filter the matched side in ON
SELECT p.*, m.status
FROM primary p
LEFT JOIN match m
       ON m.p_id = p.id
      AND m.active = true;

-- Pre-aggregate the many side, then join (avoids fan-out)
WITH agg AS (
    SELECT parent_id, SUM(amount) AS total, COUNT(*) AS n
    FROM child
    GROUP BY parent_id
)
SELECT p.*, COALESCE(agg.total, 0) AS total, COALESCE(agg.n, 0) AS n
FROM parent p
LEFT JOIN agg ON agg.parent_id = p.id;

-- Anti-join: rows in A with no match in B
SELECT a.*
FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);

-- Self-join: hierarchy, keeping the root
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

-- FULL OUTER emulation for engines without it
SELECT a.key, a.val AS a_val, b.val AS b_val
FROM a LEFT JOIN b ON b.key = a.key
UNION ALL
SELECT b.key, NULL, b.val
FROM b WHERE NOT EXISTS (SELECT 1 FROM a WHERE a.key = b.key);
```

Prompts that should trigger this skill: "Join orders to customers and give me revenue per customer", "Why does my LEFT JOIN drop rows when I add a WHERE?", "Find users with no orders", "My totals doubled after I added the items table", "Compare each employee's salary to their manager's".

## Failure modes and anti-patterns

1. **A WHERE filter on the outer table turns a LEFT JOIN into an INNER JOIN.** Spot it when unmatched rows you expected are missing and the filtered column belongs to the right table. Fix: move the condition into `ON`, or decide you really wanted an inner join and say so.
2. **Fan-out inflates aggregates.** Spot it when a `SUM` or `COUNT` is a clean multiple of the right answer, or when the result has more rows than the target grain. Fix: pre-aggregate the many side before joining, or use `EXISTS` or `COUNT(DISTINCT ...)`.
3. **NOT IN with a NULL returns nothing.** Spot it when an anti-join query returns zero rows against data you know has non-matches. Fix: use `NOT EXISTS` or the `LEFT JOIN ... IS NULL` idiom.
4. **Duplicate rows from a partial or many-to-many key.** Spot it with the grouped-count check in step 2 and a higher-than-expected final row count. Fix: join on the full composite key, or collapse one side to the needed grain first.
5. **A missing ON clause or a comma join creates a cross product.** Spot it when the row count explodes to roughly the product of the two table sizes. Fix: add the explicit `ON` condition; in MySQL be aware that `JOIN` binds tighter than a comma, so mixing the two breaks the `ON` scope (see appendix).

## Quality bar

Run the output against this list before handing it over. Every item is checkable, not aspirational.

- The target grain is stated as one sentence, and the final row count matches it.
- The join type matches the stated intent, and no `RIGHT JOIN` is used.
- Join conditions are in `ON`; row filters are in `WHERE`; any outer-side condition that must keep unmatched rows is in `ON`.
- Every inner and outer join has an explicit `ON` (or `USING`) condition.
- Aggregates over a one-to-many join are protected against fan-out and verified by row count.
- Anti-joins use `NOT EXISTS` or the `IS NULL` idiom, never `NOT IN` on a nullable column.
- Composite relationships join on the full key.
- The join key is indexed on the looked-up table, and `EXPLAIN` confirms the plan.
- Any `UPDATE`, `DELETE` or `MERGE` that uses a join is shown first as a `SELECT` preview with a row count and held for human confirmation.

## Data handling and safety

Joins themselves read data, but the patterns here are often used to build `UPDATE`, `DELETE` and `MERGE` statements, which are irreversible. Default to a `SELECT` that lists the rows a destructive statement would touch, report the count, and require explicit human sign-off before running the change. Never paste real personal, health or financial values into a prompt, a commit message or a shared log; describe columns by name and type instead. Do not send table contents to a third-party service to "help write the join"; the schema and a few synthetic rows are enough.

## Appendix: engine-specific notes (verify before relying on these)

This appendix is dialect-specific and sits apart from the tool-agnostic workflow above.

- **FULL OUTER JOIN**: supported in PostgreSQL, SQL Server, Oracle and SQLite 3.39+ (2022). MySQL has never supported it; emulate with the `LEFT JOIN` plus `UNION ALL` anti-join template above.
- **RIGHT JOIN**: valid in PostgreSQL, MySQL and SQL Server. Rewrite as `LEFT JOIN` regardless, for readability.
- **USING vs ON**: `USING (col)` requires the column to exist in both tables and collapses it to one output column; `ON` keeps both. `NATURAL JOIN` joins on all same-named columns implicitly and is best avoided, because a later schema change can silently add or drop a join key.
- **Comma joins (MySQL and others)**: `JOIN` binds tighter than a comma, so `FROM t1, t2 JOIN t3 ON t1.a = t3.a` fails with "Unknown column 't1.a' in 'on clause'". Use explicit `JOIN` throughout, or parenthesise.
- **Update or delete with a join**: PostgreSQL uses `UPDATE t SET ... FROM other WHERE ...` and `DELETE FROM t USING other WHERE ...`. MySQL uses `UPDATE t JOIN other ON ... SET ...` and `DELETE t FROM t JOIN other ON ...`. SQLite uses `UPDATE t SET ... FROM other WHERE ...` (3.33+, 2020). SQL Server uses `UPDATE t SET ... FROM t JOIN other ON ...`. Preview with a `SELECT` first in every dialect.
- **LATERAL / CROSS APPLY**: PostgreSQL and MySQL 8.0.14+ support `LATERAL`; SQL Server uses `CROSS APPLY` and `OUTER APPLY`. These let a joined subquery reference the left row, which a plain join cannot.
- **Indexing for joins**: the engine may use nested-loop, hash or merge joins. A nested-loop join needs an index on the join key of the inner table; a hash join does not but needs memory. The planner decides; your job is to provide the index and check the plan.

## Sources

- PostgreSQL 18 documentation, "Table Expressions" and "SELECT" (joined tables, `ON`/`USING`, `LATERAL`). Verified 29 October 2026.
- MySQL 8.0 Reference Manual, "JOIN Clause" (`ON` vs `WHERE`, `NATURAL`/`USING`, comma-join precedence, NULL extension, anti-join idiom). Verified 29 October 2026.
- SQLite documentation, "SELECT" and release notes for `FULL OUTER JOIN` (3.39, 2022) and `UPDATE ... FROM` (3.33, 2020). Verified 29 October 2026.
- "Where SQL joins quietly go wrong", techinterview.org (WHERE-undoes-LEFT-JOIN, fan-out, NOT IN with NULL, self-joins). Verified 29 October 2026.
- Markus Winand, "Use The Index, Luke", joins chapter (nested-loop, hash and sort-merge joins; join order and permutations). Verified 29 October 2026.

The workflow, step by step

Follow these in order. Each step has a decision rule, not a suggestion.

1. Write down the target grain first

Before any SQL, write one sentence: "The result has one row per ___." Everything after this checks against that sentence. If you cannot state the grain, stop and ask.

2. Establish each table's grain and the join cardinality

For each table, confirm how many rows share a given join key:

SELECT join_key, COUNT(*) AS n
FROM the_table
GROUP BY join_key
HAVING COUNT(*) > 1
LIMIT 10;

If this returns rows, that table has many rows per key. Decision rule: if both sides return rows here, the join is many-to-many and will produce a cross product per key. If exactly one side does, the join is one-to-many and will multiply the "one" side. Only if neither side returns rows is the join one-to-one and safe to treat as a simple lookup.

3. Choose the join type by intent

You needUseNote
Only rows that match in both tablesINNER JOINUnmatched rows on either side are dropped
Every row of the primary table, with matches where they existLEFT JOINMake the primary table the left table
Rows from both tables even when unmatchedFULL OUTER JOINReconciliation; not in MySQL or old SQLite (see appendix)
Every combination on purposeCROSS JOINA calendar times a category list, for example

Decision rule: never use RIGHT JOIN. Rewrite it as a LEFT JOIN with the tables swapped, which reads in the same order as the result. A missing ON clause silently becomes a cross product, so every inner or outer join must carry a join condition.

4. Put join conditions in ON, row filters in WHERE

ON decides which rows match. WHERE decides which rows survive into the result, and it runs after the join has built its rows.

Decision rule for outer joins: a condition on the nullable (outer) side belongs in ON when it should only limit which rows match, and in WHERE when it should remove rows from the result. Put a filter on the right table of a LEFT JOIN in WHERE and you drop every unmatched left row, because its right-side columns are NULL and NULL fails the test. The query then returns an inner join while still reading as a left join. If you want all left rows kept, move that condition into ON.

5. Guard against fan-out before aggregating

When a one-to-many join feeds SUM, COUNT or AVG, the aggregate counts the "one" side once per matching "many" row. An order with three line items has its order total added three times.

Decision rule: if step 2 found a one-to-many or many-to-many relationship and the query aggregates, do one of these before trusting the number:

  • Aggregate the many side down to the target grain in a subquery or CTE, then join the already-collapsed result.
  • Use WHERE EXISTS (...) when you only need to know a match is present, not to add its values.
  • Use COUNT(DISTINCT key) when you are counting the "one" side through a fanned-out join.

Then verify: the row count of the final result must equal the count of distinct keys at the target grain. If it is higher, fan-out is still in the query.

6. Write anti-joins NULL-safely

To find rows in A with no match in B, use one of:

-- Preferred: NULL-safe and reads as intent
SELECT a.*
FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);

-- Equivalent: the LEFT JOIN anti-join idiom
SELECT a.*
FROM a
LEFT JOIN b ON b.a_id = a.id
WHERE b.a_id IS NULL;

Decision rule: never write NOT IN (SELECT col FROM b) when col can be NULL. A single NULL in the subquery makes the whole NOT IN evaluate to unknown and the query returns zero rows, with no error. NOT EXISTS handles NULL correctly.

7. Join on the full key

Join on every column that defines the relationship. A join on part of a composite key fans out across the missing column. If two tables relate on (tenant_id, user_id), joining on user_id alone mixes tenants.

8. Handle self-joins deliberately

Alias both copies of the table. For a hierarchy such as employee to manager, use a LEFT JOIN so the root row, whose parent key is NULL, is kept rather than dropped. For comparing rows within a group, add a.id < b.id to return each pair once instead of twice and to exclude a row paired with itself.

9. Make the join perform

Index the join key on at least the table being looked up, so the planner can use an index join rather than scanning. The planner chooses join order, and the number of orderings grows factorially with the number of tables, so join only the tables the question needs. Push selective filters as early as the logic allows.

10. Verify before handing over

Run EXPLAIN (or EXPLAIN ANALYZE where safe) and confirm the join uses an index where one exists. Check the final row count against the target grain from step 1. Test at least one unmatched case and one NULL-key case. For a destructive join, show the SELECT preview and its row count, and stop for confirmation.

Worked example, end to end

Three tables: customers (one row per customer), orders (one row per order, many per customer), order_items (one row per line, many per order). The question: total revenue and order count per customer for 2026, including customers who placed no order.

A first attempt looks reasonable and is wrong twice over:

SELECT c.id, c.name,
       SUM(oi.line_total) AS revenue,
       COUNT(o.id)        AS orders
FROM customers c
LEFT JOIN orders o      ON o.customer_id = c.id
LEFT JOIN order_items oi ON oi.order_id   = o.id
WHERE o.created_at >= '2026-01-01'
GROUP BY c.id, c.name;

Two bugs. First, WHERE o.created_at >= '2026-01-01' sits on the outer side of the LEFT JOIN, so every customer with no 2026 order is dropped: the query silently became an inner join, and the "including customers who placed no order" requirement is lost. Second, COUNT(o.id) is inflated by the second join: each order is repeated once per line item, so a three-line order counts as three orders.

The corrected version collapses the many side first and filters in the right place:

WITH order_revenue AS (
    SELECT o.id, o.customer_id, o.created_at,
           SUM(oi.line_total) AS order_total
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    GROUP BY o.id, o.customer_id, o.created_at
)
SELECT c.id, c.name,
       COALESCE(SUM(orv.order_total), 0) AS revenue,
       COUNT(orv.id)                     AS orders
FROM customers c
LEFT JOIN order_revenue orv
       ON orv.customer_id = c.id
      AND orv.created_at >= '2026-01-01'
GROUP BY c.id, c.name;

The date filter moved into ON, so unmatched customers survive with revenue = 0 and orders = 0. The line items are summed to one row per order inside the CTE, so COUNT and SUM now count orders once. Verify it: the result row count must equal SELECT COUNT(*) FROM customers, because the grain is one row per customer.

Templates you can use verbatim

-- Lookup (one-to-one or many-to-one): attach reference data
SELECT f.*, d.name AS dim_name
FROM fact f
JOIN dim d ON d.id = f.dim_id;

-- Keep all primary rows, filter the matched side in ON
SELECT p.*, m.status
FROM primary p
LEFT JOIN match m
       ON m.p_id = p.id
      AND m.active = true;

-- Pre-aggregate the many side, then join (avoids fan-out)
WITH agg AS (
    SELECT parent_id, SUM(amount) AS total, COUNT(*) AS n
    FROM child
    GROUP BY parent_id
)
SELECT p.*, COALESCE(agg.total, 0) AS total, COALESCE(agg.n, 0) AS n
FROM parent p
LEFT JOIN agg ON agg.parent_id = p.id;

-- Anti-join: rows in A with no match in B
SELECT a.*
FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);

-- Self-join: hierarchy, keeping the root
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

-- FULL OUTER emulation for engines without it
SELECT a.key, a.val AS a_val, b.val AS b_val
FROM a LEFT JOIN b ON b.key = a.key
UNION ALL
SELECT b.key, NULL, b.val
FROM b WHERE NOT EXISTS (SELECT 1 FROM a WHERE a.key = b.key);

Prompts that should trigger this skill: "Join orders to customers and give me revenue per customer", "Why does my LEFT JOIN drop rows when I add a WHERE?", "Find users with no orders", "My totals doubled after I added the items table", "Compare each employee's salary to their manager's".

Failure modes and anti-patterns

  1. A WHERE filter on the outer table turns a LEFT JOIN into an INNER JOIN. Spot it when unmatched rows you expected are missing and the filtered column belongs to the right table. Fix: move the condition into ON, or decide you really wanted an inner join and say so.
  2. Fan-out inflates aggregates. Spot it when a SUM or COUNT is a clean multiple of the right answer, or when the result has more rows than the target grain. Fix: pre-aggregate the many side before joining, or use EXISTS or COUNT(DISTINCT ...).
  3. NOT IN with a NULL returns nothing. Spot it when an anti-join query returns zero rows against data you know has non-matches. Fix: use NOT EXISTS or the LEFT JOIN ... IS NULL idiom.
  4. Duplicate rows from a partial or many-to-many key. Spot it with the grouped-count check in step 2 and a higher-than-expected final row count. Fix: join on the full composite key, or collapse one side to the needed grain first.
  5. A missing ON clause or a comma join creates a cross product. Spot it when the row count explodes to roughly the product of the two table sizes. Fix: add the explicit ON condition; in MySQL be aware that JOIN binds tighter than a comma, so mixing the two breaks the ON scope (see appendix).

Quality bar

Run the output against this list before handing it over. Every item is checkable, not aspirational.

  • The target grain is stated as one sentence, and the final row count matches it.
  • The join type matches the stated intent, and no RIGHT JOIN is used.
  • Join conditions are in ON; row filters are in WHERE; any outer-side condition that must keep unmatched rows is in ON.
  • Every inner and outer join has an explicit ON (or USING) condition.
  • Aggregates over a one-to-many join are protected against fan-out and verified by row count.
  • Anti-joins use NOT EXISTS or the IS NULL idiom, never NOT IN on a nullable column.
  • Composite relationships join on the full key.
  • The join key is indexed on the looked-up table, and EXPLAIN confirms the plan.
  • Any UPDATE, DELETE or MERGE that uses a join is shown first as a SELECT preview with a row count and held for human confirmation.

Data handling and safety

Joins themselves read data, but the patterns here are often used to build UPDATE, DELETE and MERGE statements, which are irreversible. Default to a SELECT that lists the rows a destructive statement would touch, report the count, and require explicit human sign-off before running the change. Never paste real personal, health or financial values into a prompt, a commit message or a shared log; describe columns by name and type instead. Do not send table contents to a third-party service to "help write the join"; the schema and a few synthetic rows are enough.

Appendix: engine-specific notes (verify before relying on these)

This appendix is dialect-specific and sits apart from the tool-agnostic workflow above.

  • FULL OUTER JOIN: supported in PostgreSQL, SQL Server, Oracle and SQLite 3.39+ (2022). MySQL has never supported it; emulate with the LEFT JOIN plus UNION ALL anti-join template above.
  • RIGHT JOIN: valid in PostgreSQL, MySQL and SQL Server. Rewrite as LEFT JOIN regardless, for readability.
  • USING vs ON: USING (col) requires the column to exist in both tables and collapses it to one output column; ON keeps both. NATURAL JOIN joins on all same-named columns implicitly and is best avoided, because a later schema change can silently add or drop a join key.
  • Comma joins (MySQL and others): JOIN binds tighter than a comma, so FROM t1, t2 JOIN t3 ON t1.a = t3.a fails with "Unknown column 't1.a' in 'on clause'". Use explicit JOIN throughout, or parenthesise.
  • Update or delete with a join: PostgreSQL uses UPDATE t SET ... FROM other WHERE ... and DELETE FROM t USING other WHERE .... MySQL uses UPDATE t JOIN other ON ... SET ... and DELETE t FROM t JOIN other ON .... SQLite uses UPDATE t SET ... FROM other WHERE ... (3.33+, 2020). SQL Server uses UPDATE t SET ... FROM t JOIN other ON .... Preview with a SELECT first in every dialect.
  • LATERAL / CROSS APPLY: PostgreSQL and MySQL 8.0.14+ support LATERAL; SQL Server uses CROSS APPLY and OUTER APPLY. These let a joined subquery reference the left row, which a plain join cannot.
  • Indexing for joins: the engine may use nested-loop, hash or merge joins. A nested-loop join needs an index on the join key of the inner table; a hash join does not but needs memory. The planner decides; your job is to provide the index and check the plan.

Sources

  • PostgreSQL 18 documentation, "Table Expressions" and "SELECT" (joined tables, ON/USING, LATERAL). Verified 29 October 2026.
  • MySQL 8.0 Reference Manual, "JOIN Clause" (ON vs WHERE, NATURAL/USING, comma-join precedence, NULL extension, anti-join idiom). Verified 29 October 2026.
  • SQLite documentation, "SELECT" and release notes for FULL OUTER JOIN (3.39, 2022) and UPDATE ... FROM (3.33, 2020). Verified 29 October 2026.
  • "Where SQL joins quietly go wrong", techinterview.org (WHERE-undoes-LEFT-JOIN, fan-out, NOT IN with NULL, self-joins). Verified 29 October 2026.
  • Markus Winand, "Use The Index, Luke", joins chapter (nested-loop, hash and sort-merge joins; join order and permutations). Verified 29 October 2026.

When to use this skill, and when not to

Use it when the task is to combine two or more tables on a related key, or to fix a join that has gone wrong: rows missing after a WHERE, totals doubled after adding a table, or an anti-join that returns nothing. It also covers self-joins, many-to-many joins and finding records with no match.

Do not use it for a single-table query, for stacking rows with UNION, for structuring subqueries and CTEs (that is subqueries-and-ctes), or for running totals and ranking (that is window-functions). It writes and repairs the join; a person makes the business decision.

Who it is for

Analysts, data and backend engineers, and anyone using an AI agent to write or review SQL against a relational database. It suits people who know roughly what they want from the data but need the join to be correct the first time, because a join that silently drops rows or doubles a total is the kind of bug that reaches a report before anyone notices.

What is inside

The skill carries a ten-step workflow with decision rules and thresholds, not vague advice. It tells the agent how to establish the grain, how to read join cardinality with a grouped count, when a filter belongs in ON rather than WHERE, how to pre-aggregate before summing to avoid fan-out, and how to write a NULL-safe anti-join. It includes a worked example with the two most common bugs and their fixes, copy-and-use templates, five failure modes with how to spot each, a quality bar the agent runs against its own output, and an engine appendix for PostgreSQL, MySQL, SQLite and SQL Server kept apart from the tool-agnostic core.

Example prompts

  • “Join orders to customers and give me revenue per customer.”
  • “Why does my LEFT JOIN drop rows when I add a WHERE?”
  • “Find users who have never placed an order.”
  • “My totals doubled after I added the line-items table.”

Example output

The skill returns a correct query with the reasoning stated, not just SQL. A shortened version:

Grain: one row per customer. The orders-to-items join is one-to-many, so SUM(line_total) would fan out; I pre-aggregate items to one row per order in a CTE first. The 2026 date filter moves into the ON clause so customers with no order are kept with revenue 0. Verify: the result has 8,412 rows, equal to COUNT(*) FROM customers.

What the agent needs from you

The tables and the type of each join key, how the tables relate on that key (one-to-one, one-to-many or many-to-many), what one row of the result should represent, and which SQL engine you run. If you cannot give the cardinality, the skill checks it with a grouped count rather than guessing, because that relationship decides whether the join multiplies rows.

How to install and use it

In the Claude apps, download the SKILL.md from this page, put it in a folder named sql-joins, and upload the folder as a skill in settings. In Claude Code, save it as ~/.claude/skills/sql-joins/SKILL.md, or inside a project under .claude/skills/. In Codex and other Agent Skills tools, drop it in the tool’s skills folder, often .agents/skills/. Then ask the agent to write or fix a join in plain language; the description triggers it.

How to use SQL joins step by step

  1. Install the skill as sql-joins/SKILL.md.
  2. Give the agent the tables, the join keys and the result grain.
  3. It establishes each table’s grain and the join cardinality with a grouped count.
  4. It chooses the join type by intent and puts each filter in ON or WHERE accordingly.
  5. It guards against fan-out before any SUM or COUNT, and writes anti-joins NULL-safely.
  6. It verifies the final row count against the grain and shows any destructive change as a SELECT preview first.

Worked example

Asked for total revenue and order count per customer for 2026, including customers with no order, the skill caught two bugs in the obvious query: a date filter in WHERE that silently dropped every customer without a 2026 order, turning the LEFT JOIN into an inner join, and a COUNT inflated by the line-items join so a three-line order counted as three. It moved the date filter into ON, pre-aggregated line items to one row per order in a CTE, then confirmed the result had one row per customer. No data was changed.

Situations this skill handles

SituationWhat the skill does
Rows vanish after adding a WHEREMoves an outer-side filter into ON so a LEFT JOIN stays a LEFT JOIN
Totals doubled or tripledPre-aggregates the many side before summing, then verifies the row count
Anti-join returns nothingReplaces NOT IN with NOT EXISTS so a NULL cannot wipe the result
Duplicate rows appearJoins on the full composite key instead of a partial one
The row count explodesAdds the missing ON condition a comma join or typo removed
A destructive joinShows the SELECT preview and row count before any UPDATE or DELETE

What makes this skill different

Most SQL skills give a table of join types and a few examples, then leave the hard part, choosing the right join and keeping the grain, to the reader. This one gives the agent explicit decision rules and a verification step: state the grain first, read cardinality with a grouped count, put each filter in the clause that keeps the rows you want, and check the final row count against the grain. It names the failure modes that reach production, such as a WHERE filter quietly turning a LEFT JOIN into an inner join, and it stays engine-agnostic with a separate appendix for dialect differences.

Common mistakes

Filtering the outer table of a LEFT JOIN in WHERE, summing across a one-to-many join without collapsing it first, using NOT IN against a nullable column, joining on a partial composite key, and leaving off an ON clause so the join becomes a cross product. The skill names each one, explains how to spot it, and says what to do instead.

Pair it with Subqueries and CTEs for pre-aggregating before a join and testing existence safely, and with Window functions when a per-row calculation fits better than a self-join. To profile the tables first, see Exploratory data analysis. Browse the full SQL and querying skills set in the Data category.

Frequently asked questions

A join combines rows from two or more tables by matching a related column, such as a customer id shared by a customers table and an orders table. The join type (inner, left, right or full) decides what happens to rows with no match. This skill helps an AI agent pick the right one and keep the result at the grain you need.

Almost always because a filter on the right table sits in WHERE instead of ON. The unmatched left rows hold NULL in the right-table columns, and the WHERE test removes them, so the LEFT JOIN quietly behaves like an inner join. Moving that condition into the ON clause keeps the unmatched rows.

That is fan-out. When you join a one-to-many relationship and then SUM, the one side is added once for every matching row on the many side. The fix is to aggregate the many side to the right grain first, then join, or to use EXISTS when you only need to know a match exists.

No. It writes and reviews SELECT queries by default. When a join feeds an UPDATE, DELETE or MERGE, the skill shows the rows the change would touch as a SELECT first and waits for explicit human confirmation before anything runs.

The workflow is engine-agnostic, so it holds for PostgreSQL, MySQL, SQLite and SQL Server. The SKILL.md ends with an appendix covering where they differ, such as FULL OUTER JOIN support and update-with-join syntax, kept apart from the core logic.

Use an anti-join: NOT EXISTS, or a LEFT JOIN with WHERE on the right key IS NULL. Avoid NOT IN against a column that can be NULL, because a single NULL makes it return zero rows with no error. The skill writes the NULL-safe version by default.

Changelog

  • 1.0.0, 29 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.