Joins & Set Operations
77 pages · ~170 min✓ Reviewed
Builds on SELECT & Filtering. Next up: GROUP BY & Aggregates.
Part 1 · Joins & Set Operations
Combining Tables Without Losing or Inventing Rows
Almost every real database splits its data across tables. Normalization keeps customers in one place and their orders in another so nothing is stored twice, and a join is how you put the pieces back together when you read. A join takes two tables in and gives one table out, which means its result can feed another join, a filter or an aggregate. Joins make a result wider by adding columns, while the set operators UNION, INTERSECT and EXCEPT make it longer by adding or removing rows.
The trouble is that a join can go wrong without any error. A forgotten condition returns billions of rows. A WHERE placed on the wrong table quietly turns a LEFT JOIN into an inner join. A one-to-many link multiplies a total, and a NULL key matches nothing, not even another NULL. In each case the query runs, the numbers look plausible, and they are wrong. This chapter is built around those cases, so you learn to expect them before they reach a report.
We use one small schema throughout: six employees and four departments, with one employee who belongs to no department and one department, 'Legal', that has nobody in it. Those two unmatched rows are the test case for every join type. By the end you will be able to pick the right join for a question, predict how many rows it returns, handle NULL correctly, read a query plan to see which algorithm the engine chose, and choose between a join and a set operator.
You need to be comfortable with SELECT, WHERE, GROUP BY and the idea of a primary key and a foreign key. Any engine works, such as PostgreSQL, MySQL, SQLite or SQL Server. A few features differ between engines, and the text says where. For each example, work out the row count in your head before you run it, because that habit is what catches silent errors.
Part 2 · Why Joins Exist: Normalization & the Relational Model
What a join actually is
A join is a binary operator: it takes exactly two relations (tables, views, or any query result) and produces exactly one relation. Each row of the output is a combination of one row from the left input and one row from the right input, and it only exists if that pair satisfies a predicate. Joining three tables is never one big operation. It is two binary joins, one after the other.
The theory behind this comes from relational algebra, which has three operators you need for joins. Everything a join does can be described with them, and the table below maps each one to the SQL you already know.
| Symbol | Name | SQL equivalent | What it does |
|---|---|---|---|
| × | Cross product | CROSS JOIN | Pairs every left row with every right row |
| σ | Selection | WHERE / ON | Keeps only the rows whose predicate is true |
| π | Projection | The SELECT list | Keeps only the columns you name |
Put them together and you get the definition of the most common join: INNER JOIN = σ_predicate(A × B). Nothing more. First imagine every possible pair, then discard the pairs that fail the predicate. Real engines never build the full cross product, because they use smarter algorithms, but the result is always exactly what that definition would give. The example below does it the slow, literal way and then lets SQL do it, so you can see both agree.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(""" CREATE TABLE customers (id INT PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id INT PRIMARY KEY, customer_id INT REFERENCES customers(id), amount INT); INSERT INTO customers VALUES (1,'Asha'),(2,'Ben'),(3,'Chen'); INSERT INTO orders VALUES (10,1,50),(11,1,20),(12,2,70); """) cross = db.execute( 'SELECT c.id, o.customer_id FROM customers c CROSS JOIN orders o' ).fetchall() print('cross product rows:', len(cross)) kept = [pair for pair in cross if pair[0] == pair[1]] print('after filter:', len(kept)) for row in db.execute(""" SELECT c.name, o.order_id, o.amount FROM customers c JOIN orders o ON o.customer_id = c.id ORDER BY o.order_id"""): print(row)
3 customers × 3 orders gives 9 pairs; the predicate keeps 3
cross product rows: 9 after filter: 3 ('Asha', 10, 50) ('Asha', 11, 20) ('Ben', 12, 70)
Chen has no orders, so no pair involving Chen survived the filter. Recovering rows like that one is exactly what the outer joins in later sections are for.
Why data is split, and why results nest
If joins are just filtered cross products, why not keep everything in one big table? Because of normalization: the practice of splitting data so each fact is stored once. Imagine a single flat orders table that repeats the customer's name and city on every row. If Asha moves, you must update every one of her order rows, and missing one leaves the data contradicting itself.
| order_id | customer | city | amount |
|---|---|---|---|
| 10 | Asha | Pune | 50 |
| 11 | Asha | Pune | 20 |
| 12 | Ben | Delhi | 70 |
| id | name | city |
|---|---|---|
| 1 | Asha | Pune |
| 2 | Ben | Delhi |
| order_id | customer_id | amount |
|---|---|---|
| 10 | 1 | 50 |
| 11 | 1 | 20 |
| 12 | 2 | 70 |
After the split, Asha's city lives in exactly one row, so there is one place to change it. The price is that a question like 'which city did order 11 ship to?' now spans two tables. Normalization splits data on write; a join puts it back together on read. That trade is the reason the join exists at all.
The second big idea is that every join result is itself a table. It has columns, rows, and no memory of how it was made. That closure property is why you can use a join result as the input to another join, wrap it in a subquery, or name it in a CTE (a named query defined with WITH) and treat it like any stored table.
WITH customer_orders AS ( -- join result, given a name SELECT c.id, c.name, o.amount FROM customers c JOIN orders o ON o.customer_id = c.id ) SELECT name, SUM(amount) AS spend -- queried like any table FROM customer_orders GROUP BY name;
The CTE is a join; the outer query neither knows nor cares
- 1customers JOIN ordersproduces relation R1
- 2R1 JOIN order_itemsproduces relation R2
- 3R2 JOIN productsproduces the final relation
Keys, conditions, and left versus right
A foreign key is a declared link in the schema: you tell the database that orders.customer_id must point at an existing customers.id. The database uses that declaration to reject bad data. A join condition is different. It is the query-time use of that link, where you write the predicate that says which rows belong together.
-- declared once, in the schema CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT REFERENCES customers(id), amount NUMERIC ); -- used every time, in the query SELECT c.name, o.amount FROM customers c JOIN orders o ON o.customer_id = c.id;
Declaring the key and joining on it are two separate acts
A foreign key is not required to join. The ON clause accepts any boolean expression over comparable values, so you can join on inequalities and computed values as easily as on matching ids. The database will not stop you, which is both the power and the danger of the feature.
-- range join: put each sale into a pricing band SELECT s.id, b.label FROM sales s JOIN bands b ON s.amount BETWEEN b.min_amt AND b.max_amt; -- computed join: match on an expression, not a stored key SELECT a.id, b.id FROM accounts a JOIN archive b ON LOWER(a.email) = LOWER(b.email);
No foreign key exists in either query
Now the vocabulary. The left table is the one written before the JOIN keyword, and the right table is the one written after it. In a chain, the 'left side' means everything accumulated so far. These names only change the result for outer joins, where one side is preserved. Inner and cross joins give the same rows whichever way round you write them.
| Join type | Does left/right order change the rows? | Why |
|---|---|---|
| INNER | No | Only matching pairs survive, and matching is symmetric |
| CROSS | No | Every pair appears either way |
| LEFT / RIGHT / FULL | Yes (for LEFT and RIGHT) | One side is preserved, so swapping sides changes which rows are kept |
Because no key is required, a join on the wrong column runs without any error and returns confident-looking nonsense. A schema with no declared keys still joins, and one with keys still lets you join on the wrong thing. Check that your ON columns mean the same thing on both sides.
Cardinality and the other axis: set operations
Before writing any join, name its cardinality: how many rows on one side can match one row on the other. There are four classes. Getting this wrong is the number one cause of row explosion, where a result is far bigger than either input and aggregates quietly turn into wrong numbers.
| Class | Meaning | Example | Output rows |
|---|---|---|---|
| 1:1 | One row matches at most one row | users to user_profiles | About the same as the inputs |
| 1:many | One parent has many children | customers to orders | One row per child |
| many:1 | Many children share one parent | orders to customers | One row per child, parent repeated |
| many:many | Both sides repeat the key | orders to addresses, both by customer | Matches multiply, per key |
The dangerous case is joining two independent 1:many children to the same parent. For a given customer, every order is paired with every address. Two orders and two addresses give four rows, and each order's amount is counted twice. The next example shows the total going wrong with no error and no warning.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(""" CREATE TABLE customers (id INT PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id INT PRIMARY KEY, customer_id INT, amount INT); CREATE TABLE addresses (addr_id INT PRIMARY KEY, customer_id INT, city TEXT); INSERT INTO customers VALUES (1,'Asha'),(2,'Ben'); INSERT INTO orders VALUES (10,1,50),(11,1,20),(12,2,70); INSERT INTO addresses VALUES (1,1,'Pune'),(2,1,'Goa'),(3,2,'Delhi'); """) print('true total:', db.execute('SELECT SUM(amount) FROM orders').fetchone()[0]) n, total = db.execute(""" SELECT COUNT(*), SUM(o.amount) FROM customers c JOIN orders o ON o.customer_id = c.id JOIN addresses a ON a.customer_id = c.id""").fetchone() print('joined rows:', n) print('joined total:', total)
Asha has 2 orders and 2 addresses, so her orders appear twice
true total: 140 joined rows: 5 joined total: 210
The total jumped from 140 to 210 and nothing complained. After every join you add, compare the row count with what you expected. If it grew when you did not expect it to, the newest join is many:many.
The last distinction is the direction in which data grows. A join works horizontally: it adds columns by bringing attributes from another table alongside each row. The set operators UNION, INTERSECT, and EXCEPT work vertically: they stack or compare whole rows from queries that have the same shape. Different axis, different tool.
| Joins | Set operations | |
|---|---|---|
| Direction | Horizontal: adds columns | Vertical: adds or removes rows |
| Inputs must have | Any shape, linked by a predicate | Same column count and compatible types |
| Can multiply rows? | Yes | No |
| Typical question | What are this order's customer details? | Which ids appear in both lists? |
-- join: wider rows SELECT c.name, o.amount FROM customers c JOIN orders o ON o.customer_id = c.id; -- set operation: more rows, same two columns SELECT id, name FROM users UNION ALL SELECT id, name FROM users_archive;
Same tables in spirit, opposite axes
Part 3 · The Running Example Schema
Four tables used in every example
Every join in this chapter runs against the same small database. Keeping the data fixed means each new join type can be judged against the same few numbers. The core is two tables, employees and departments. A second pair, customers and orders, is used whenever we need a one-to-many relationship.
employees holds 6 rows with the columns emp_id, name, dept_id, manager_id and salary. Two of its columns are allowed to be NULL on purpose. One employee has dept_id = NULL and belongs to no department. One employee has manager_id = NULL, which marks the CEO at the top of the reporting chain.
| emp_id | name | dept_id | manager_id | salary |
|---|---|---|---|---|
| 1 | Ava (CEO) | 10 | NULL | 200000 |
| 2 | Ben | 10 | 1 | 120000 |
| 3 | Chen | 20 | 1 | 90000 |
| 4 | Dina | 20 | 3 | 80000 |
| 5 | Eli | 30 | 1 | 85000 |
| 6 | Farah | NULL | 3 | 70000 |
departments holds 4 rows. Three of them have staff. The fourth, Legal, has nobody working in it.
| dept_id | dept_name | employees in it |
|---|---|---|
| 10 | Engineering | Ava, Ben |
| 20 | Sales | Chen, Dina |
| 30 | Marketing | Eli |
| 40 | Legal | nobody |
The many-side examples use customers(cust_id, name) and orders(order_id, customer_id, amount). Acme has two orders, Birch has one and Cobalt has none. One row on the customer side can therefore match several rows on the order side. That is exactly the situation where joins start to multiply rows.
| customers | orders (order_id, customer_id, amount) |
|---|---|
| 1 Acme | 101 (cust 1, 250), 102 (cust 1, 100) |
| 2 Birch | 103 (cust 2, 75) |
| 3 Cobalt | no orders |
The DDL and how the tables link
The key declaration is the employees table. It has two foreign keys. dept_id points to departments. manager_id points back into employees itself. A foreign key column that allows NULL is what lets an employee exist with no department, or with no manager.
dept_iddept_nameemp_idnamedept_idmanager_idsalarycust_idnameorder_idcustomer_idamount- departments — has — employees
- employees — manages — employees
- customers — places — orders
The script below builds the whole database in memory with Python's built-in sqlite3 module. The SQL inside is the real schema, including the CREATE TABLE employees statement. After loading the rows, it prints a row count for each table. Later examples in this section reuse the db connection created here.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE departments (dept_id INT PRIMARY KEY, dept_name TEXT); CREATE TABLE employees ( emp_id INT PRIMARY KEY, name TEXT, dept_id INT REFERENCES departments(dept_id), manager_id INT REFERENCES employees(emp_id), salary NUMERIC); CREATE TABLE customers (cust_id INT PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id INT PRIMARY KEY, customer_id INT, amount NUMERIC); INSERT INTO departments VALUES (10,'Engineering'),(20,'Sales'),(30,'Marketing'),(40,'Legal'); INSERT INTO employees VALUES (1,'Ava',10,NULL,200000),(2,'Ben',10,1,120000),(3,'Chen',20,1,90000), (4,'Dina',20,3,80000),(5,'Eli',30,1,85000),(6,'Farah',NULL,3,70000); INSERT INTO customers VALUES (1,'Acme'),(2,'Birch'),(3,'Cobalt'); INSERT INTO orders VALUES (101,1,250),(102,1,100),(103,2,75); """) for table in ("employees", "departments", "customers", "orders"): n = db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] print(table, n)
employees 6 departments 4 customers 3 orders 3
Because manager_id references emp_id in the same table, an employee row and its manager's row both live in employees. Joining the table to itself turns that link into a readable "employee, manager" pair. The CEO's NULL manager_id is the row that makes the choice of self-join type matter.
The asymmetry, the baseline and the method
The data is lopsided on purpose. Farah is an orphan employee: her dept_id is NULL, so no department claims her. Legal is a childless department: no employee points at it. Each join type treats these two rows differently. Inner joins drop both. A left join keeps Farah, a right join keeps Legal and a full join keeps both.
| row | partner in departments |
|---|---|
| Ava, Ben | Engineering |
| Chen, Dina | Sales |
| Eli | Marketing |
| Farah | none (dept_id NULL) |
| row | partner in employees |
|---|---|
| Engineering | Ava, Ben |
| Sales | Chen, Dina |
| Marketing | Eli |
| Legal | none |
Three numbers are worth memorizing, because every later result is compared with them. The unmatched rows are one on each side, which is why the totals below do not simply add up to a clean table.
| Baseline | Count |
|---|---|
| employees | 6 |
| departments | 4 |
| matched pairs | 5 |
| unmatched | 1 + 1 (Farah and Legal) |
The method used for the rest of the chapter is short. Run the INNER JOIN first, then ask which rows were lost. The loss is exactly what the outer joins recover. The next script does this with the db connection from before. It counts the inner-join pairs and then lists the rows on each side that found no partner.
matched = db.execute(""" SELECT e.name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id """).fetchall() print("matched pairs:", len(matched)) lost_emp = db.execute(""" SELECT name FROM employees e WHERE NOT EXISTS (SELECT 1 FROM departments d WHERE d.dept_id = e.dept_id) """).fetchall() lost_dept = db.execute(""" SELECT dept_name FROM departments d WHERE NOT EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id) """).fetchall() print("employees lost:", [r[0] for r in lost_emp]) print("departments lost:", [r[0] for r in lost_dept])
The inner join finds 5 pairs and loses one row on each side
matched pairs: 5 employees lost: ['Farah'] departments lost: ['Legal']
Keep these predictions in mind. Each join type in the coming sections should produce one of these counts on the employee and department tables.
| Join | Rows expected | Why |
|---|---|---|
| CROSS | 24 | 6 x 4, every pairing |
| INNER | 5 | matched pairs only |
| LEFT (employees first) | 6 | 5 pairs + Farah |
| RIGHT (employees first) | 6 | 5 pairs + Legal |
| FULL | 7 | 5 pairs + Farah + Legal |
A LEFT JOIN that returns 6 rows can look like proof that nothing went wrong. Always compare against the inner-join baseline of 5 and ask what the extra row is. If the answer is not "Farah, with a NULL department name", a join is matching more than you intended.
Part 4 · CROSS JOIN: The Cartesian Product
Every row paired with every row
A CROSS JOIN takes two tables and pairs each row on the left with each row on the right. It never asks whether two rows belong together, so it has no ON clause at all, and writing one is a syntax error. The result is the Cartesian product of the two tables: every possible combination, nothing filtered out.
In the running schema there are 6 employees and 4 departments. Crossing them gives each employee a row for every department, including 'Legal', which has nobody in it, and including the employee who has no department. The pairing is purely mechanical, so the result says nothing about who actually works where. The example below builds a small copy of the schema in SQLite and counts the rows.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE departments (dept_id INT, dept_name TEXT); INSERT INTO departments VALUES (1,'Engineering'),(2,'Sales'),(3,'Marketing'),(4,'Legal'); CREATE TABLE employees (emp_id INT, name TEXT, dept_id INT); INSERT INTO employees VALUES (1,'Asha',1),(2,'Ben',1),(3,'Chen',2),(4,'Dara',2),(5,'Eli',3),(6,'Fay',NULL); ''') rows = db.execute(''' SELECT e.name, d.dept_name FROM employees e CROSS JOIN departments d ORDER BY e.emp_id, d.dept_id ''').fetchall() print(len(rows)) for r in rows[:5]: print(r)
SELECT * FROM employees CROSS JOIN departments gives 6 x 4 rows
24 ('Asha', 'Engineering') ('Asha', 'Sales') ('Asha', 'Marketing') ('Asha', 'Legal') ('Ben', 'Engineering')
Asha appears four times, once per department, then Ben starts the next block of four. Fay, whose dept_id is NULL, appears four times as well, because a cross join never compares keys and so NULL never gets in the way.
The row count is known before you run it
The output size is exactly |A| × |B|, the left row count times the right row count. This makes CROSS JOIN the only join whose cardinality you can state without looking at the data. For an inner join the count depends on which keys match, and for a left join it is at least the left size but otherwise unknown. For a cross join, two row counts give you the answer.
| Left rows | Right rows | CROSS JOIN rows |
|---|---|---|
| 6 | 4 | 24 |
| 3 sizes | 5 colors | 15 variants |
| 365 days | 40 stores | 14,600 |
| 100,000 | 100,000 | 10,000,000,000 |
If you can multiply the two table sizes in your head, you know exactly how many rows a CROSS JOIN returns. If you cannot afford that product, you cannot afford the query.
The comma, and the cross product behind every join
Older SQL has no CROSS JOIN keyword in the FROM clause. You list the tables separated by a comma, and the engine treats that as a cross join. Because the comma looks like ordinary list punctuation, it is easy to miss. Adding a WHERE condition that links the two tables filters the product down to the matching pairs, which is exactly what an inner join does.
comma = db.execute('SELECT COUNT(*) FROM employees e, departments d').fetchone()[0] filtered = db.execute(''' SELECT e.name, d.dept_name FROM employees e, departments d WHERE e.dept_id = d.dept_id ORDER BY e.emp_id''').fetchall() inner = db.execute(''' SELECT e.name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id ORDER BY e.emp_id''').fetchall() print(comma, len(filtered), filtered == inner)
Reuses the db built above
24 5 True
The bare comma gives 24 rows, the comma plus a WHERE gives 5, and the result is identical to the explicit INNER JOIN. Fay and 'Legal' vanish because neither has a partner that passes the filter. The two spellings return the same rows, but the explicit one tells the reader which part is the pairing and which part is a filter.
Every join is a cross product plus a filter
This relationship is how joins are defined, and it goes beyond the comma syntax. Conceptually, any join starts by forming the full product of its inputs. The ON condition then keeps only the pairs where it is true. An outer join finishes by adding back the unmatched rows from the preserved side, padded with NULLs. Database engines never actually build the full product for a normal join, because they use hash, merge or indexed nested-loop algorithms to find the matches directly. The meaning of the query is still defined as if the product had been built.
- 1Cross productevery left row with every right row, |A| x |B|
- 2ON filterkeep pairs where the condition is TRUE
- 3Preserve sidesouter joins only: re-add unmatched rows with NULL padding
- 4Resultwhat the query returns
CROSS JOIN is the first step with the other two steps removed. That is why it needs no ON clause: it has nothing to filter and nothing to preserve.
When a cross product is the right tool
Most of the time you want pairs that belong together, and a cross join is a mistake. Sometimes the combinations themselves are the thing you want to generate. Three uses come up repeatedly.
| Use | Left side | Right side | What you get |
|---|---|---|---|
| Report grid | calendar days | stores | one row per day per store, even with no sales |
| Product variants | sizes | colors | every size and color combination for a catalog |
| Scaffold before a LEFT JOIN | departments | months | a full grid to attach real numbers onto |
The third use is the most common in reporting. Real data has gaps: a department with no sales in February simply has no February row, and a report built straight from the sales table leaves that month out. The usual fix is to build a dense scaffold of every department and month pair with a CROSS JOIN, then LEFT JOIN the real figures onto it. Pairs with no sales stay in the result, and you can fill their totals with COALESCE.
db.executescript(''' CREATE TABLE months (n INT, month TEXT); INSERT INTO months VALUES (1,'Jan'),(2,'Feb'); CREATE TABLE sales (dept_id INT, month TEXT, total INT); INSERT INTO sales VALUES (1,'Jan',100),(1,'Feb',150),(2,'Jan',80); ''') rows = db.execute(''' SELECT d.dept_name, m.month, COALESCE(s.total, 0) FROM departments d CROSS JOIN months m LEFT JOIN sales s ON s.dept_id = d.dept_id AND s.month = m.month ORDER BY d.dept_id, m.n''').fetchall() print(len(rows), 'rows') for r in rows: print(*r)
Dense report scaffold: 4 departments x 2 months = 8 rows
8 rows Engineering Jan 100 Engineering Feb 150 Sales Jan 80 Sales Feb 0 Marketing Jan 0 Marketing Feb 0 Legal Jan 0 Legal Feb 0
The sales table has only three rows, yet the report has all eight department and month combinations. The ON clause of the LEFT JOIN must match on both dept_id and month. If it matched on only one of them, each scaffold row would pick up several sales rows and the totals would be wrong.
Build the scaffold from the dimensions you want to see (days, stores, departments), then LEFT JOIN the facts onto it. The grid decides which rows exist, and the data only fills in the numbers.
LATERAL is not a plain CROSS JOIN, and the accidental product
A plain CROSS JOIN evaluates both sides independently, so the right side cannot refer to columns of the left. CROSS JOIN LATERAL in PostgreSQL and MySQL, or CROSS APPLY in SQL Server, changes this. The right side becomes a correlated subquery that is evaluated once for each left row and can use that row's values. It still returns pairs, but only the pairs the subquery produces for that row. It is the standard tool for the top-N-per-group problem, where a window function would be heavier than needed.
SELECT d.dept_name, top.name, top.salary FROM departments d CROSS JOIN LATERAL ( SELECT e.name, e.salary FROM employees e WHERE e.dept_id = d.dept_id ORDER BY e.salary DESC LIMIT 3 ) AS top;
Top 3 earners per department. Not runnable in SQLite.
For each department, the subquery looks only at that department's employees, sorts them by salary and keeps three. A department that returns no rows from the subquery, such as 'Legal', disappears from the result, because crossing with an empty set gives an empty set. To keep such departments, use LEFT JOIN LATERAL (...) AS top ON TRUE.
| Plain CROSS JOIN | CROSS JOIN LATERAL / CROSS APPLY | |
|---|---|---|
| Right side sees left columns | No | Yes |
| Right side evaluated | Once | Once per left row |
| Row count | Exactly |A| x |B| | Sum of what each subquery returns |
| Typical use | Grids and variants | Top N per group |
The accidental cross join
The costly version of this join is the one you did not mean to write. Forget the ON clause or the WHERE link in a comma-style query and the engine does exactly what it was told: it returns every combination. Two tables of 100,000 rows each would produce ten billion rows, enough to exhaust memory or disk and stall the server. The accidental query is written the same way as an intentional one, so nothing in the text tells a reviewer which it is.
FROM orders o, customers c with the WHERE condition lost during an edit, or a JOIN whose ON clause was deleted, returns |A| x |B| rows. Check the row count when a query suddenly becomes slow or its totals multiply.
Write the keywords CROSS JOIN instead of a bare comma whenever the product is intentional. A reviewer who sees them knows it was deliberate, and a reviewer who sees a comma with no matching WHERE knows to ask.
Part 5 · INNER JOIN: Matched Rows Only
What INNER JOIN keeps
An INNER JOIN looks at every pair made from one row on the left and one row on the right, and evaluates the ON predicate for that pair. The pair appears in the output only when the predicate is TRUE. If the predicate is FALSE the pair is dropped, and if it is UNKNOWN (which happens whenever a NULL takes part in a comparison) the pair is dropped too.
| ON predicate result | Pair kept? | Typical cause |
|---|---|---|
| TRUE | Yes | Both keys present and equal |
| FALSE | No | Both keys present but different |
| UNKNOWN | No | One or both keys are NULL |
The running schema has 6 employees and 4 departments. One employee, Fay, has a NULL dept_id, and the Legal department has no employees. The query below joins them on the department key. Five employees have a department that exists, so five rows come back.
import sqlite3 con = sqlite3.connect(':memory:') con.executescript(""" CREATE TABLE departments (dept_id INT PRIMARY KEY, dept_name TEXT); CREATE TABLE employees (emp_id INT PRIMARY KEY, name TEXT, dept_id INT, manager_id INT); INSERT INTO departments VALUES (10,'Engineering'),(20,'Sales'),(30,'Support'),(40,'Legal'); INSERT INTO employees VALUES (1,'Asha',10,NULL),(2,'Ben',10,1),(3,'Chen',20,1), (4,'Dara',20,3),(5,'Eli',30,1),(6,'Fay',NULL,1); """) rows = con.execute(""" SELECT e.name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id ORDER BY e.emp_id """).fetchall() for name, dept in rows: print(name, dept) print(len(rows), 'rows')
Inner join of the running example
Asha Engineering
Ben Engineering
Chen Sales
Dara Sales
Eli Support
5 rowsTwo rows vanish without any warning. Fay never appears, because NULL = anything is UNKNOWN and no department can satisfy it. Legal never appears, because no employee row has dept_id = 40. An inner join returns only the intersection of what the two sides can prove they share, and it says nothing about what was left out.
An inner join never reports the rows it discarded. If a report says 'all employees' but is built on an INNER JOIN, the NULL-department employee is silently missing. Count the input table and compare it with the output before you trust the result.
Keyword, ordering, and row counts
The word INNER is optional. A bare JOIN means an inner join, so JOIN and INNER JOIN are the same operation. Writing INNER only documents your intent for the reader. Many teams write it because it makes the contrast with LEFT JOIN obvious in a long query.
An inner join is also commutative and associative. A JOIN B produces the same rows as B JOIN A, and (A JOIN B) JOIN C produces the same rows as A JOIN (B JOIN C). The optimizer therefore reorders any chain of inner joins however it likes, usually starting from the most selective table. The order you write in FROM is for readers and does not choose the plan. Only the column order of SELECT * can change.
def q(sql): return sorted(con.execute(sql).fetchall()) a = q('SELECT e.name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id') b = q('SELECT e.name, d.dept_name FROM employees e JOIN departments d ON e.dept_id = d.dept_id') c = q('SELECT e.name, d.dept_name FROM departments d JOIN employees e ON e.dept_id = d.dept_id') print(len(a), a == b, a == c)
Dropping INNER and swapping the tables changes nothing
5 True True
The number of rows an inner join returns depends on the data, not on the table sizes alone. It can be smaller than both inputs, equal to one of them, or much larger than either.
| Outcome | When it happens | Example |
|---|---|---|
| Fewer rows | Many rows have no partner | 6 employees and 4 departments give 5 rows |
| Equal rows | Every row on one side matches exactly once | Orders joined to their customer, one customer per order |
| More rows | A key matches several rows on the other side | One customer joined to 10 orders gives 10 rows |
In a one-to-many join, each row on the one side is repeated once per matching row on the many side. The customer's columns are copied onto every one of that customer's orders. When duplicate keys exist on both sides, the effect compounds: for any one key value, the output holds every combination, so 3 matching left rows times 2 matching right rows gives 6 rows.
con.executescript(""" CREATE TABLE l (k INT, v TEXT); CREATE TABLE r (k INT, w TEXT); INSERT INTO l VALUES (1,'a1'),(1,'a2'),(1,'a3'),(2,'a4'); INSERT INTO r VALUES (1,'b1'),(1,'b2'),(3,'b3'); """) rows = con.execute('SELECT l.v, r.w FROM l JOIN r ON l.k = r.k ORDER BY l.v, r.w').fetchall() print(len(rows), 'rows from 4 left and 3 right') for v, w in rows: print(v, w)
Key 1 appears 3 times on the left and 2 times on the right
6 rows from 4 left and 3 right a1 b1 a1 b2 a2 b1 a2 b2 a3 b1 a3 b2
Keys 2 and 3 have no partner, so they contribute nothing. Key 1 alone produces 6 rows, more than either input has for that key. This is why SUM over a many-to-many join returns totals that are exact multiples of the truth.
Range joins and composite keys
An inner join does not need an equality. The ON clause accepts any boolean expression, and a pair is kept whenever that expression is TRUE. A band join (or range join) uses an inequality, most often BETWEEN, to place a value in a bucket. Here each sale is matched to the pricing band whose range contains its amount.
con.executescript(""" CREATE TABLE sales (sale_id INT, amount INT); CREATE TABLE bands (band TEXT, min_amt INT, max_amt INT); INSERT INTO sales VALUES (1,50),(2,250),(3,900),(4,5000); INSERT INTO bands VALUES ('small',0,99),('medium',100,999),('large',1000,9999); """) for row in con.execute(""" SELECT s.sale_id, s.amount, b.band FROM sales s JOIN bands b ON s.amount BETWEEN b.min_amt AND b.max_amt ORDER BY s.sale_id """): print(*row)
Band join: an inner join with an inequality predicate
1 50 small 2 250 medium 3 900 medium 4 5000 large
If the bands overlapped, a sale could fall into two of them and would appear twice, which is the multiplication rule again. If a sale fell into no band, it would vanish. A band join is only as safe as the ranges you store.
The other common shape is a multi-column join. When a table's identity is a composite key, the join must compare every column of that key. In a multi-tenant system the user id is unique only inside one tenant, so ON a.tenant_id = b.tenant_id AND a.user_id = b.user_id is the complete key. The next example drops the tenant condition and compares the result with the full key.
con.executescript(""" CREATE TABLE users (tenant_id INT, user_id INT, name TEXT); CREATE TABLE settings (tenant_id INT, user_id INT, plan TEXT); INSERT INTO users VALUES (1,1,'Ann'),(1,2,'Bo'),(2,1,'Cy'); INSERT INTO settings VALUES (1,1,'pro'),(2,1,'free'),(1,2,'free'); """) wrong = 'SELECT u.name, s.plan FROM users u JOIN settings s ON u.user_id = s.user_id ORDER BY u.name, s.plan' right = '''SELECT u.name, s.plan FROM users u JOIN settings s ON u.tenant_id = s.tenant_id AND u.user_id = s.user_id ORDER BY u.name, s.plan''' print('missing tenant column:', con.execute(wrong).fetchall()) print('full key:', con.execute(right).fetchall())
Two tenants both have a user_id of 1
missing tenant column: [('Ann', 'free'), ('Ann', 'pro'), ('Bo', 'free'), ('Cy', 'free'), ('Cy', 'pro')] full key: [('Ann', 'pro'), ('Bo', 'free'), ('Cy', 'free')]
With the tenant column missing, Ann is paired with tenant 2's settings and Cy with tenant 1's. Five rows come back instead of three. The many-to-many row multiplication described under 'Keyword, ordering, and row counts' is at work here, but the extra rows are also another customer's data.
Leaving one column out of a multi-column join is a data leak, not a slow query. The query runs, returns plausible rows, and mixes tenants. Review every join against the full key of the table it joins to.
When INNER JOIN is the right choice
Pick an inner join when a missing match means the row is irrelevant to the question you are asking. 'Which employees work in which department?' has no use for an employee without a department. 'Total sales per pricing band' has no use for a sale that falls in no band. In both cases the lost rows are not a loss, because they were never part of the answer.
An inner join keeps a pair only when ON is TRUE, so NULL keys and unmatched rows disappear. The count can shrink, stay equal or grow, depending on how many matches each key has. Join on the full key, and count rows after the join to confirm it did what you meant.
Part 6 · LEFT OUTER JOIN: Keep Everything on the Left
Every left row survives
An inner join throws away any row that finds no partner. A LEFT JOIN refuses to. Every row of the table written before the JOIN keyword, the left table, appears in the result at least once. When a left row has no match on the right, the engine still emits it and fills every right-table column with NULL. This is called padding.
The sample data has six employees and four departments. Fay belongs to no department (her dept_id is NULL), and Legal has no employees. Run the query below and watch what happens to Fay. The show helper is reused by later examples, so the setup lives in this first block.
import sqlite3 con = sqlite3.connect(':memory:') con.execute('CREATE TABLE departments (dept_id INT PRIMARY KEY, dept_name TEXT)') con.execute('CREATE TABLE employees (emp_id INT PRIMARY KEY, name TEXT, dept_id INT)') con.executemany('INSERT INTO departments VALUES (?, ?)', [(10, 'Engineering'), (20, 'Sales'), (30, 'Marketing'), (40, 'Legal')]) con.executemany('INSERT INTO employees VALUES (?, ?, ?)', [(1, 'Asha', 10), (2, 'Ben', 10), (3, 'Chen', 20), (4, 'Dana', 20), (5, 'Eli', 30), (6, 'Fay', None)]) def show(sql): rows = con.execute(sql).fetchall() for row in rows: print(row) print('rows:', len(rows)) show('SELECT e.name, d.dept_name ' 'FROM employees e LEFT JOIN departments d ' 'ON e.dept_id = d.dept_id ORDER BY e.emp_id')
SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id
('Asha', 'Engineering') ('Ben', 'Engineering') ('Chen', 'Sales') ('Dana', 'Sales') ('Eli', 'Marketing') ('Fay', None) rows: 6
Six employees in, six rows out, and exactly one of them carries dept_name = NULL. An inner join would have returned five rows and silently lost Fay. Legal does not appear at all, because it sits on the right side and nothing preserves it.
LEFT JOIN and LEFT OUTER JOIN are the same operation. The word OUTER is optional and most people leave it off, so treat either spelling as identical when you read other people's SQL.
Row counts: at least the left, not exactly the left
A LEFT JOIN gives you one firm guarantee: the output has at least as many rows as the left table. It equals the left row count only when every left row matches at most once. The catch is that preserving all left rows is not the same as preserving the left row count. A left row with three matches shows up three times, exactly as in an inner join.
| Matches for a left row | Rows it contributes | Right columns |
|---|---|---|
| 0 | 1 | All NULL (padded) |
| 1 | 1 | Real values |
| k, where k is 2 or more | k | Real values, left columns repeated |
So the output size is the sum over all left rows of max(1, matches). Below, Ana has two orders, Bo has one, and Cy has none. Three customers go in and four rows come out.
con.execute('CREATE TABLE customers (cust_id INT PRIMARY KEY, name TEXT)') con.execute('CREATE TABLE orders (order_id INT PRIMARY KEY, cust_id INT, amount INT)') con.executemany('INSERT INTO customers VALUES (?, ?)', [(1, 'Ana'), (2, 'Bo'), (3, 'Cy')]) con.executemany('INSERT INTO orders VALUES (?, ?, ?)', [(101, 1, 50), (102, 1, 30), (103, 2, 20)]) show('SELECT c.name, o.order_id, o.amount ' 'FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id ' 'ORDER BY c.cust_id, o.order_id')
('Ana', 101, 50) ('Ana', 102, 30) ('Bo', 103, 20) ('Cy', None, None) rows: 4
Joining a customer to their orders and then summing a customer-level column, such as a credit limit, counts it once per order. If the report must have one row per left row, aggregate the right side first or confirm the match is one-to-one.
Padded NULLs and aggregates
The NULL that padding puts in d.dept_name is indistinguishable from a NULL that was actually stored there. The result set carries no flag saying a value was invented. To tell a real match from padding, test a right-table column that can never be NULL when a match exists, usually its primary key.
| Expression | What it counts or returns | Cy (no orders) |
|---|---|---|
| COUNT(*) | Every output row, padded ones included | 1 |
| COUNT(o.order_id) | Only rows where a real order matched | 0 |
| SUM(o.amount) | Total of matched amounts; NULL if nothing matched | NULL |
| COALESCE(SUM(o.amount), 0) | Same total, with 0 when the group has no matches | 0 |
COUNT(column) skips NULL values, which is why it counts matches only. SUM ignores NULL inputs too, but when every input is NULL it returns NULL rather than 0. A report that shows blank cells where it should show zero spend is this trap. Wrap the sum in COALESCE.
show('SELECT c.name, COUNT(*) AS count_star, COUNT(o.order_id) AS n_orders, ' 'SUM(o.amount) AS raw_sum, COALESCE(SUM(o.amount), 0) AS spend ' 'FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id ' 'GROUP BY c.cust_id ORDER BY c.cust_id')
('Ana', 2, 2, 80, 80) ('Bo', 1, 1, 20, 20) ('Cy', 1, 0, None, 0) rows: 3
Cy has zero orders, yet COUNT(*) reports 1 because the padded row is still a row. Use COUNT(o.order_id) to count orders, and COALESCE(SUM(...), 0) when a missing total should read as zero.
Order matters, finding gaps, and when to reach for it
LEFT JOIN is not commutative. A LEFT JOIN B preserves A, while B LEFT JOIN A preserves B. Swapping the tables changes which side is protected and therefore which unmatched rows survive. Here the employee and department tables trade places.
show('SELECT d.dept_name, e.name ' 'FROM departments d LEFT JOIN employees e ON e.dept_id = d.dept_id ' 'ORDER BY d.dept_id, e.emp_id')
departments is now the left table
('Engineering', 'Asha') ('Engineering', 'Ben') ('Sales', 'Chen') ('Sales', 'Dana') ('Marketing', 'Eli') ('Legal', None) rows: 6
The count is again six, but only by coincidence. Fay is gone, and the empty Legal department has taken her place. Compare the contents, not just the totals.
The same padding gives you the anti-join idiom. Add WHERE right.key IS NULL after a LEFT JOIN and only the left rows with no partner remain, because only those rows were padded. Filter on the right table's primary key, which is never NULL in a real match.
show('SELECT c.name ' 'FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id ' 'WHERE o.order_id IS NULL')
customers who have never ordered
('Cy',) rows: 1
LEFT JOIN keeps every left row, pads missing right columns with NULL, and can still multiply a left row when the right side matches more than once. Count with a right-side key, wrap sums in COALESCE, and pick the left table deliberately, because swapping the sides changes the answer.
Part 7 · RIGHT OUTER JOIN and Why It Is Rare
What RIGHT JOIN does
A RIGHT OUTER JOIN is the mirror image of the LEFT JOIN you just met. Every row of the table written after the JOIN keyword survives. When a right row has no partner on the left, the engine still emits it and fills every left-table column with NULL. (OUTER is optional, so RIGHT JOIN and RIGHT OUTER JOIN mean the same thing.)
In our running schema, the right-hand table is departments, and 'Legal' is the department nobody belongs to. An INNER JOIN would drop it. A RIGHT JOIN keeps it and pads the employee side with NULL.
SELECT e.name, d.dept_name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.dept_id;
| name | dept_name |
|---|---|
| Asha | Engineering |
| Ben | Engineering |
| Chen | Sales |
| Dara | Marketing |
| Eli | Marketing |
| NULL | Legal |
6 rows: 5 matched pairs plus the empty department
Notice what is missing: the employee whose dept_id is NULL. That employee sits on the left, has no match, and the left side is not the preserved one here, so they disappear. The row count is 6 because there are 5 matched pairs and 1 unmatched right row. As with LEFT JOIN, 'preserves every right row' does not mean 'exactly one output row per right row': a department with three employees still produces three rows.
| LEFT JOIN | RIGHT JOIN | |
|---|---|---|
| Preserved table | The one before JOIN | The one after JOIN |
| Padded with NULL | Right-table columns | Left-table columns |
| Orphan employee (NULL dept_id) | Kept, dept_name = NULL | Dropped |
| Empty department 'Legal' | Dropped | Kept, name = NULL |
| Output rows on the sample data | 6 | 6 |
The exact equivalence and the style rule
RIGHT JOIN adds no expressive power. For any predicate p, A RIGHT JOIN B ON p produces exactly the same rows as B LEFT JOIN A ON p. The only visible difference appears with SELECT *, where the columns come out in the order the tables are written, so the two forms list their columns in opposite order. If you name your columns explicitly, as you should, the results are indistinguishable.
-- these two return the same 6 rows SELECT e.name, d.dept_name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.dept_id; SELECT e.name, d.dept_name FROM departments d LEFT JOIN employees e ON e.dept_id = d.dept_id;
You can check the claim without a database. The sketch below implements both joins as plain loops over the sample data, then compares them. The right join walks the right table and pads the left side. The left join is run with the tables swapped, and its pairs are flipped back.
emps = [('Asha', 10), ('Ben', 10), ('Chen', 20), ('Dara', 30), ('Eli', 30), ('Fay', None)] depts = [(10, 'Engineering'), (20, 'Sales'), (30, 'Marketing'), (40, 'Legal')] def right_join(left, right, on): out = [] for r in right: hits = [l for l in left if on(l, r)] out += [(l, r) for l in hits] or [(None, r)] return out def left_join(left, right, on): out = [] for l in left: hits = [r for r in right if on(l, r)] out += [(l, r) for r in hits] or [(l, None)] return out match = lambda e, d: e[1] == d[0] via_right = right_join(emps, depts, match) via_left = [(e, d) for d, e in left_join(depts, emps, lambda d, e: match(e, d))] print(len(via_right), len(via_left), via_right == via_left) print(via_right[-1])
6 6 True (None, (40, 'Legal'))
Because the two forms are interchangeable, the question becomes one of readability, and readability is why most teams ban RIGHT JOIN in their style guides. A query is read top to bottom, and the table in FROM is naturally taken to be the subject of the report. A LEFT JOIN confirms that expectation: the first table is the one that is kept. A RIGHT JOIN contradicts it, and the reader must notice the keyword and mentally flip the query before they know which rows are guaranteed to appear.
Skimming a query that starts FROM employees e and assuming every employee appears. With a RIGHT JOIN further down, the orphan employee is dropped and only the right table is guaranteed. Reviewers should read the join keyword before trusting the FROM table.
Chains, mixing, and the few good uses
The confusion grows with every table you add. Joins are evaluated left to right, so in A JOIN B JOIN C the 'left side' of the second join is not just B. It is the entire result accumulated so far, (A JOIN B). A RIGHT JOIN in position three therefore preserves table C while quietly making the whole earlier join the optional, NULL-padded side.
- 1employees RIGHT JOIN departmentskeeps every department
- 2result RIGHT JOIN officesleft side is now the whole intermediate result
- 3keeps every officeemployee and department columns may all be NULL
Rewriting such a chain means reversing it so the preserved table comes first. Write the table you want to keep in FROM, then attach everything else with LEFT JOINs, in the order each table depends on the one before it.
-- RIGHT chain: keeps every office SELECT o.office_name, d.dept_name, e.name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.dept_id RIGHT JOIN offices o ON d.office_id = o.office_id; -- LEFT chain: same rows, preserved table first SELECT o.office_name, d.dept_name, e.name FROM offices o LEFT JOIN departments d ON d.office_id = o.office_id LEFT JOIN employees e ON e.dept_id = d.dept_id;
Mixing both directions in one query is a stronger warning sign still. If one join keeps the left side and another keeps the right, the author may really have wanted both sides kept, which is what a FULL OUTER JOIN does, and the next section covers it. Treat a LEFT-and-RIGHT mixture as a prompt to ask whether the question is really about unmatched rows on both sides.
There is one legitimate niche. Generated SQL, such as ORM output or a query assembled by a reporting tool, often arrives with a FROM clause you are not allowed to reorder. If you need to append one more table whose rows must all survive, a RIGHT JOIN at the end lets you do that without touching the generated prefix. Outside that case, prefer LEFT.
| Engine | RIGHT JOIN support |
|---|---|
| MySQL | Yes |
| PostgreSQL | Yes |
| SQL Server | Yes |
| Oracle | Yes |
| SQLite before 3.39 | No |
| SQLite 3.39 (2022) and later | Added |
Portability is therefore a weak argument in either direction today, but it is one more reason that LEFT JOIN, which every engine has always had, is the safer default.
To convert a RIGHT JOIN chain: pick the table that was preserved, move it to FROM, turn every join into a LEFT JOIN in dependency order, then compare. Run both versions with COUNT(*) and confirm the counts match exactly. For a stronger check, run (old EXCEPT new) UNION ALL (new EXCEPT old) with explicit, identical column lists. An empty result means the rewrite is faithful.
A query with both LEFT JOIN and RIGHT JOIN usually means the author wanted every row from two tables. Replace the pair with one FULL OUTER JOIN, and COALESCE the key, since either side may be the padded one.
Part 8 · FULL OUTER JOIN: Keep Both Sides
Matched pairs plus both kinds of orphans
LEFT JOIN rescues the unmatched rows of one table and RIGHT JOIN rescues the other's. FULL OUTER JOIN rescues both at once. The result is built from three groups of rows: every matched pair, then every unmatched left row padded with NULL in the right-hand columns, then every unmatched right row padded with NULL in the left-hand columns. Nothing from either input is thrown away.
The sample data was built with one asymmetry on each side, and this is where both of them show up. Fay belongs to no department, and Legal holds nobody. An inner join loses both, a LEFT join recovers only Fay, a RIGHT join recovers only Legal, and the full join keeps them together.
| Group | Comes from | Padded with NULL | Rows on the sample data |
|---|---|---|---|
| Matched pairs | Both tables | Nothing | 5 (Asha, Ben, Chen, Dara, Eli) |
| Unmatched left | employees only | All department columns | 1 (Fay, no department) |
| Unmatched right | departments only | All employee columns | 1 (Legal, no employees) |
| Total | 5 + 1 + 1 = 7 |
SELECT e.name, d.dept_name FROM employees e FULL OUTER JOIN departments d ON e.dept_id = d.dept_id; -- 7 rows: 5 matched, (Fay, NULL), (NULL, Legal)
Standard syntax, as PostgreSQL, SQL Server and Oracle accept it. OUTER is optional: FULL JOIN means the same thing.
The row count is not fixed by the table sizes alone. A full join always returns at least as many rows as the larger input, because every row of both tables appears at least once. If one key matches several rows on the other side, those pairs multiply exactly as they do in an inner join, so the total can go above 7 on other data.
Swapping the tables
Swap the two tables in a LEFT join and you get a different result, because the preserved side changes. Swap them in a FULL join and the row content stays identical: the same pairs, the same orphans, the same padding. Only the order of the columns in a SELECT * flips, since the table written first now supplies the first columns. That makes FULL the one outer join where it genuinely does not matter which table you wrote first.
| Swap the two tables | Same rows? | Why |
|---|---|---|
| A LEFT JOIN B vs B LEFT JOIN A | No | A different table is preserved |
| A LEFT JOIN B vs B RIGHT JOIN A | Yes | Mirror images of each other |
| A FULL JOIN B vs B FULL JOIN A | Yes | Both sides are preserved already |
FULL JOIN is the union of what LEFT JOIN keeps and what RIGHT JOIN keeps. On the sample data that is 5 matched pairs, 1 orphan employee and 1 empty department, which is 7 rows.
Telling the rows apart: CASE and COALESCE
A full join hands you one flat list, and the three groups look alike except for where the NULLs sit. Almost every real use starts by labelling each row with the group it belongs to. The label comes from asking which side failed to supply a row: if the left side is all NULL, the row exists only on the right, and the other way around.
In SQL this is a single CASE expression, and it is the core of any reconciliation report: CASE WHEN a.k IS NULL THEN 'only in B' WHEN b.k IS NULL THEN 'only in A' ELSE 'both' END. The order of the branches matters. A row cannot be NULL on both sides, so the first two tests never collide, and anything that survives them is a real match.
Fay's dept_id is NULL in the table itself, so e.dept_id IS NULL cannot tell a padded row from a stored NULL, and Fay would be labelled 'only in departments' by mistake. Test the primary key of each side instead, such as e.emp_id and d.dept_id. A stored primary key is never NULL, so a NULL there always means the row was padded.
The second problem is the join key itself. For a matched row both sides carry the key. For an orphan only one side does, and it may be either one, so SELECT e.dept_id shows NULL for every department-only row and SELECT d.dept_id shows NULL for every employee-only row. Neither column is safe on its own. Use COALESCE(a.key, b.key) AS key, which takes whichever side actually has a value.
SELECT COALESCE(e.dept_id, d.dept_id) AS k, CASE WHEN e.emp_id IS NULL THEN 'only in departments' WHEN d.dept_id IS NULL THEN 'only in employees' ELSE 'both' END AS side, e.name, d.dept_name FROM employees e FULL OUTER JOIN departments d ON e.dept_id = d.dept_id;
One merged key column and one label column. Fay's merged key stays NULL because her employee row has no department at all.
Once rows carry a label, the report is easy to finish. Count rows per label for a summary, filter on the label to list the orphans, or look inside the both rows and compare the remaining columns to find records that exist on both sides but disagree.
When there is no FULL JOIN: the MySQL emulation
MySQL has no FULL OUTER JOIN. The keyword is a syntax error there, and older SQLite versions (before 3.39) lack it too. You build it from the two outer joins you do have. The result is the union of the left-preserving half and the right-preserving half, and the only care needed is the matched rows, which both halves produce.
(SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id) UNION (SELECT e.name, d.dept_name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.dept_id);
Simple version. UNION, not UNION ALL, removes the 5 matched rows that both halves return.
The matched pairs show up once in each half, so plain UNION ALL would report them twice. UNION removes the doubles by comparing whole rows. That works here, but it has two costs. The engine must sort or hash the entire combined result to find duplicates. Worse, it cannot tell a double from a genuine duplicate: two different employees who share the same projected values come out as one row. A reconciliation report that silently loses rows is the worst kind of bug.
The safer form avoids duplicates by construction. Take the whole left join, then add only the right-side rows that found no partner, which is an anti-join, and stack them with UNION ALL. No comparison work is done, and no real row is ever collapsed.
- 1A LEFT JOIN Ball matched pairs, plus unmatched left rows
- 2B LEFT JOIN A, keep only A.key IS NULLjust the unmatched right rows
- 3UNION ALLstack them, nothing overlaps, nothing collapses
SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id UNION ALL SELECT e.name, d.dept_name FROM departments d LEFT JOIN employees e ON e.dept_id = d.dept_id WHERE e.emp_id IS NULL;
The WHERE here belongs to the anti-join half and is correct. Test a primary key, not the join key.
| LEFT UNION RIGHT | LEFT UNION ALL anti-join | |
|---|---|---|
| Matched rows | Appear twice, then deduplicated | Appear once by construction |
| Genuine duplicate rows | Collapsed into one | Kept |
| Extra work | Sort or hash of the whole result | None |
| Join runs | Both halves in full | One full, one anti-join |
The runnable example below uses the safer form, so it works on any SQLite as well as in MySQL. It is wrapped in a view named full_join so the later examples can reuse it. The view also keeps both departments keys and the employee primary key, for the labelling and filtering shown afterwards.
import sqlite3 con = sqlite3.connect(':memory:') con.executescript(''' CREATE TABLE departments (dept_id INTEGER PRIMARY KEY, dept_name TEXT); CREATE TABLE employees (emp_id INTEGER PRIMARY KEY, name TEXT, dept_id INTEGER); INSERT INTO departments VALUES (10, 'Engineering'), (20, 'Sales'), (30, 'HR'), (40, 'Legal'); INSERT INTO employees VALUES (1, 'Asha', 10), (2, 'Ben', 10), (3, 'Chen', 20), (4, 'Dara', 20), (5, 'Eli', 30), (6, 'Fay', NULL); CREATE VIEW full_join AS SELECT e.emp_id, e.name, e.dept_id AS e_dept, d.dept_id AS d_dept, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id UNION ALL SELECT e.emp_id, e.name, e.dept_id, d.dept_id, d.dept_name FROM departments d LEFT JOIN employees e ON e.dept_id = d.dept_id WHERE e.emp_id IS NULL; ''') rows = con.execute('''SELECT name, dept_name FROM full_join ORDER BY COALESCE(e_dept, d_dept, 99), emp_id''').fetchall() for r in rows: print(r) print('rows:', len(rows))
('Asha', 'Engineering') ('Ben', 'Engineering') ('Chen', 'Sales') ('Dara', 'Sales') ('Eli', 'HR') (None, 'Legal') ('Fay', None) rows: 7
Seven rows, as predicted: five pairs, then Legal with no employee, then Fay with no department. Now the labelling from the previous page can run on the same view.
for r in con.execute(''' SELECT COALESCE(e_dept, d_dept) AS k, CASE WHEN emp_id IS NULL THEN 'only in departments' WHEN d_dept IS NULL THEN 'only in employees' ELSE 'both' END AS side, name, dept_name FROM full_join ORDER BY COALESCE(e_dept, d_dept, 99), emp_id'''): print(r)
(10, 'both', 'Asha', 'Engineering') (10, 'both', 'Ben', 'Engineering') (20, 'both', 'Chen', 'Sales') (20, 'both', 'Dara', 'Sales') (30, 'both', 'Eli', 'HR') (40, 'only in departments', None, 'Legal') (None, 'only in employees', 'Fay', None)
Where it earns its keep, and what to watch
A full join is the natural tool whenever two collections should describe the same set of things and you need to see every disagreement. Neither side is the subject, so neither side may lose rows. That is why it sits behind most reconciliation work.
| Use case | Left side | Right side | 'Only in' rows tell you |
|---|---|---|---|
| Data migration check | Source table | Target table | Rows not migrated, or created by mistake |
| Comparing two snapshots | Yesterday's export | Today's export | What was deleted and what was added |
| Drift between systems | System of record | Downstream copy | Records one system lost or invented |
The both rows deserve a second look in every one of these. Matching on the key only proves that the record exists on both sides, so compare the remaining columns of those rows to catch values that changed on the way.
A WHERE clause undoes the fullness
Padded rows have NULL in all the columns of the missing side. A WHERE filter runs after the join and compares those NULLs, and a comparison with NULL is never true, so the padded rows are discarded. Filtering on a column of either table therefore throws away one group of orphans, or both, and the query quietly turns into an inner-ish join. The query still runs and returns plausible rows, so nothing warns you.
-- Drops every orphan: padded rows fail the comparison SELECT * FROM employees e FULL JOIN departments d ON e.dept_id = d.dept_id WHERE d.dept_name = 'Sales'; -- Filter one side first, then join; the other side's rows survive as orphans SELECT * FROM employees e FULL JOIN (SELECT * FROM departments WHERE dept_name = 'Sales') d ON e.dept_id = d.dept_id;
Putting the condition in the ON clause also stops it from deleting rows, but it changes what counts as a match rather than what is shown.
The same effect shows on the sample data. Applying the filter to the finished result keeps only the two Sales employees. Filtering the department side before the join keeps every employee, with a department only where the Sales row matched.
where_rows = con.execute(
"SELECT name, dept_name FROM full_join WHERE dept_name = 'Sales'").fetchall()
SALES = "(SELECT * FROM departments WHERE dept_name = 'Sales')"
sub_rows = con.execute(f'''
SELECT e.name, s.dept_name FROM employees e
LEFT JOIN {SALES} s ON e.dept_id = s.dept_id
UNION ALL
SELECT e.name, s.dept_name FROM {SALES} s
LEFT JOIN employees e ON e.dept_id = s.dept_id WHERE e.emp_id IS NULL
ORDER BY name''').fetchall()
print('WHERE:', len(where_rows))
print('subquery:', len(sub_rows))
for r in sub_rows:
print(r)WHERE: 2 subquery: 6 ('Asha', None) ('Ben', None) ('Chen', 'Sales') ('Dara', 'Sales') ('Eli', None) ('Fay', None)
If a FULL JOIN returns only matched rows after you add a WHERE, the filter is the cause. Move the condition into a subquery on the side it belongs to, or into the ON clause, and keep a plain IS NULL test only for the deliberate case of listing orphans.
Why the engine avoids a nested loop
A nested-loop join walks the outer table and, for each row, searches the inner one. That is enough to find unmatched outer rows, but an inner row is visited many times and no step is ready to say 'this one never matched anything'. Because of that, FULL OUTER JOIN usually cannot use a nested-loop plan. Engines pick a hash join or a merge join instead, and PostgreSQL even refuses a full join whose condition is neither hash-joinable nor merge-joinable. One consequence is that a full join on an inequality or a function-wrapped condition may fail or run slowly.
- 1Buildhash one input and give every entry a matched flag
- 2Probestream the other input; matches emit pairs and set the flag
- 3Emit unmatcheda probe row with no match is padded and emitted at once
- 4Sweepevery build entry whose flag is still off is padded and emitted
Part 9 · SELF JOIN: One Table, Two Roles
The same table, wearing two hats
A self join is any join where both sides are the same table. SQL has no special keyword for it. You write an ordinary JOIN and name the table twice. The trick is that each mention plays a different role: in an employee table, one copy is "the employee" and the other is "that employee's manager".
Because both copies have identical column names, aliases are mandatory. Without e and m, a reference to name or emp_id is ambiguous and the engine refuses to run the query. The alias is also how you tell the reader which role a column belongs to.
- 1employees AS ethe employee role
- 2ON e.manager_id = m.emp_idchild points at parent
- 3employees AS mthe manager role
The classic use is a manager lookup. Each employee row stores only a manager_id, which is a foreign key back into the same table. To turn that number into a name, you join the table to itself and match the employee's manager_id against the manager copy's emp_id.
The program below builds a small version of the running schema (six employees, one in no department, one CEO with no manager) and runs the lookup. It also counts what an INNER JOIN would have returned.
import sqlite3 con = sqlite3.connect(':memory:') con.executescript(''' CREATE TABLE employees (emp_id INT PRIMARY KEY, name TEXT, dept_id INT, manager_id INT); INSERT INTO employees VALUES (1,'Asha',10,NULL),(2,'Ben',10,1),(3,'Chen',20,1), (4,'Dara',10,2),(5,'Esi',20,3),(6,'Farid',NULL,2); ''') sql = '''SELECT e.name, m.name FROM employees e {kind} JOIN employees m ON e.manager_id = m.emp_id ORDER BY e.emp_id''' for emp, mgr in con.execute(sql.format(kind='LEFT')): print(f"{emp:<6} -> {mgr or 'NULL'}") inner = con.execute(sql.format(kind='INNER')).fetchall() print('inner rows:', len(inner), '- the CEO is gone')
Manager lookup with LEFT JOIN, compared with INNER
Asha -> NULL
Ben -> Asha
Chen -> Asha
Dara -> Ben
Esi -> Chen
Farid -> Ben
inner rows: 5 - the CEO is goneThe word that matters here is LEFT. The CEO's manager_id is NULL, and a NULL key never matches anything in a join predicate. An INNER JOIN therefore discards Asha silently, leaving five rows instead of six. A LEFT JOIN keeps every employee and pads the manager column with NULL where no manager exists.
Writing a plain JOIN for the manager lookup drops the top of the tree. The query runs, the numbers look plausible, and the CEO is simply missing from the report. Use LEFT JOIN whenever the root of the hierarchy has to appear.
Generating pairs without duplicates
The second classic self join builds pairs: every two colleagues in the same department, for a pairing exercise or a duplicate check. Joining a table to itself on dept_id alone gives every combination, including each person paired with themselves and each pair listed in both orders. The extra condition a.emp_id < b.emp_id fixes both problems at once.
SELECT a.name, b.name FROM employees a JOIN employees b ON a.dept_id = b.dept_id AND a.emp_id < b.emp_id;
Each unordered pair exactly once
Why does < work? For any two different people x and y, exactly one of x.emp_id < y.emp_id and y.emp_id < x.emp_id is true, so only one ordering survives. For a person paired with themselves, x.emp_id < x.emp_id is false, so self-pairs vanish.
The two near-miss operators fail in opposite ways. Using <> instead of < yields each pair twice, once as (x, y) and once as (y, x). Using = yields every row paired with itself and nothing else. The program below runs all three against the same data.
base = '''SELECT a.name, b.name FROM employees a JOIN employees b ON a.dept_id = b.dept_id AND a.emp_id {op} b.emp_id ORDER BY a.emp_id, b.emp_id''' for op in ('<', '<>', '='): rows = con.execute(base.format(op=op)).fetchall() print(f'{op:<2} {len(rows)} rows, e.g. {rows[:2]}')
Same join, three operators on emp_id
< 4 rows, e.g. [('Asha', 'Ben'), ('Asha', 'Dara')] <> 8 rows, e.g. [('Asha', 'Ben'), ('Asha', 'Dara')] = 5 rows, e.g. [('Asha', 'Asha'), ('Ben', 'Ben')]
| Operator on emp_id | Self-pairs? | Mirrored pairs? | Rows here | Verdict |
|---|---|---|---|---|
| a.emp_id < b.emp_id | No | No | 4 | Each unordered pair once |
| a.emp_id <> b.emp_id | No | Yes, both (x,y) and (y,x) | 8 | Every pair twice |
| a.emp_id = b.emp_id | Yes, only these | n/a | 5 | Every row matched to itself |
Notice that Farid never appears in any of the three results. His dept_id is NULL, and the a.dept_id = b.dept_id part of the predicate never matches NULL, even against himself. That is why the = variant returns five rows rather than six.
<> does remove self-pairs, so the output looks right at a glance, but every pair now appears twice. Counts, sums and "number of duplicate pairs" are all doubled. Use < whenever the pair is unordered.
Comparing with the previous row
A self join can also compare a row with its neighbour. Suppose daily holds one value per day and you want the change since the previous day. Join the table to itself so each row t meets the row p whose day is one earlier, then subtract. This was the standard idiom before window functions existed.
SELECT t.day, t.value - p.value AS delta FROM daily t LEFT JOIN daily p ON p.day = t.day - 1;
Delta with a self join
The LEFT JOIN matters again: the first day has no predecessor, and with an inner join it would disappear. The join also depends on days being consecutive. If a day is missing, the row after the gap finds no partner and gets a NULL delta.
The modern alternative is LAG() (or LEAD() for the next row). It reads the neighbouring row directly in sorted order, so it needs no second copy of the table, no join condition and no assumption that days are consecutive. The program below runs both on data with a gap at day 4.
con.executescript(''' CREATE TABLE daily (day INT, value INT); INSERT INTO daily VALUES (1,100),(2,110),(3,105),(5,130); ''') join_sql = '''SELECT t.day, t.value - p.value FROM daily t LEFT JOIN daily p ON p.day = t.day - 1 ORDER BY t.day''' lag_sql = '''SELECT day, value - LAG(value) OVER (ORDER BY day) FROM daily ORDER BY day''' print('self join:', con.execute(join_sql).fetchall()) print('LAG :', con.execute(lag_sql).fetchall())
Day 4 is missing from the data
self join: [(1, None), (2, 10), (3, -5), (5, None)] LAG : [(1, None), (2, 10), (3, -5), (5, 25)]
The two disagree on day 5. The self join asked for "exactly yesterday" and found nothing, while LAG asked for "the previous row that exists" and found day 3. Neither is wrong, but you must decide which question you mean. If you want strict calendar adjacency with gaps shown as NULL, the join says so explicitly. For "previous available row", LAG is shorter and clearer.
| Self join on t.day - 1 | LAG() / LEAD() | |
|---|---|---|
| Table reads | Two (or a shared scan) | One pass, sorted |
| Missing day | NULL delta | Uses the previous existing row |
| Needs consecutive keys | Yes | No |
| Typical speed | Slower on large tables | Usually faster |
| Best for | Exact-offset matching | Most adjacent-row logic |
For adjacent-row logic such as deltas, running comparisons and next-event lookups, start with LAG() or LEAD(). Keep the self join for cases where the relationship is a real key match, such as manager to employee or a fixed offset.
Depth, cost and the SELECT * trap
A hierarchy stored in one table is walked one level per join. To list an employee, their manager and their manager's manager, you chain two self joins with three aliases. Each extra level adds another join, and every one of them must be a LEFT JOIN or the shallower chains drop out.
SELECT e.name, m1.name AS boss, m2.name AS boss_of_boss FROM employees e LEFT JOIN employees m1 ON e.manager_id = m1.emp_id LEFT JOIN employees m2 ON m1.manager_id = m2.emp_id;
Two levels up needs two joins
This stops working when you do not know the depth in advance. N levels need N joins, and the query text must change whenever the org chart gets deeper. For arbitrary depth, use a recursive CTE (WITH RECURSIVE): it starts from the root and repeatedly joins the rows found so far back to the table.
cte = '''WITH RECURSIVE chain(emp_id, name, depth) AS ( SELECT emp_id, name, 0 FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.name, c.depth + 1 FROM employees e JOIN chain c ON e.manager_id = c.emp_id) SELECT name, depth FROM chain ORDER BY depth, name''' for name, depth in con.execute(cte): print(' ' * depth + name)
Any depth, one query
Asha
Ben
Chen
Dara
Esi
FaridCost is the other thing to watch. A self join reads the same table twice, so on a large table that can mean two full scans plus the join work itself. Only a plan that shares one scan between both sides avoids this, and you cannot count on the optimizer doing so. An index on the join key (manager_id or the lookup column) keeps each side cheap, and a window function sidesteps the problem for adjacent-row cases.
The last gotcha is cosmetic but nasty. SELECT * on a self join returns every column of both copies, so the result has duplicate column names. The program below shows the header of such a query.
cur = con.execute('''SELECT * FROM employees e JOIN employees m ON e.manager_id = m.emp_id''') print([d[0] for d in cur.description])
Duplicate names in the result header
['emp_id', 'name', 'dept_id', 'manager_id', 'emp_id', 'name', 'dept_id', 'manager_id']
Application code that reads columns by name will get whichever name the driver keeps, usually the last, so the employee's name may silently turn into the manager's. Always project explicit, aliased columns such as e.name AS employee and m.name AS manager.
Duplicate column names make results ambiguous or silently overwrite each other in dictionaries and data frames. List the columns you need and give each role its own alias.
Part 10 · Join Syntax: ON vs USING vs NATURAL vs WHERE
Four ways to say which rows match
Every join needs a rule for pairing rows, and SQL gives you several spellings of it. They are not equally safe. This section walks through them from most flexible to most dangerous, using a small dataset: six employees (one, Fay, has no department), four departments (Legal has nobody) and four orders.
ON: any boolean expression
The ON clause accepts any boolean expression. Plain equality is the common case, but inequalities, OR, function calls and multi-column conjunctions are all legal. A pair of rows is kept only when the expression evaluates to TRUE; FALSE and UNKNOWN both discard it. The example below sets up the dataset and joins on equality plus a salary threshold in one predicate.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE departments (dept_id INT, dept_name TEXT); CREATE TABLE employees (emp_id INT, name TEXT, dept_id INT, salary INT); CREATE TABLE orders (order_id INT, emp_id INT, year INT); INSERT INTO departments VALUES (10,'Eng'),(20,'Sales'),(30,'Ops'),(40,'Legal'); INSERT INTO employees VALUES (1,'Ana',10,90),(2,'Ben',10,70),(3,'Cy',20,80), (4,'Di',20,60),(5,'Eli',30,75),(6,'Fay',NULL,95); INSERT INTO orders VALUES (1,1,2024),(2,1,2023),(3,3,2024),(4,4,2023); ''') def show(sql): for row in db.execute(sql): print(row) show('''SELECT e.name, d.dept_name FROM employees e JOIN departments d ON e.dept_id = d.dept_id AND e.salary >= 75 ORDER BY e.emp_id''')
Equality and a range test combined in one ON
('Ana', 'Eng') ('Cy', 'Sales') ('Eli', 'Ops')
The same slot could hold ON e.dept_id = d.dept_id OR e.salary > 90, or ON LOWER(a.code) = LOWER(b.code). Keep in mind that a function wrapped around a column usually stops the engine from using an index on it.
USING: shorthand that merges the column
When both tables name the key column identically, USING (dept_id) means the same as an equality test on that pair. It also changes the output: the two columns collapse into a single merged column. Because only one dept_id exists in the result, the usual style is to write it unqualified, as SELECT dept_id. PostgreSQL, MySQL and SQLite also accept a qualified e.dept_id with USING, but a few engines (Oracle, for example) reject a qualified reference to a USING column, so the unqualified form is the portable habit.
In an outer join the merged column holds the COALESCE of both sides, meaning whichever side has a value. In a FULL join, the unmatched Legal department therefore shows 40 rather than NULL. In a LEFT join, as below, the left value is always present when the left row has one, and Fay's key stays NULL because she has none.
show('''SELECT dept_id, name, dept_name FROM employees LEFT JOIN departments USING (dept_id) ORDER BY emp_id''')
One merged dept_id column, referenced without a qualifier
(10, 'Ana', 'Eng') (10, 'Ben', 'Eng') (20, 'Cy', 'Sales') (20, 'Di', 'Sales') (30, 'Eli', 'Ops') (None, 'Fay', None)
NATURAL JOIN: the one to avoid
NATURAL JOIN goes one step further and joins on every column pair that happens to share a name. You never write the predicate, so the predicate can change without any edit to your query. Adding a created_at column to both tables makes it a second join condition, and since the timestamps differ, the join quietly stops matching.
q = 'SELECT COUNT(*) FROM employees NATURAL JOIN departments' print(db.execute(q).fetchone()[0]) db.executescript(''' ALTER TABLE employees ADD COLUMN created_at TEXT; ALTER TABLE departments ADD COLUMN created_at TEXT; UPDATE employees SET created_at = '2024-01-05'; UPDATE departments SET created_at = '2023-06-01'; ''') print(db.execute(q).fetchone()[0])
Same query text, different result after a schema change
5 0
A schema change that looks harmless, such as an audit column like created_at or updated_at added to both tables, rewrites the join predicate. The query still runs and returns fewer rows, with no error. Spell the condition out with ON.
Comma joins and vendor syntax
The old style lists tables with commas and puts the condition in WHERE. It works for inner joins, because a comma join is a cross join that WHERE then filters. It cannot express outer joins portably. The vendor workarounds, Oracle's (+) marker and SQL Server's *= operator, are deprecated and should not appear in new code.
SELECT e.name, d.dept_name FROM employees e, departments d WHERE e.dept_id = d.dept_id; -- inner join in disguise
| Style | Handles any predicate | Outer joins | Verdict |
|---|---|---|---|
| ON | Yes | Yes | Default choice |
| USING (col) | Equality on same-named columns only | Yes | Fine for simple keys |
| NATURAL JOIN | No predicate written at all | Yes | Avoid |
| Comma + WHERE | Yes | No, not portably | Legacy only |
ON versus WHERE: the same text, two different moments
For an inner join, a predicate in ON and the same predicate in WHERE give identical results. A row pair that fails the test is dropped either way, and there are no preserved rows to protect. The example runs the same year filter both ways and compares the two result lists.
a = db.execute('''SELECT e.name, o.order_id FROM employees e JOIN orders o ON o.emp_id = e.emp_id AND o.year = 2024 ORDER BY e.emp_id''').fetchall() b = db.execute('''SELECT e.name, o.order_id FROM employees e JOIN orders o ON o.emp_id = e.emp_id WHERE o.year = 2024 ORDER BY e.emp_id''').fetchall() print(a == b) print(a)
True [('Ana', 1), ('Cy', 3)]
With an outer join the two placements diverge, because the join happens in stages. ON decides which pairs match. Then the engine pads unmatched preserved rows with NULLs. Only after that does WHERE run, on the padded result.
- 1Pair rowsevery left row against right rows
- 2ON filters pairsdecides what counts as a match
- 3Pad with NULLsunmatched left rows survive
- 4WHERE filters rowsruns on the padded result
Consider the request "all employees, with their 2024 orders". Putting o.year = 2024 in ON keeps every employee: those without a 2024 order simply show NULL. Moving the same test to WHERE evaluates NULL = 2024 on padded rows, which is UNKNOWN, so those employees vanish and the LEFT JOIN behaves like an inner join. The last query shows the one deliberate use of WHERE after an outer join: testing a right-side key for NULL to find unmatched left rows (an anti-join).
base = 'SELECT e.name, o.order_id FROM employees e LEFT JOIN orders o ' queries = [ ('ON:', base + 'ON o.emp_id = e.emp_id AND o.year = 2024 ORDER BY e.emp_id'), ('WHERE:', base + 'ON o.emp_id = e.emp_id WHERE o.year = 2024 ORDER BY e.emp_id'), ('anti:', 'SELECT e.name FROM employees e LEFT JOIN orders o ' 'ON o.emp_id = e.emp_id WHERE o.order_id IS NULL ORDER BY e.emp_id'), ] for label, sql in queries: print(label, db.execute(sql).fetchall())
Filter in ON, filter in WHERE, and the intentional IS NULL test
ON: [('Ana', 1), ('Ben', None), ('Cy', 3), ('Di', None), ('Eli', None), ('Fay', None)] WHERE: [('Ana', 1), ('Cy', 3)] anti: [('Ben',), ('Eli',), ('Fay',)]
| Join type | Predicate in ON | Same predicate in WHERE |
|---|---|---|
| INNER | Drops non-matching pairs | Identical result |
| LEFT / RIGHT / FULL | Filters before padding; unmatched preserved rows survive as NULLs | Filters after padding; NULL-padded rows fail the test and disappear |
Writing LEFT JOIN orders o ON ... WHERE o.year = 2024 silently turns the outer join into an inner join. The query runs, the row count shrinks, and nobody gets an error.
Treat any WHERE predicate that mentions the right table of a LEFT JOIN as a bug until proven otherwise. The only expected exception is WHERE right.key IS NULL, the anti-join filter, and it should test a column that cannot be NULL in real rows, such as the primary key.
Part 11 · NULLs in Joins: Three-Valued Logic
Why NULL never matches NULL
SQL logic has three outcomes, not two: TRUE, FALSE and UNKNOWN. A NULL means 'a value exists but we do not know it', so comparing it to anything, including another NULL, cannot be answered with yes or no. NULL = NULL therefore evaluates to UNKNOWN, not TRUE. The practical result is that a NULL join key never matches anything, not even another NULL key.
In the running schema, Fay is the employee whose dept_id is NULL. Her key cannot equal any department's key, so on an equality predicate she pairs with no row, whatever the join type. An outer join can still keep her, but only by padding, which is a different mechanism that the next page covers. The first example sets up the tables and shows all three facts.
import sqlite3 con = sqlite3.connect(":memory:") q = lambda sql: con.execute(sql).fetchall() con.execute("CREATE TABLE departments (dept_id INT, dept_name TEXT)") con.executemany("INSERT INTO departments VALUES (?, ?)", [(10, 'Eng'), (20, 'Sales'), (30, 'Ops'), (40, 'Legal')]) con.execute("CREATE TABLE employees (emp_id INT, name TEXT, dept_id INT)") con.executemany("INSERT INTO employees VALUES (?, ?, ?)", [(1, 'Ana', 10), (2, 'Ben', 10), (3, 'Cy', 20), (4, 'Di', 30), (5, 'Eli', 20), (6, 'Fay', None)]) print(q("SELECT NULL = NULL, NULL IS NULL, 1 = NULL")) print(q("SELECT COUNT(*) FROM employees e JOIN departments d ON e.dept_id = d.dept_id")) print(q("SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id WHERE e.name = 'Fay'"))
SQLite shows UNKNOWN as None and TRUE as 1
[(None, 1, None)] [(5,)] [('Fay', None)]
The first line shows that NULL = NULL and 1 = NULL both come back as None, which is how SQLite displays UNKNOWN, while IS NULL gives a real TRUE. The inner join finds only 5 matches out of 6 employees. Fay is missing, and 'Legal' is missing from the other side too. The third line shows the left join keeping Fay, but only as a row whose department columns are padded with NULL.
An equality join never uses a NULL key to find a partner. If the question needs NULL keys to line up, you must say so explicitly with one of the techniques on page 3.
What the ON clause keeps
A join predicate is a filter over candidate row pairs, and it has one rule: only pairs where the predicate is TRUE survive. A pair that evaluates to FALSE is rejected, and so is a pair that evaluates to UNKNOWN. A NULL key always produces UNKNOWN, which is why it behaves exactly like a mismatch.
| Pair | ON e.dept_id = d.dept_id | Kept by the predicate? |
|---|---|---|
| dept 10 vs dept 10 | TRUE | Yes |
| dept 10 vs dept 20 | FALSE | No |
| NULL vs dept 20 | UNKNOWN | No |
| NULL vs NULL | UNKNOWN | No |
An outer join adds one more step after the predicate. Every row from the preserved side that found no TRUE partner is added back, with NULL in all the columns of the other side. That is why Fay shows up in a left join: she lost every comparison, then was re-added with a NULL department name. She was never matched.
- 1Pair rowsevery employee against every department
- 2Evaluate ONFay's NULL key gives UNKNOWN every time
- 3Keep only TRUEFALSE and UNKNOWN are both dropped
- 4Re-add preserved rowsFay returns with NULL department columns
Expecting a NULL-keyed row to match a NULL-keyed row on the other side because 'they are both empty'. The predicate returns UNKNOWN, so the pair is dropped, and if the join is inner the data silently disappears from the result.
Making NULLs match on purpose
When NULL keys should be treated as equal, use a NULL-safe equality operator. It returns TRUE when both sides are NULL, TRUE when both hold the same value, and FALSE otherwise, so it never produces UNKNOWN. The standard spelling is IS NOT DISTINCT FROM, and MySQL has the shorthand <=>. SQLite spells it plain IS.
| Engine | NULL-safe equality |
|---|---|
| PostgreSQL, standard SQL | a.k IS NOT DISTINCT FROM b.k |
| MySQL | a.k <=> b.k |
| SQLite | a.k IS b.k |
| No operator available | a.k = b.k OR (a.k IS NULL AND b.k IS NULL) |
The last row is the portable fallback. It is correct, but the OR usually stops the engine from using an index on the key, so large tables may fall back to slower plans. Prefer the native operator where one exists. The next example runs a small pair of tables, l and r, each holding one NULL key, through every form.
con.execute("CREATE TABLE l (k INT)") con.execute("CREATE TABLE r (k INT)") con.executemany("INSERT INTO l VALUES (?)", [(None,), (1,), (2,)]) con.executemany("INSERT INTO r VALUES (?)", [(None,), (2,), (3,)]) print(q("SELECT l.k, r.k FROM l LEFT JOIN r ON l.k = r.k ORDER BY l.k")) print(q("SELECT l.k, r.k FROM l JOIN r ON l.k = r.k ORDER BY l.k")) print(q("SELECT l.k, r.k FROM l JOIN r ON l.k IS r.k ORDER BY l.k")) print(q("SELECT l.k, r.k FROM l JOIN r ON l.k = r.k OR (l.k IS NULL AND r.k IS NULL) ORDER BY l.k"))
[(None, None), (1, None), (2, 2)] [(2, 2)] [(None, None), (2, 2)] [(None, None), (2, 2)]
The left join with plain = shows a row (None, None), but that is only the left NULL being padded, not a match. The plain inner join finds just (2, 2). Both NULL-safe forms add the genuine (None, None) pair and give identical results.
A common shortcut is to wrap both keys in COALESCE with a placeholder value, which turns every NULL into the same number. It works only when the sentinel can never appear as a real key. The next example breaks it by giving table t a real key of -1.
con.execute("CREATE TABLE t (k INT)") con.executemany("INSERT INTO t VALUES (?)", [(-1,), (2,)]) print(q("SELECT l.k, t.k FROM l JOIN t ON COALESCE(l.k, -1) = COALESCE(t.k, -1) ORDER BY l.k"))
[(None, -1), (2, 2)]
Trusting COALESCE(a.k, -1) = COALESCE(b.k, -1). Above, a NULL key is paired with a real -1 key, a false match that looks completely normal in the output. Use the NULL-safe operator, or pick a sentinel only after proving it cannot occur in the data.
Padded NULLs, counts, and the set-operator asymmetry
After a left join, a NULL in a right-table column can mean two different things: the right row was missing and the engine padded it, or the right row matched and its stored value really is NULL. To test for 'no match', check a right-table column that can never be NULL in a real row, usually its primary key. Counting works the same way: COUNT(*) counts every output row, including padded ones, while COUNT(right_col) skips NULLs and so skips padding.
con.execute("CREATE TABLE customers (cust_id INT, name TEXT)") con.execute("CREATE TABLE orders (order_id INT, cust_id INT, amount INT)") con.executemany("INSERT INTO customers VALUES (?, ?)", [(1, 'Asha'), (2, 'Bo'), (3, 'Cai')]) con.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(100, 1, 50), (101, 1, 30), (102, 2, None)]) print(q("SELECT c.name, COUNT(*), COUNT(o.order_id) FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id GROUP BY c.name ORDER BY c.name")) print(q("SELECT c.name FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id WHERE o.amount IS NULL ORDER BY c.name")) print(q("SELECT c.name FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id WHERE o.order_id IS NULL"))
Bo's only order has a stored NULL amount
[('Asha', 2, 2), ('Bo', 1, 1), ('Cai', 1, 0)] [('Bo',), ('Cai',)] [('Cai',)]
Cai has no orders, yet COUNT(*) reports 1 for her because the padded row is still a row. COUNT(o.order_id) reports the true 0. Testing o.amount IS NULL wrongly returns Bo as well, since his matched order has a stored NULL amount. Testing the primary key o.order_id IS NULL finds only Cai.
Writing COUNT(*) after a LEFT JOIN and reporting 1 order for customers who have none. Count a non-null column of the right table instead.
Joins treat NULLs as unequal, but UNION, INTERSECT, EXCEPT, GROUP BY and DISTINCT treat two NULLs as the same value when deciding whether rows are duplicates. The final example runs the same l and r tables through all of them.
print(q("SELECT k FROM l UNION SELECT k FROM r ORDER BY k")) print(q("SELECT k FROM l INTERSECT SELECT k FROM r ORDER BY k")) print(q("SELECT k FROM l EXCEPT SELECT k FROM r ORDER BY k")) both = "SELECT k FROM l UNION ALL SELECT k FROM r" print(q(f"SELECT k, COUNT(*) FROM ({both}) GROUP BY k ORDER BY k")) print(q(f"SELECT DISTINCT k FROM ({both}) ORDER BY k"))
[(None,), (1,), (2,), (3,)] [(None,), (2,)] [(1,)] [(None, 2), (1, 1), (2, 2), (3, 1)] [(None,), (1,), (2,), (3,)]
The two NULLs collapse into one in the UNION. INTERSECT keeps NULL as a common row, which the join on page 3 could not do with plain =. EXCEPT removes it from the left side because the right side has one too. GROUP BY puts both NULLs in one group of size 2, and DISTINCT keeps a single NULL.
| Context | Are two NULLs equal? |
|---|---|
| Join predicate (=) | No, UNKNOWN |
| UNION / INTERSECT | Yes, duplicates |
| EXCEPT | Yes, duplicates |
| GROUP BY / DISTINCT | Yes, same group |
NULLs group together everywhere duplicates are being removed or counted, but they never match in a join. When you convert a join into a set operator or the reverse, the result can change for NULL rows alone.
Part 12 · Semi-Joins and Anti-Joins
Semi-joins: does a match exist?
Sometimes the question is not 'show me customers with their orders' but simply 'which customers have ordered at all?'. That question is about existence. A semi-join answers it: it returns rows from the left table when at least one matching row exists on the right, and it returns each left row once, no matter how many matches it has. The right table is only consulted, never displayed.
SQL has no SEMI JOIN keyword. You spell a semi-join in one of three ways: a correlated WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.cust_id), an IN (SELECT ...) subquery, or an ordinary join against a DISTINCT subquery so the right side is already unique before it joins.
The reason to care is the plain INNER JOIN. It pairs a customer with every one of their orders, so a customer with three orders appears three times. If you only wanted 'customers who ordered', those repeats are a silent bug, and every count or sum built on top of them is inflated. The example below sets up a small version of the running schema and runs all four forms side by side.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE customers (cust_id INT PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id INT PRIMARY KEY, cust_id INT); INSERT INTO customers VALUES (1,'Asha'),(2,'Ben'),(3,'Cara'); INSERT INTO orders VALUES (10,1),(11,1),(12,2); """) FORMS = { "inner:": "SELECT c.name FROM customers c JOIN orders o ON o.cust_id = c.cust_id ORDER BY c.name", "exists:": "SELECT name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.cust_id)", "in:": "SELECT name FROM customers WHERE cust_id IN (SELECT cust_id FROM orders)", "distinct:": "SELECT c.name FROM customers c JOIN (SELECT DISTINCT cust_id FROM orders) o ON o.cust_id = c.cust_id", } for label, sql in FORMS.items(): print(f"{label:<10}{[r[0] for r in db.execute(sql)]}")
Asha has two orders, Ben one, Cara none.
inner: ['Asha', 'Asha', 'Ben'] exists: ['Asha', 'Ben'] in: ['Asha', 'Ben'] distinct: ['Asha', 'Ben']
The inner join returned Asha twice, once per order. The three semi-join forms returned her once. Cara is absent from all four because she has no orders, which is exactly the group the next page goes looking for.
Joining to orders and then adding DISTINCT to hide the repeats works by accident and hides the real cause. It also pays for a sort or hash pass you did not need. If the question is 'who has at least one order', write EXISTS.
Anti-joins and the NOT IN trap
An anti-join is the mirror image: it keeps the left rows that have no match on the right. 'Customers who never ordered' is the classic case. Again there is no keyword, and again there are three spellings: NOT EXISTS, LEFT JOIN ... WHERE right.key IS NULL, and NOT IN. They look interchangeable, but one of them behaves differently as soon as a NULL appears in the right-hand data.
NOT IN is dangerous. If the subquery returns even one NULL, the whole result becomes empty, with no error and no warning. The reason is three-valued logic. x NOT IN (1, NULL) is shorthand for x <> 1 AND x <> NULL. Comparing anything to NULL evaluates to UNKNOWN, and WHERE keeps a row only when the predicate is TRUE. The table walks through it for two values of x.
| Step | x = 3 | x = 1 |
|---|---|---|
| x <> 1 | TRUE | FALSE |
| x <> NULL | UNKNOWN | UNKNOWN |
| Combined with AND | TRUE AND UNKNOWN = UNKNOWN | FALSE AND UNKNOWN = FALSE |
| Row kept by WHERE? | No, UNKNOWN is not TRUE | No, FALSE |
A value that is not in the list can never be proven to be different from the NULL, so no row can ever evaluate to TRUE. The next example runs all three anti-join forms, first on clean data and then after one order is inserted with a missing customer id. It reuses the tables from the previous page.
ANTI = {
"NOT EXISTS": "SELECT name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.cust_id)",
"LEFT JOIN": "SELECT c.name FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id WHERE o.order_id IS NULL",
"NOT IN": "SELECT name FROM customers WHERE cust_id NOT IN (SELECT cust_id FROM orders)",
}
def run(title):
print(title)
for label, sql in ANTI.items():
print(f" {label:<11}{[r[0] for r in db.execute(sql)]}")
run("before NULL row")
db.execute("INSERT INTO orders VALUES (13, NULL)")
run("after NULL row")Order 13 has no customer id, like a guest checkout.
before NULL row NOT EXISTS ['Cara'] LEFT JOIN ['Cara'] NOT IN ['Cara'] after NULL row NOT EXISTS ['Cara'] LEFT JOIN ['Cara'] NOT IN []
On clean data all three agree. One NULL later, NOT IN quietly returns nothing while the other two still find Cara. This is why NOT EXISTS is generally the recommended default for anti-joins: it tests whether a correlated row exists, so a NULL in the right table simply never matches and cannot poison the answer. It is NULL-safe without any extra care, it reads as the plain-English question, and it is a close second to LEFT JOIN ... IS NULL for clarity.
The query passes every test on development data and then returns zero rows in production the day a single NULL arrives. If you must use NOT IN, add WHERE cust_id IS NOT NULL inside the subquery, or just use NOT EXISTS.
Semi-join: EXISTS. Anti-join: NOT EXISTS. Both stay correct when NULLs appear and both state the intent directly.
Cost, comparison and finding gaps
On performance, EXISTS has a natural advantage: it only has to find the first match for each left row, so it can stop early and never builds the full list of right-hand values. The LEFT JOIN ... IS NULL form conceptually produces every joined row, padded ones included, and filters afterwards, so it may materialize more. In practice most optimizers recognise all three anti-join spellings and rewrite them to the same internal anti-join operator, so the plans often match. Check with EXPLAIN ANALYZE rather than assuming.
A related fact: EXISTS ignores the subquery's SELECT list entirely. It asks only whether any row comes back, never what the row contains. SELECT 1, SELECT * and even SELECT 1/0 behave identically, because the expressions are never used. The loop below proves it on the tables from before.
for sel in ("1", "*", "1/0"): sql = f"SELECT name FROM customers c WHERE EXISTS (SELECT {sel} FROM orders o WHERE o.cust_id = c.cust_id)" print(f"{sel:<4}{[r[0] for r in db.execute(sql)]}")
1 ['Asha', 'Ben'] * ['Asha', 'Ben'] 1/0 ['Asha', 'Ben']
Putting the three join shapes next to each other makes the difference concrete. They differ in what they keep and in whether a left row can repeat.
| Join | Keeps | Can multiply left rows? |
|---|---|---|
| INNER JOIN | Matches, with multiplication | Yes, once per matching right row |
| SEMI (EXISTS / IN) | Matches, no multiplication | No, each left row at most once |
| ANTI (NOT EXISTS) | Non-matches only | No, and the right side contributes nothing |
The correlated NOT EXISTS is the canonical gap-finding query. It answers 'what is missing?' for products never ordered, users who never logged in, and orphan foreign keys left behind by a bad migration, where a child row points at a parent that no longer exists.
-- products never ordered SELECT p.product_id, p.name FROM products p WHERE NOT EXISTS (SELECT 1 FROM order_items i WHERE i.product_id = p.product_id); -- users with no login SELECT u.user_id FROM users u WHERE NOT EXISTS (SELECT 1 FROM logins l WHERE l.user_id = u.user_id); -- orphan foreign keys: orders whose customer is gone SELECT o.order_id FROM orders o WHERE o.cust_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM customers c WHERE c.cust_id = o.cust_id);
The last query skips NULL cust_id on purpose: a missing key is not an orphan.
In the LEFT JOIN ... IS NULL form, the only WHERE condition on the right table should be the IS NULL test, and it must target a column that can never be NULL in real rows, such as the primary key. Any extra right-side filter belongs in ON, otherwise the anti-join silently changes meaning.
Part 13 · How Engines Execute Joins: The Three Algorithms
Nested loop and hash join
A join in SQL only says which rows should pair up. It never says how to find them. The engine chooses one of three physical algorithms for each join in the plan: nested loop, hash join or merge join. They return the same rows, but their costs differ by orders of magnitude. Knowing which one is running tells you why a query is fast or slow.
Nested loop: probe the inner side once per outer row
The simplest algorithm picks a row from the outer input, looks through the inner input for partners, emits every match, then moves to the next outer row. With N outer rows and M inner rows, scanning the inner side each time costs about N × M comparisons. If the inner join key has an index, each probe becomes a tree lookup instead of a scan, and the cost drops to about N × log M.
Nested loop wins when the outer side is tiny and the inner side has a selective index. That describes the classic OLTP query, such as fetching one customer and their handful of orders. Three index lookups cost almost nothing, and nothing has to be built or sorted first. The same algorithm is a disaster on two large unindexed tables, because N × M grows without bound.
| Nested loop variant | Cost | Typical situation |
|---|---|---|
| Inner side scanned each time | about N × M | Tiny tables, or no usable index |
| Index on the inner join key | about N × log M | Few outer rows, selective lookups |
Hash join: build on the small side, stream the big side
A hash join works in two phases. It first reads the smaller input, called the build side, into an in-memory hash table keyed on the join column. It then streams the larger probe side. Each probe row hashes its key and checks one bucket. Every row is touched about once, so the cost is roughly N + M rather than N × M.
- 1Buildhash every row of the smaller input by its join key
- 2Probestream the larger input, hash each key, look in one bucket
- 3Emitoutput a joined row for every bucket hit
Hashing only tells you whether two keys are equal. It says nothing about which is larger. A hash join therefore requires an equality predicate, and it cannot serve ON a.x < b.y. Within that limit it is the workhorse for large, unindexed analytical joins, where nested loop would be hopeless and sorting everything would be wasted effort.
The hash table has to fit in the work memory budget, such as work_mem in PostgreSQL. If the build side is bigger, the engine falls back to a grace hash join. It partitions both inputs by hash value, writes the partitions to disk, then joins them partition by partition. The result is still correct, but the extra disk writes and reads make it much slower.
Selecting every column makes each hashed row bigger, so the build side overflows memory sooner. If a hash join suddenly slows down after you added columns, check the plan for batches spilled to disk and select only the columns you need.
Merge join, outer joins and a side-by-side run
Merge join: sort both sides, then walk them together
A merge join, also called sort-merge, sorts both inputs on the join key and then advances a pointer on each side in lockstep. Whichever side holds the smaller key moves forward. When the keys are equal, the engine emits the pairs. Each input is read once during the walk. Sorting costs about N log N + M log M. If both inputs already arrive in key order, for example from an index scan or a clustered table, the sort disappears and the cost falls to about N + M.
Because the walk follows the sort order, a merge join's output is already sorted on the join key. When the query ends in ORDER BY on that key, the planner can drop the separate sort. That makes the ordering free.
In PostgreSQL and SQL Server, a merge join needs at least one sortable equality condition. A range predicate such as ON s.amount BETWEEN b.min_amt AND b.max_amt or ON a.x < b.y is normally executed as a nested loop, so keep such ranges tightly bounded. Only some engines, such as Oracle, can merge on inequalities.
How outer joins reuse the same algorithms
Outer joins need no new algorithm. Each algorithm gets a variant that tracks whether every row from the preserved side found a partner. A row that never matched is emitted once, with NULLs in all columns from the other side.
In a hash join the same idea works at the end of the probe phase. To preserve the build side, the engine flags each hash entry when it is hit. After all probe rows have been streamed, it emits every unflagged entry padded with NULLs. This also explains why FULL OUTER JOIN is normally hash- or merge-based. It must emit unmatched rows from both inputs, and a plain nested loop that walks the outer side has no cheap way to find which inner rows were never touched. PostgreSQL rejects a full join whose condition cannot be hashed or merged for that reason.
The same join under all three algorithms
The sketch below runs the employee and department join from the running schema three ways and counts the basic work each one does. The orphan employee has a NULL key. It matches nothing in any algorithm, and the empty Legal department is likewise left out. The merge version filters out NULL keys before sorting. The merge count covers only the walk, not the sort.
emps = [('Ana', 10), ('Ben', 10), ('Cy', 20), ('Di', 30), ('Eve', 30), ('Flo', None)] depts = [(10, 'Eng'), (20, 'Sales'), (30, 'Ops'), (40, 'Legal')] def nested_loop(outer, inner): rows, work = [], 0 for name, d in outer: for key, label in inner: work += 1 if d == key: rows.append((name, label)) return rows, work def hash_join(build, probe): table, work = {}, 0 for key, label in build: table[key] = label work += 1 rows = [] for name, d in probe: work += 1 if d in table: rows.append((name, table[d])) return rows, work def merge_join(left, right): left = sorted((r for r in left if r[1] is not None), key=lambda r: r[1]) right = sorted(right) rows, work, i, j = [], 0, 0, 0 while i < len(left) and j < len(right): work += 1 lk, rk = left[i][1], right[j][0] if lk < rk: i += 1 elif lk > rk: j += 1 else: rows.append((left[i][0], right[j][1])) i += 1 return rows, work nl, w1 = nested_loop(emps, depts) hj, w2 = hash_join(depts, emps) mj, w3 = merge_join(emps, depts) print(f'nested loop: {len(nl)} rows, {w1} comparisons') print(f'hash join: {len(hj)} rows, {w2} hash operations') print(f'merge join: {len(mj)} rows, {w3} merge steps') print('same rows:', sorted(nl) == sorted(hj) == sorted(mj))
Dictionary keys are unique here, so the hash and merge versions can assume each department matches at most once.
nested loop: 5 rows, 24 comparisons hash join: 5 rows, 10 hash operations merge join: 5 rows, 7 merge steps same rows: True
All three return the same five rows. They differ only in the work done to get there. At this size the gap is small. Scale the tables up and the nested loop's N × M comparisons dwarf the other two.
Reading a plan and steering the choice
The three side by side
| Nested loop | Hash join | Merge join | |
|---|---|---|---|
| Predicate it can serve | Anything, including ranges | Equality only | At least one sortable equality in PostgreSQL and SQL Server; some engines, such as Oracle, also merge on inequalities |
| Approximate cost | N × M, or N × log M with an inner index | N + M | N log N + M log M, or N + M if already sorted |
| Needs | Ideally an index on the inner key | Memory for the build side | Sorted inputs, or a sort step |
| Best when | Tiny outer side, selective inner index | Large unindexed equality joins | Inputs already ordered, or a sorted result is wanted |
| Output order | Follows the outer side | Follows the probe side | Sorted on the join key |
The usual decision logic follows from that table. Engines weigh estimated costs rather than fixed rules, but the shape of the choice looks like this.
Reading the plan
EXPLAIN shows the chosen algorithm as a node name: Nested Loop, Hash Join or Merge Join. Next to each node sit the estimated row count and, if you used EXPLAIN ANALYZE, the actual row count. Look at both. The plan below is healthy, because the estimates match what really happened.
Hash Join (rows=1200 est) (actual rows=1198) Hash Cond: (o.customer_id = c.id) -> Seq Scan on orders o (rows=1200 est) (actual rows=1198) -> Hash (rows=300 est) (actual rows=300) -> Seq Scan on customers c (rows=300 est) (actual rows=300)
A trimmed PostgreSQL-style plan. Estimates and actuals agree.
Nested Loop (rows=3 est) (actual rows=94000) -> Index Scan on customers c (rows=3 est) (actual rows=2100) -> Index Scan on orders o (rows=1 est) (actual rows=45)
The planner expected three rows and got 94,000. It picked a loop that suits three rows.
A big gap between estimated and actual rows, roughly 10x or more, means the planner is working from bad statistics. It picked the nested loop because it believed the outer side was tiny. The first node where estimates and actuals diverge is where to start looking.
You cannot write a join algorithm into plain SQL. You influence the planner through indexes (a selective index on the inner key invites a nested loop, sorted index scans invite a merge join), statistics freshness (run ANALYZE after bulk loads), and predicate selectivity (filters that shrink the inputs before the join). Hints that force an algorithm exist, but they freeze the plan against future data, so treat them as a last resort.
A hash join got slower after the table grew. What do you check first?
- Whether the plan shows the hash spilling to disk, meaning the build side no longer fits in the work memory budget.
- Whether you select more columns than needed, since wider rows enlarge the build side.
- Whether the statistics are stale, because the planner may now have the wrong side as the build input.
- Raising the memory setting can fix the slowdown with no query change.
Part 14 · Join Order, Cardinality, and the Optimizer
Who Chooses the Join Order
When you write FROM a JOIN b JOIN c, you are describing what you want, not the order in which tables get read. Inner joins are commutative (a JOIN b equals b JOIN a) and associative ((a JOIN b) JOIN c equals a JOIN (b JOIN c)), so every ordering returns the same rows. The planner is therefore free to pick whichever order it estimates to be cheapest. The order you type in the FROM clause is a readability choice, not a plan.
Freedom has a price: the number of candidate orderings grows factorially. With N tables there are up to N! left-deep orderings, and bushy trees (joining two intermediate results together) add even more. The script below prints how fast that grows.
import math for n in (3, 5, 10, 15): print(n, math.factorial(n))
Upper bound on left-deep join orders for N tables
3 6 5 120 10 3628800 15 1307674368000
No planner can price a trillion plans inside a query's planning budget, so engines switch strategy as N grows. The table shows the usual split.
| Number of tables | Typical strategy | Result |
|---|---|---|
| Small (roughly 8 or fewer) | Dynamic programming: keep the best plan for every subset of tables and build up | Finds the cheapest plan under its cost model |
| Large (roughly 12 or more) | Heuristics or genetic search (Postgres switches to GEQO) | Good enough plan, found fast, not guaranteed best |
| In between | Engine-specific thresholds and join-collapse limits | Planning time can start to rival execution time |
You do not control join order directly. You influence it by giving the optimizer good inputs: accurate statistics, useful indexes, and queries that shrink row counts early.
Cardinality Estimation and Statistics
Every choice the planner makes depends on one number: how many rows will come out of each step. This is cardinality estimation. If the planner thinks a filter leaves 2,000 rows when it really leaves 400,000, it will pick a nested loop where a hash join belonged, and it will place that table too early in the order. Because each node's estimate feeds the next one, a wrong guess at the bottom of the plan spreads upward.
- 1Row estimate too lowat a scan or filter
- 2Wrong algorithmnested loop instead of hash
- 3Wrong join orderbig table joined too early
- 4Slow queryspills, huge loop counts
Estimates are built from statistics the database keeps per column: a histogram of value ranges, the number of distinct values, the null fraction, and the most common values. A predicate like status = 'shipped' is turned into a selectivity (a fraction of the table) using those numbers, and selectivity times table size gives the row estimate.
Statistics are a snapshot. After a bulk load, a big delete, or a migration, the snapshot describes yesterday's table. The plan that was fine on Monday becomes a sudden slow join on Tuesday with no code change. Run ANALYZE (Postgres, MySQL) or UPDATE STATISTICS (SQL Server) after bulk changes.
The second classic failure is correlated columns. The planner assumes predicates are independent and multiplies their selectivities. When the columns are linked, as city and country are, that product is far too small.
| Predicate | Selectivity | Estimated rows (1,000,000-row table) |
|---|---|---|
city = 'Paris' | 0.02 | 20,000 |
country = 'France' | 0.10 | 100,000 |
| Both, assumed independent: 0.02 × 0.10 | 0.002 | 2,000 |
| Both, in reality (every Paris row is in France) | 0.02 | 20,000 |
The estimate is 10x too low, and a join sitting above this filter will inherit that error. The fix is to tell the database the columns are related by creating multi-column (Postgres calls them extended) statistics, then refreshing them.
CREATE STATISTICS addr_city_country (dependencies) ON city, country FROM addresses; ANALYZE addresses;
Postgres: teach the planner that city determines country
Shaping Joins So the Optimizer Wins
The rule of thumb is filter early, join late: reduce the number of rows before they enter a join, not after. A join's cost grows with its inputs, so a filter that removes 99% of a table before the join saves work at every step above it. For plain inner joins the optimizer pushes simple WHERE filters down for you. The rule matters most where it cannot: filters that must apply after a GROUP BY, a DISTINCT, or inside the nullable side of an outer join, and pre-aggregation of large tables.
Outer joins limit how freely the planner can reorder. a LEFT JOIN b must keep every row of a, so b cannot be moved ahead of a, and a LEFT JOIN (b JOIN c) is not the same query as (a LEFT JOIN b) JOIN c. A chain of five LEFT JOINs therefore has far fewer legal plans than five inner joins, which means fewer chances to find a cheap one.
| Join shape | Can the planner swap the two sides? | Effect on plan choice |
|---|---|---|
a JOIN b | Yes, either order | Full freedom |
a LEFT JOIN b | No, a must be preserved | Only the algorithm can change |
(a LEFT JOIN b) JOIN c | Only in limited cases | Fewer legal orders |
| Long LEFT JOIN chain | Mostly fixed in written order | Your FROM order starts to matter |
Join order is also where the silent corruption happens. When two independent 1:many tables hang off the same parent, joining both multiplies the children: a customer with 3 orders and 2 addresses yields 3 × 2 = 6 rows, and every order amount is counted twice. With 10 orders and 5 addresses it would be 50 rows per customer. The example below shows the damage and the cure, which is to aggregate each branch in its own subquery before joining.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE orders(id INTEGER PRIMARY KEY, customer_id INT, amount INT); CREATE TABLE addresses(id INTEGER PRIMARY KEY, customer_id INT, city TEXT); INSERT INTO customers VALUES (1, 'Asha'); INSERT INTO orders VALUES (1, 1, 10), (2, 1, 20), (3, 1, 30); INSERT INTO addresses VALUES (1, 1, 'Pune'), (2, 1, 'Delhi'); ''') bad = db.execute(''' SELECT COUNT(*), SUM(o.amount) FROM customers c JOIN orders o ON o.customer_id = c.id JOIN addresses a ON a.customer_id = c.id''').fetchone() print('fan-out rows, sum:', bad) good = db.execute(''' SELECT c.name, o.total, a.n FROM customers c JOIN (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) o ON o.customer_id = c.id JOIN (SELECT customer_id, COUNT(*) AS n FROM addresses GROUP BY customer_id) a ON a.customer_id = c.id''').fetchall() print('pre-aggregated:', good)
The true order total is 60, not 120
fan-out rows, sum: (6, 120) pre-aggregated: [('Asha', 60, 2)]
The query runs, returns plausible numbers, and the totals are exact multiples of the truth. Count rows after each added join and compare against COUNT(DISTINCT parent_id). If a join multiplies rows when you expected it not to, the newest join is the culprit.
Diagnosing a Slow Join and Fixing It
When a join is slow, do not guess. Run EXPLAIN ANALYZE, which executes the query and reports both the planner's estimated rows and the actual rows at each node. Plain EXPLAIN shows only estimates, and estimates are exactly what might be wrong. Walk up the plan from the leaves and find the first node where estimate and actual differ by 10x or more. Everything above it is built on a bad number, so that node is where you start.
- 1EXPLAIN ANALYZErun the real query
- 2Compare estimated vs actualat every node
- 3Find first 10x gapstart at the leaves
- 4Fix that node's inputstats, index, rewrite
- 5Re-run and re-compareconfirm the gap closed
Nested Loop (rows=2000) (actual rows=400000) -> Seq Scan on addresses (rows=2000) (actual rows=20000) -- 10x off: start here Filter: city = 'Paris' AND country = 'France' -> Index Scan on orders (rows=1) (actual rows=20 loops=20000)
Simplified plan: the correlated filter is underestimated, so a nested loop was chosen
Here the scan on addresses is off by exactly the correlation error from earlier. The planner expected 2,000 probes into orders and got 20,000, so the nested loop it chose is a poor fit. Fixing the estimate at that scan usually fixes the whole plan, with no change to the query text.
Work through the fixes in this order, from cheapest and most durable to most invasive. Hints come last.
-- before: runs the subquery once per customer SELECT c.name, (SELECT MAX(o.placed_at) FROM orders o WHERE o.customer_id = c.id) AS last_order FROM customers c; -- after: aggregate once, then join SELECT c.name, o.last_order FROM customers c LEFT JOIN (SELECT customer_id, MAX(placed_at) AS last_order FROM orders GROUP BY customer_id) o ON o.customer_id = c.id;
Rewrite a correlated subquery as a join
Every major engine offers a way to force a join algorithm or order. They work, but they freeze the plan: when the data grows or its shape changes, the hinted plan stays the same while the optimizer would have moved on. Treat a hint as a last resort, document why it exists, and revisit it.
| Engine | Hint | What it forces |
|---|---|---|
| Oracle | /*+ USE_HASH(o) */ | Hash join for that table |
| SQL Server | OPTION (HASH JOIN) | Hash join for the whole query |
| MySQL | STRAIGHT_JOIN | Join tables in the written order |
A hint hides a bad estimate instead of fixing it. The slow query turns fast today, and then slow again after the next data change, with nobody remembering why the hint is there. Refresh statistics, add the missing index, and rewrite before you hint.
Part 15 · Chaining Multiple Joins
Reading a chain left to right
A join takes two tables and returns one table. Because the result is itself a table, you can feed it into another join. That is all a chain is. When you write A JOIN B JOIN C, the engine reads it as (A JOIN B) JOIN C. The first join runs, and its output becomes the input to the second.
This matters for the ON clause of the third join. It may refer to columns from any table already in the chain, not only the one written just above it. It also matters for outer joins. For the third join, the 'left table' is everything accumulated so far: every column and every row, including rows that were NULL-padded by earlier joins.
That is why RIGHT JOIN confuses readers inside a chain. In customers c LEFT JOIN orders o ON ... RIGHT JOIN shipments s ON s.order_id = o.order_id, the RIGHT JOIN keeps every shipment. It is not preserving just orders. It preserves one side of a join whose other side is the whole customers-and-orders result. A reader has to rebuild that composite in their head, which is the main reason teams keep chains LEFT all the way down.
The examples in this section use a small shop schema. Customer Ana has two orders, both shipped. Ben has one order that has not shipped yet. Cy has never ordered. The first block builds the tables and shows the intermediate result AB. That is the table the shipments join will be applied to.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE customers (cust_id INT, name TEXT); CREATE TABLE orders (order_id INT, cust_id INT, amount INT); CREATE TABLE shipments (ship_id INT, order_id INT, carrier TEXT); CREATE TABLE tickets (ticket_id INT, cust_id INT); INSERT INTO customers VALUES (1,'Ana'),(2,'Ben'),(3,'Cy'); INSERT INTO orders VALUES (10,1,50),(11,1,30),(12,2,20); INSERT INTO shipments VALUES (100,10,'DHL'),(101,11,'UPS'); INSERT INTO tickets VALUES (900,1),(901,1),(902,1),(903,2); """) def run(label, sql): print(label) for row in db.execute(sql): print(row) run("customers LEFT JOIN orders", "SELECT c.name, o.order_id FROM customers c " "LEFT JOIN orders o ON o.cust_id = c.cust_id " "ORDER BY c.cust_id, o.order_id")
The composite AB: four rows, with Cy padded by NULL.
customers LEFT JOIN orders ('Ana', 10) ('Ana', 11) ('Ben', 12) ('Cy', None)
When an INNER JOIN cancels a LEFT JOIN
Take the composite above and join shipments to it. Cy's row carries order_id = NULL. An INNER JOIN on s.order_id = o.order_id evaluates NULL = something, which is UNKNOWN, and an inner join discards anything that is not TRUE. So Cy's row is dropped. A single inner join after a left join silently cancels the outer-ness, and the query behaves as if the first join had been inner too.
Ben loses his row for a different reason. His order 12 has no shipment, so the inner join finds no partner for it. The block below runs three versions of the chain. Compare which customers survive.
head = "SELECT c.name, o.order_id, s.carrier FROM customers c " tail = " ORDER BY c.cust_id, o.order_id" run("LEFT, LEFT", head + "LEFT JOIN orders o ON o.cust_id = c.cust_id " "LEFT JOIN shipments s ON s.order_id = o.order_id" + tail) run("LEFT, then INNER", head + "LEFT JOIN orders o ON o.cust_id = c.cust_id " "JOIN shipments s ON s.order_id = o.order_id" + tail) run("LEFT onto (orders JOIN shipments)", head + "LEFT JOIN (orders o JOIN shipments s " "ON s.order_id = o.order_id) ON o.cust_id = c.cust_id" + tail)
LEFT, LEFT ('Ana', 10, 'DHL') ('Ana', 11, 'UPS') ('Ben', 12, None) ('Cy', None, None) LEFT, then INNER ('Ana', 10, 'DHL') ('Ana', 11, 'UPS') LEFT onto (orders JOIN shipments) ('Ana', 10, 'DHL') ('Ana', 11, 'UPS') ('Ben', None, None) ('Cy', None, None)
There are two ways to fix this. The first is to keep the chain LEFT all the way down, which is the first result above. Every customer survives, and Ben's unshipped order is still visible with a NULL carrier. The second is to parenthesize: A LEFT JOIN (B JOIN C ON ...) ON .... This joins B and C first, then attaches that result to A. The third result shows what this means. The inner part keeps only orders that have shipments, so Ben's unshipped order 12 never reaches the outer join and Ben appears with NULL. The two fixes give different answers, so pick the one that matches the question being asked.
| Chain shape | Ana | Ben | Cy | Rows |
|---|---|---|---|---|
| A LEFT B LEFT C | 2 shipped orders | order 12, carrier NULL | kept, NULLs | 4 |
| A LEFT B INNER C | 2 shipped orders | dropped | dropped | 2 |
| A LEFT (B INNER C) | 2 shipped orders | kept, order NULL | kept, NULLs | 4 |
A chain that starts with LEFT JOIN and later contains a plain JOIN has usually lost its outer rows without anyone noticing. Whenever you read a plain JOIN below a LEFT JOIN, ask whether it should be LEFT, or whether it belongs inside parentheses with the table it depends on.
Fan and chasm traps
The fan trap happens when one parent row is joined to two child tables that have nothing to do with each other. Ana has 2 orders and 3 support tickets. Joined together, each order meets each ticket, giving 2 × 3 = 6 rows. The orders and tickets are never related to each other, but the join multiplies them anyway. The visible symptom is an inflated SUM or COUNT: Ana's spend is counted three times and her tickets twice.
The fix is to collapse each child to one row per parent before it enters the chain, so every join is at most many-to-one against the parent. A subquery with GROUP BY does this. Engines that support LATERAL can do the same with a correlated aggregate per customer. The block below compares the naive chain with the pre-aggregated one.
run("naive chain", "SELECT c.name, SUM(o.amount) AS spend, COUNT(t.ticket_id) AS tickets " "FROM customers c " "LEFT JOIN orders o ON o.cust_id = c.cust_id " "LEFT JOIN tickets t ON t.cust_id = c.cust_id " "GROUP BY c.cust_id ORDER BY c.cust_id") run("pre-aggregated", "SELECT c.name, COALESCE(o.spend, 0), COALESCE(t.tickets, 0) " "FROM customers c " "LEFT JOIN (SELECT cust_id, SUM(amount) AS spend " " FROM orders GROUP BY cust_id) o ON o.cust_id = c.cust_id " "LEFT JOIN (SELECT cust_id, COUNT(*) AS tickets " " FROM tickets GROUP BY cust_id) t ON t.cust_id = c.cust_id " "ORDER BY c.cust_id")
naive chain ('Ana', 240, 6) ('Ben', 20, 1) ('Cy', None, 0) pre-aggregated ('Ana', 80, 3) ('Ben', 20, 1) ('Cy', 0, 0)
Ana's true spend is 80 with 3 tickets, but the naive chain reports 240 and 6. Ben looks fine only by luck, because he has one order and one ticket. A bug that appears only for some rows is easy to miss in a quick check.
The chasm trap is the same multiplication seen from the other side. Two tables each point many-to-one at a shared dimension, such as sales and refunds that both carry a store_id. They share a store but have no relationship to each other. Joining both to the store pairs every sale with every refund from the same store. Those pairs correspond to no real event.
db.executescript(""" CREATE TABLE stores (store_id INT, name TEXT); CREATE TABLE sales (sale_id INT, store_id INT); CREATE TABLE refunds (refund_id INT, store_id INT); INSERT INTO stores VALUES (1,'North'),(2,'South'); INSERT INTO sales VALUES (1,1),(2,1),(3,2); INSERT INTO refunds VALUES (7,1),(8,1),(9,2); """) run("sales x refunds through stores", "SELECT st.name, sa.sale_id, rf.refund_id FROM stores st " "JOIN sales sa ON sa.store_id = st.store_id " "JOIN refunds rf ON rf.store_id = st.store_id " "ORDER BY st.store_id, sa.sale_id, rf.refund_id")
sales x refunds through stores ('North', 1, 7) ('North', 1, 8) ('North', 2, 7) ('North', 2, 8) ('South', 3, 9)
North produces four rows from two sales and two refunds. 'Sale 1 with refund 7' is not a fact anywhere in the data. The remedy is the same as for the fan trap: aggregate sales and refunds separately per store, then join the two summaries.
| Trap | Shape | Symptom | Fix |
|---|---|---|---|
| Fan | One parent, two independent child tables | SUM and COUNT too large by a multiple | Pre-aggregate each child, or use a lateral join |
| Chasm | Two many:1 paths meeting at a shared dimension | Rows pairing facts that are unrelated | Aggregate each path per dimension key, then join |
| Star schema | One fact table, many dimensions, each many:1 | None: row count equals fact rows | The safe design to aim for |
Adding DISTINCT or COUNT(DISTINCT ...) after a total comes out too large hides the symptom without removing the cause. It cannot repair a SUM, because duplicate rows with equal amounts collapse into one. It also adds a sort or hash step you did not need. Find the join that multiplies rows and fix that one.
Designing chains you can trust
The safest shape for a wide query is the star schema pattern. One fact table, such as orders or events, is the driving table. Each descriptive dimension is LEFT JOINed to it on a surrogate key. Every one of those joins is many-to-one, since many fact rows point to one dimension row, so no join can multiply the fact rows. The result has exactly one row per fact row, and no dimension can remove a fact because every join is LEFT.
SELECT f.order_id, f.amount, c.name AS customer, p.title AS product, d.month FROM fact_orders f LEFT JOIN dim_customer c ON c.customer_key = f.customer_key LEFT JOIN dim_product p ON p.product_key = f.product_key LEFT JOIN dim_date d ON d.date_key = f.date_key;
Fact first, dimensions after; each join is many:1.
That layout also shows two habits for any multi-join query. First, order the FROM clause to tell a story: the driving or fact table first, then the lookups it needs. For inner joins the optimizer ignores your order and picks its own, so this is purely for readers. For outer joins the order does carry meaning, as the earlier examples showed, so it is worth getting right.
Second, alias every table and qualify every column. In a six-table query, a bare name or created_at is ambiguous. Worse, a query that works today can start failing with an 'ambiguous column' error the day someone adds a name column to another table in the chain. Short, meaningful aliases such as c, o and s cost nothing.
SELECT name, amount FROM ... works until a table in the chain gains a column with the same name. Then it either errors or, in some engines and with some combinations, picks one silently. Write c.name and o.amount every time.
When a query reaches nine tables, break it into named steps with CTEs. Each CTE joins a few tables and gives the result a name, and the final query joins the named pieces. On most engines the optimizer flattens simple CTEs back into one plan, so the semantics and usually the speed are unchanged. The only difference is that people can read it. CTEs are also the natural place to pre-aggregate children before a join.
WITH order_context AS ( SELECT o.order_id, o.amount, c.name, r.region_name FROM orders o LEFT JOIN customers c ON c.cust_id = o.cust_id LEFT JOIN regions r ON r.region_id = c.region_id ), order_tickets AS ( SELECT cust_id, COUNT(*) AS tickets FROM tickets GROUP BY cust_id ), order_shipping AS ( SELECT order_id, MIN(carrier) AS carrier FROM shipments GROUP BY order_id )) SELECT oc.*, os.carrier FROM order_context oc LEFT JOIN order_shipping os ON os.order_id = oc.order_id;
Illustrative: three named steps instead of one long chain.
Finally, run a sanity drill while you build a chain: add one join, then check the row count. Each join should change the count only in a way you can explain. If the count jumps unexpectedly, the newest join is many-to-many against what you already had. The block below runs this on our data, adding one join at a time.
base = "FROM customers c" steps = [("start", ""), ("+ orders", "LEFT JOIN orders o ON o.cust_id = c.cust_id"), ("+ tickets", "LEFT JOIN tickets t ON t.cust_id = c.cust_id")] for label, join in steps: base += " " + join n = db.execute("SELECT COUNT(*) " + base).fetchone()[0] print(f"{label:<10} {n}")
start 3 + orders 4 + tickets 8
Orders add one row because Ana has two orders, and that growth is expected for a one-to-many join. Tickets then double the count from 4 to 8, which no customer-level question explains. The tickets join is many-to-many against orders, and it is the one to fix.
A chain is (A JOIN B) JOIN C, and each join sees everything before it. Keep outer joins LEFT or parenthesize the inner part, alias every table, put the fact table first, pre-aggregate independent children, and count rows after every join.
Part 16 · UNION and UNION ALL
Stacking rows: UNION versus UNION ALL
Joins make a result wider by adding columns. The set operators make it longer by adding rows. UNION takes two result sets and stacks them one on top of the other. It then removes every duplicate row from the combined set, so a row that appears in both inputs is returned once. UNION ALL stacks the same two inputs and keeps every row, duplicates included.
The difference matters for cost as well as for the result. To remove duplicates, UNION has to compare rows with each other, either by sorting the whole combined set or by building a hash table of it. UNION ALL does no comparison at all. It just appends the second input to the first.
| UNION | UNION ALL | |
|---|---|---|
| Duplicates across the two sides | Removed | Kept |
| Duplicates inside one side | Also removed | Kept |
| Extra work | Sort or hash over the whole result | None, rows are appended |
| Rows returned | Distinct rows only | Rows in A plus rows in B |
| Use it when | Duplicates would be wrong | Branches cannot overlap, or you want every row |
Here is the difference on a small example. The sample data has a users table and a users_archive table. Ben and a row with a NULL name appear in both. The helper show runs a query and prints each row, with NULL appearing as None.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE users (id INT, name TEXT); CREATE TABLE users_archive (id INT, name TEXT); INSERT INTO users VALUES (1, 'Asha'), (2, 'Ben'), (3, NULL); INSERT INTO users_archive VALUES (2, 'Ben'), (3, NULL), (4, 'Dev'); ''') def show(sql): for row in db.execute(sql): print(row) print('UNION') show('''SELECT id, name FROM users UNION SELECT id, name FROM users_archive ORDER BY id''') print('UNION ALL') show('''SELECT id, name FROM users UNION ALL SELECT id, name FROM users_archive ORDER BY id''')
UNION (1, 'Asha') (2, 'Ben') (3, None) (4, 'Dev') UNION ALL (1, 'Asha') (2, 'Ben') (2, 'Ben') (3, None) (3, None) (4, 'Dev')
Look at the row (3, None). A join predicate never treats NULL as equal to NULL, but duplicate detection does. UNION therefore collapses the two NULL-name rows into one, because it considers the whole row identical. This is the opposite of what you saw with join keys, and it follows the same rule as DISTINCT and GROUP BY.
UNION ALL is strictly cheaper, because UNION is the same work plus a dedupe pass. Write UNION ALL by default and switch to UNION only when you can name the duplicates you need removed.
The rules both sides must follow
Stacking only works if the two result sets have the same shape. Both SELECTs must return the same number of columns, in the same order, with compatible types position by position. The engine matches columns by position and never by name, so the third column of the first query is paired with the third column of the second even if their names differ.
The column names come from the first SELECT. Any aliases you put on the second branch are ignored. Because of this, alias the columns in the first branch and treat the later branches as positional.
When rows from several branches end up in one list, you usually want to know where each one came from. The fix is to tag your branches with a constant column. In the example, the first branch names that column source, and the second branch only needs to supply the value.
cur = db.execute(''' SELECT 'active' AS source, id, name AS label FROM users UNION ALL SELECT 'archived', id, name FROM users_archive ORDER BY source, id''') print([c[0] for c in cur.description]) for row in cur: print(row)
['source', 'id', 'label'] ('active', 1, 'Asha') ('active', 2, 'Ben') ('active', 3, None) ('archived', 2, 'Ben') ('archived', 3, None) ('archived', 4, 'Dev')
The output header is source, id, label, all taken from the first branch. The tag also settles the duplicate question in a useful way. If you tag the rows and then use plain UNION, the tags make the rows differ, so nothing is removed. When you tag branches, UNION ALL is the right operator.
The last rule is about types. If one side supplies an integer and the other text in the same position, engines disagree. Some raise an error. Others silently coerce the integers to text, which changes how the values sort ('10' comes before '9'). Do not rely on either behaviour. Cast one side explicitly so the result type is a choice you made.
-- may error, or silently turn ids into text SELECT id FROM users UNION ALL SELECT 'n/a'; -- explicit and portable SELECT CAST(id AS TEXT) AS id FROM users UNION ALL SELECT 'n/a';
Make the target type visible in the query
If the second branch lists name, id where the first lists id, name, the engine pairs the columns by position. It either raises a type error or, worse, stacks names under ids when the types happen to be compatible. Check the column order on every branch.
ORDER BY, LIMIT and a Total row
A set operation produces a single combined result, so ORDER BY belongs to that whole result. You write it once, after the last SELECT. It cannot sort one branch on its own, and it can only refer to the output columns, either by the names from the first SELECT or by position, such as ORDER BY 1. An expression over the original table columns is not allowed here, and several engines will reject it.
LIMIT on a single branch is different. The clause would otherwise attach to the whole statement, so to limit one branch you put that branch in parentheses. Each parenthesised branch can then carry its own ORDER BY and LIMIT, and the final ORDER BY after the closing parenthesis still applies to everything.
(SELECT id FROM users ORDER BY id DESC LIMIT 5) UNION ALL (SELECT id FROM users_archive ORDER BY id LIMIT 5) ORDER BY id;
Postgres and MySQL. SQLite does not accept parentheses here, so wrap each branch as SELECT * FROM (SELECT ... LIMIT 5).
One common use of UNION ALL is adding a synthetic Total row to a report. The first branch groups the data, and the second branch computes the grand total and labels it. Because the total must come last, the query adds a small sort column to both branches and orders by it.
db.executescript(''' CREATE TABLE sales (region TEXT, amount INT); INSERT INTO sales VALUES ('North', 120), ('South', 80), ('North', 40); ''') show(''' SELECT 0 AS ord, region, SUM(amount) AS total FROM sales GROUP BY region UNION ALL SELECT 1, 'Total', SUM(amount) FROM sales ORDER BY ord, region ''')
(0, 'North', 160) (0, 'South', 80) (1, 'Total', 240)
The Total row must use UNION ALL. With plain UNION, a region whose sum happened to equal the grand total would not be removed (the labels differ), but you would still pay for a pointless sort. Without the ord column, nothing guarantees the Total lands last, since a set operation has no inherent order.
Writing ORDER BY after the first SELECT is a syntax error in most engines. Where it is accepted, as a parenthesised branch, it only matters if that branch also has a LIMIT. Otherwise the engine is free to discard the ordering.
Where UNION earns its place, and the OR rewrite
UNION ALL fits any case where the same kind of row lives in several places and you want one list. The three common cases are below.
orders_2026_01, orders_2026_02, ...
same columns, one table per period or shard
UNION ALL, tagged with the table
grouped rows from one branch
grand total from the other
UNION ALL, sorted with a helper column
customers flagged by rule A
customers flagged by rule B
UNION removes the overlap
The third case is the one where UNION, rather than UNION ALL, is correct. If a customer can match both rules and you want each customer once, the dedupe is the whole point. Even then, prefer to select only the key columns so that the comparison is cheap and the rows really are identical.
UNION is not a substitute for OR in a WHERE clause. They answer different questions: OR filters one set of rows, while UNION combines two queries, and UNION also removes duplicate rows that the OR version would keep. The rewrite is sometimes worth doing anyway, because a single OR across two different columns can stop the planner from using either index. As a UNION ALL, each branch is a plain filter that can use its own index.
-- one filter, two columns: the planner may scan everything SELECT * FROM orders WHERE customer_id = 7 OR salesperson_id = 12; -- each branch seeks its own index SELECT * FROM orders WHERE customer_id = 7 UNION ALL SELECT * FROM orders WHERE salesperson_id = 12 AND customer_id IS DISTINCT FROM 7;
The second branch excludes rows the first already returned, so UNION ALL gives the same rows as the OR
The exclusion in the second branch is what keeps the rewrite equivalent. Without it, an order for customer 7 handled by salesperson 12 would appear twice. Using UNION instead would hide that problem, but it would also collapse genuinely duplicate rows and add the dedupe sort. The NULL-safe IS DISTINCT FROM matters too, since a plain <> 7 would drop rows whose customer is NULL. MySQL spells it NOT (customer_id <=> 7).
Same shape, same column order, names from the first SELECT. UNION ALL stacks and UNION also dedupes, treating NULL as equal to NULL. Cast mismatched types, tag the branches, write ORDER BY once at the end, and wrap any branch with a LIMIT in parentheses.
Part 17 · INTERSECT and EXCEPT
Rows in both, rows in one
UNION stacks two result sets. INTERSECT and EXCEPT compare them instead. INTERSECT keeps only the rows that appear in both result sets. EXCEPT keeps the rows from the first set that do not appear in the second. Oracle spells that second operator MINUS. Both remove duplicates from their output by default, the same way UNION does.
- 1Run both SELECTssame column count, compatible types
- 2Compare whole rowsevery projected column, not one key
- 3Keep or dropINTERSECT keeps shared rows, EXCEPT keeps first-only rows
- 4Deduplicateeach surviving row appears once
The second step is where people go wrong. These operators have no key column and no ON clause. Two rows are the same only when every projected column is equal. If your SELECT lists user_id, plan, a user who moved from the free plan to the pro plan is a different row, not the same user seen twice.
The example below uses SQLite from the Python standard library, which has both operators. It also shows how the order of operands matters for one operator but not the other.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE march (user_id INT, plan TEXT); CREATE TABLE april (user_id INT, plan TEXT); INSERT INTO march VALUES (1,'free'),(2,'pro'),(3,'pro'),(3,'pro'),(4,'free'); INSERT INTO april VALUES (2,'pro'),(3,'free'),(4,'free'),(5,'pro'); ''') def q(op, cols='user_id, plan', a='march', b='april'): sql = f'SELECT {cols} FROM {a} {op} SELECT {cols} FROM {b} ORDER BY 1' return db.execute(sql).fetchall() print('INTERSECT, whole row:', q('INTERSECT')) print('INTERSECT, user_id only:', q('INTERSECT', 'user_id')) print('march EXCEPT april:', q('EXCEPT')) print('april EXCEPT march:', q('EXCEPT', a='april', b='march'))
User 3 is pro in March and free in April, so the whole row differs
INTERSECT, whole row: [(2, 'pro'), (4, 'free')] INTERSECT, user_id only: [(2,), (3,), (4,)] march EXCEPT april: [(1, 'free'), (3, 'pro')] april EXCEPT march: [(3, 'free'), (5, 'pro')]
Look at user 3. Compared on user_id alone, the user is in both months. Compared on the whole row, the user is not shared, because the plan changed. March also held two identical copies of (3, 'pro'), and EXCEPT returned a single row for them. The last two lines show that swapping the operands changes the answer.
| INTERSECT | EXCEPT | |
|---|---|---|
| Keeps | Rows present in both sets | Rows in the first set and not in the second |
| A op B vs B op A | Same rows (commutative) | Different rows (not commutative) |
| Oracle name | INTERSECT | MINUS |
| Default output | Deduplicated | Deduplicated |
Writing SELECT * FROM a INTERSECT SELECT * FROM b and expecting it to match on id alone. Every column takes part, so one differing updated_at makes the rows unequal. If you only care about some columns, project only those columns, or use EXISTS and NOT EXISTS.
Duplicates, NULLs and precedence
By default these operators work on sets, so copies collapse. The ALL variants, INTERSECT ALL and EXCEPT ALL, work on multisets and count copies. Say the left side holds a row three times and the right side holds it once. INTERSECT ALL keeps the smaller count, so it returns 1 copy. EXCEPT ALL subtracts counts, so it returns 2 copies. Postgres supports both ALL variants. SQL Server does not, and MySQL is not somewhere to count on them either.
| Left has 3 copies, right has 1 | Rows returned |
|---|---|
| INTERSECT | 1 |
| INTERSECT ALL | 1 |
| EXCEPT | 0 |
| EXCEPT ALL | 2 |
Python's Counter follows the same rules. Its & takes the minimum count per item, and its - subtracts counts and floors them at zero. That makes it a handy way to see the arithmetic.
from collections import Counter left = Counter({'x': 3}) right = Counter({'x': 1}) print('INTERSECT:', len(set(left) & set(right))) print('INTERSECT ALL:', sum((left & right).values())) print('EXCEPT:', len(set(left) - set(right))) print('EXCEPT ALL:', sum((left - right).values()))
INTERSECT: 1 INTERSECT ALL: 1 EXCEPT: 0 EXCEPT ALL: 2
NULLs behave differently here than in a join. In a join predicate NULL = NULL is UNKNOWN, so NULL keys never match. In set operators two NULLs count as equal. A row of all NULLs on both sides therefore counts as a duplicate, and it appears in an INTERSECT. The next example runs the same data through both mechanisms.
db.executescript(''' CREATE TABLE x (a INT, b INT); CREATE TABLE y (a INT, b INT); INSERT INTO x VALUES (NULL,NULL),(NULL,NULL),(1,NULL); INSERT INTO y VALUES (NULL,NULL),(1,2); ''') print('INTERSECT:', q('INTERSECT', 'a, b', 'x', 'y')) print('x EXCEPT y:', q('EXCEPT', 'a, b', 'x', 'y')) n = db.execute('SELECT COUNT(*) FROM x JOIN y ON x.a = y.a AND x.b = y.b').fetchone()[0] print('join on equality:', n)
Reuses the db and q helper from the first example
INTERSECT: [(None, None)] x EXCEPT y: [(1, None)] join on equality: 0
Now precedence. In the standard, and in Postgres, INTERSECT binds tighter than UNION and EXCEPT. UNION and EXCEPT share a level and group left to right. Mixing them without brackets can therefore give a result you did not intend. SQLite is the exception: it treats all compound operators equally, left to right, and has no brackets.
| Query | Parsed as | Sets | Result |
|---|---|---|---|
A UNION B INTERSECT C | A UNION (B INTERSECT C) | A = 1; B = 2, 3; C = 3 | 1, 3 (left to right would give 3) |
A EXCEPT B EXCEPT C | (A EXCEPT B) EXCEPT C | A = 1, 2, 3; B = 2, 3; C = 3 | 1 (right-nested would give 1, 3) |
A query like a UNION b INTERSECT c EXCEPT d is legal and runs without error. It is also easy to misread. Parenthesize every time you mix operators, even where the default grouping happens to match what you meant.
Reconciling tables and everyday use
The best-known use of EXCEPT is checking whether two tables hold the same data. Subtract in both directions and put the results together: (A EXCEPT B) UNION ALL (B EXCEPT A). If the result is empty, nothing is in one table and missing from the other. Teams run this on the old and new versions of a query before merging a rewrite.
The second question in the flowchart matters because EXCEPT removes duplicates before it compares. A table with one copy of a row and a table with two copies look identical to it. The example below shows the trap, then closes it by numbering the copies of each row. That is a portable stand-in for EXCEPT ALL, and it also works on engines that lack the ALL variants.
db.executescript(''' CREATE TABLE t1 (a INT, b TEXT); CREATE TABLE t2 (a INT, b TEXT); CREATE TABLE t3 (a INT, b TEXT); INSERT INTO t1 VALUES (1,'a'),(2,'b'); INSERT INTO t2 VALUES (1,'a'),(2,'b'),(2,'b'); INSERT INTO t3 VALUES (1,'a'),(2,'c'); ''') def diff(x, y): sql = f'''SELECT * FROM (SELECT * FROM {x} EXCEPT SELECT * FROM {y}) UNION ALL SELECT * FROM (SELECT * FROM {y} EXCEPT SELECT * FROM {x})''' return db.execute(sql).fetchall() def numbered(t): return f'SELECT a, b, ROW_NUMBER() OVER (PARTITION BY a, b ORDER BY a) AS n FROM {t}' def diff_all(x, y): sql = f'SELECT * FROM ({numbered(x)}) EXCEPT SELECT * FROM ({numbered(y)})' return db.execute(sql).fetchall() print('t1 vs t3:', diff('t1', 't3')) print('t1 vs t2:', diff('t1', 't2')) print('row counts:', [db.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0] for t in ('t1', 't2')]) print('t2 EXCEPT ALL t1:', diff_all('t2', 't1'))
t1 vs t3: [(2, 'b'), (2, 'c')] t1 vs t2: [] row counts: [2, 3] t2 EXCEPT ALL t1: [(2, 'b', 2)]
t1 vs t2 came back empty even though t2 has an extra copy of (2, 'b'). A plain EXCEPT check can say two tables match when their row multiplicities differ. Use EXCEPT ALL where it exists, compare COUNT(*) as well, or number the copies as above.
MySQL only gained INTERSECT and EXCEPT in version 8.0.31. On an older server you emulate them. Use EXISTS or NOT EXISTS, compare every column with the NULL-safe operator <=>, and add DISTINCT to match the set semantics.
-- A INTERSECT B, before MySQL 8.0.31 SELECT DISTINCT a.* FROM a WHERE EXISTS (SELECT 1 FROM b WHERE a.x <=> b.x AND a.y <=> b.y); -- A EXCEPT B SELECT DISTINCT a.* FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE a.x <=> b.x AND a.y <=> b.y);
In day-to-day work the two operators answer membership questions. INTERSECT finds audience overlap: the users who are in both of two segments. EXCEPT finds churn: last month's users minus this month's. Both queries project a single id column, so comparing the whole row is the same as comparing the key.
-- Overlap: users in both segments SELECT user_id FROM segment_a INTERSECT SELECT user_id FROM segment_b; -- Churn: active last month, gone this month SELECT user_id FROM active_users WHERE month = '2026-03' EXCEPT SELECT user_id FROM active_users WHERE month = '2026-04';
Both operators compare whole rows, treat NULLs as equal, and deduplicate unless you ask for ALL. EXCEPT depends on operand order and INTERSECT does not. Parenthesize when you mix operators.
Part 18 · Set Operations vs Joins: Choosing and Converting
Which direction does the data grow?
Joins and set operations both combine two queries, but they grow the result along different axes. A join is wider: each output row carries the columns of both inputs, so you get more attributes about the same entity. A set operation is longer: the output has the same columns as its inputs and only the number of rows changes. Before you pick a tool, ask which way you want the data to grow.
| Join | Set operation | |
|---|---|---|
| Grows | Sideways: more columns | Downward: more or fewer rows |
| Output shape | Columns of A plus columns of B | Same columns as both inputs |
| Typical question | What else do we know about this customer? | Which rows appear in both lists, or in only one? |
The shape of the inputs is the quickest check. A set operation demands that both sides have the same number of columns with compatible types, in the same order. A join has no such rule, since you can join a 2-column table to a 9-column one. If you find yourself padding queries with dummy columns or casting just to line them up, you are probably reaching for a set operation when you wanted a join. If you find yourself joining two same-shaped tables only to stack them, you wanted UNION ALL.
Adding NULL AS col placeholders to make a UNION compile, or joining on a made-up key to stack two lists, are both signs that the axis is wrong. Stop and decide whether you are adding columns or adding rows.
There is one more asymmetry. UNION ALL has no join equivalent at all. A FULL OUTER JOIN may look similar because it keeps unmatched rows from both sides, but it places them side by side, with NULLs padding the columns of the other table. A row that exists in both tables becomes one wide row, not two stacked rows. UNION ALL keeps both rows, stacked in one shared set of columns.
INTERSECT and EXCEPT as semi- and anti-joins
INTERSECT and EXCEPT are really membership tests on whole rows, so they can be rewritten with the semi-join and anti-join forms from earlier. A INTERSECT B returns each distinct row of A that has an identical row in B. That is a semi-join on all columns, followed by DISTINCT, because INTERSECT removes duplicates.
SELECT DISTINCT a.k, a.v FROM a WHERE EXISTS ( SELECT 1 FROM b WHERE b.k IS NOT DISTINCT FROM a.k AND b.v IS NOT DISTINCT FROM a.v );
A INTERSECT B, written as a semi-join on every column
The comparison must be NULL-safe on every column. A plain = returns UNKNOWN when either side is NULL, so a row such as (NULL, 'z') would never match itself. Set operators treat two NULLs as equal, so the rewrite has to as well. IS NOT DISTINCT FROM does that in Postgres. SQLite spells the same test IS, and MySQL uses <=>.
A EXCEPT B is the mirror image: distinct rows of A with no identical row in B. That is an anti-join on all columns. Use NOT EXISTS with the same NULL-safe comparison on every column, and keep the DISTINCT.
SELECT DISTINCT a.k, a.v FROM a WHERE NOT EXISTS ( SELECT 1 FROM b WHERE b.k IS NOT DISTINCT FROM a.k AND b.v IS NOT DISTINCT FROM a.v );
A EXCEPT B, written as an anti-join on every column
Proof by example, and when only some columns matter
The example below runs the set operators and the rewrites on two small tables, a and b. Table a holds a duplicated row (1, 'x') and a row with a NULL key. Table b also has a NULL-key row. The last query is a naive INNER JOIN on both columns, which shows two things: it multiplies the duplicated row, and it loses the NULL row because NULL = NULL is not true.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE a (k INT, v TEXT); CREATE TABLE b (k INT, v TEXT); INSERT INTO a VALUES (1,'x'),(2,'y'),(NULL,'z'),(1,'x'); INSERT INTO b VALUES (1,'x'),(NULL,'z'),(3,'w'); ''') def run(sql): return db.execute(sql).fetchall() print('INTERSECT:', run('SELECT k, v FROM a INTERSECT SELECT k, v FROM b ORDER BY 1, 2')) print('EXISTS:', run('SELECT DISTINCT k, v FROM a WHERE EXISTS (SELECT 1 FROM b WHERE b.k IS a.k AND b.v IS a.v) ORDER BY 1, 2')) print('JOIN:', run('SELECT a.k, a.v FROM a JOIN b ON a.k = b.k AND a.v = b.v ORDER BY 1, 2')) print('EXCEPT:', run('SELECT k, v FROM a EXCEPT SELECT k, v FROM b ORDER BY 1, 2')) print('NOT EXISTS:', run('SELECT DISTINCT k, v FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.k IS a.k AND b.v IS a.v) ORDER BY 1, 2'))
INTERSECT: [(None, 'z'), (1, 'x')] EXISTS: [(None, 'z'), (1, 'x')] JOIN: [(1, 'x'), (1, 'x')] EXCEPT: [(2, 'y')] NOT EXISTS: [(2, 'y')]
The NULL-safe EXISTS and NOT EXISTS forms agree exactly with INTERSECT and EXCEPT. The plain join does not: it returns the duplicated (1, 'x') twice and drops (NULL, 'z') entirely.
The flip side matters just as much. INTERSECT and EXCEPT always compare the full projected row. If the question is ''which customers appear in both lists, judged by customer id alone'', then the other columns, such as a name or a timestamp, must not take part in the comparison. Projecting only the id is sometimes enough, but if you need other columns in the output, you must use a join or EXISTS on just the key. Using INTERSECT or EXCEPT there is simply wrong.
Writing SELECT * FROM old EXCEPT SELECT * FROM new to find ids that disappeared will also report every row whose other columns changed. Compare only the key with NOT EXISTS.
Row multiplication, performance, and a side-by-side table
Joins can multiply rows. If a customer has three orders, a join to orders yields three rows for that customer. INTERSECT and EXCEPT never multiply: their output has at most one copy of each row, whatever the input multiplicities. That determinism is a good reason to prefer them, or an EXISTS form, for pure membership questions such as ''who is in both lists''. A join answers that question only if you remember to deduplicate afterwards.
Performance points the other way, though. INTERSECT and EXCEPT typically sort or hash both inputs completely so they can compare whole rows. A correlated NOT EXISTS can instead probe an index on the large side for each row of the small side, and it can stop at the first match. When one side is huge and indexed, the EXISTS form often wins. When both sides are modest, or you want the simplest query, the set operator is clear and fast enough.
| Operator | Dedupes? | Can multiply rows? | Needs same shape? | NULL = NULL? |
|---|---|---|---|---|
| INNER / LEFT / FULL JOIN | no | yes | no | no |
| UNION | yes | no | yes | yes |
| UNION ALL | no | no | yes | not compared |
| INTERSECT | yes | no | yes | yes |
| EXCEPT | yes | no | yes | yes |
Read the first row as the joins and the rest as the set operations. Joins answer no, yes, no, no. The deduplicating set operations answer yes, no, yes, yes. UNION ALL never compares rows, so the NULL column does not apply to it.
A decision flow for choosing the tool
Put the rules together by asking what the result should look like. If you have the same entity and want more attributes about it, the result grows wider, so use a join. If you have rows of the same shape from several places and want them all in one list, the result grows longer, so use UNION ALL. If you are asking whether a row exists elsewhere, you have a membership question: use EXISTS or NOT EXISTS, or INTERSECT and EXCEPT when whole rows are being compared.
Joins widen, set operations lengthen. Membership questions on a key belong to EXISTS; on a whole row, to INTERSECT and EXCEPT. A shape mismatch means you probably picked the wrong tool.
Part 19 · Common Mistakes and How to Catch Them
Filters, Fan-Outs and Counts That Lie
Most join bugs do not raise an error. The query runs, returns rows, and the numbers look plausible. That is what makes them dangerous. This section walks through the mistakes that silently corrupt a result set, shows the symptom each one leaves, and gives the fix. The habits for catching them early are collected under 'Verification habits' and 'Reconciliation before you ship'.
Filtering the right table in WHERE after a LEFT JOIN
A LEFT JOIN promises to keep every left row. It keeps that promise by padding the right-side columns with NULL when nothing matches. A WHERE clause runs after that padding, and a padded NULL fails nearly every comparison, so the unmatched rows are thrown away again. The query is now an inner join wearing an outer join's syntax. Putting the same predicate in ON filters the right table before the padding happens, so unmatched left rows survive.
| Predicate in ON | Predicate in WHERE | |
|---|---|---|
| When it runs | While matching, before NULL padding | After the join, on the padded rows |
| Left row with no qualifying match | Kept, right columns are NULL | Dropped, because NULL = 2024 is UNKNOWN |
| Effective join type | LEFT JOIN | INNER JOIN |
| Right choice when | You want all left rows, filtered matches | You really want only matched rows (then say INNER) |
COUNT(*) after a LEFT JOIN is not 'orders per customer'
A customer with zero orders still produces one output row, the padded one. COUNT(*) counts rows, so it reports 1 for that customer. COUNT(o.order_id) counts only non-NULL order ids, and a padded row has a NULL there, so it correctly reports 0. Count a non-nullable column from the right table, usually its primary key. The program below runs both filters and both counts on the same three customers. Chen has no orders, and Ben has only a 2023 order.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(""" CREATE TABLE customers (cust_id INT PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id INT PRIMARY KEY, cust_id INT, year INT); INSERT INTO customers VALUES (1, 'Asha'), (2, 'Ben'), (3, 'Chen'); INSERT INTO orders VALUES (10, 1, 2024), (11, 1, 2023), (12, 2, 2023); """) def show(label, sql): print(label, db.execute(sql).fetchall()) J = 'FROM customers c LEFT JOIN orders o ON o.cust_id = c.cust_id' show('WHERE 2024:', f'SELECT c.name, o.order_id {J} WHERE o.year = 2024') show('ON 2024 :', f'SELECT c.name, o.order_id {J} AND o.year = 2024') show('COUNT(*) :', f'SELECT c.name, COUNT(*) {J} GROUP BY c.name') show('COUNT(id) :', f'SELECT c.name, COUNT(o.order_id) {J} GROUP BY c.name')
Same join, four different answers
WHERE 2024: [('Asha', 10)] ON 2024 : [('Asha', 10), ('Ben', None), ('Chen', None)] COUNT(*) : [('Asha', 2), ('Ben', 1), ('Chen', 1)] COUNT(id) : [('Asha', 2), ('Ben', 1), ('Chen', 0)]
Any WHERE on the right table of a LEFT JOIN is a bug until proven otherwise. The one deliberate exception is WHERE o.order_id IS NULL, which is the anti-join filter. If you truly want only matched rows, write INNER JOIN so the next reader does not have to guess.
Chen shows 1 order under COUNT(*) and 0 under COUNT(o.order_id). Only the second is true. Wrap sums in COALESCE(SUM(o.amount), 0) as well, because an unmatched group sums to NULL, not 0.
Many:many fan-out, then SUM over the inflated rows
When two tables are both on the many side of the same key, joining them pairs every row of one with every row of the other for that key. A customer with 2 payments and 3 support tickets becomes 6 rows. Summing the payment amount over those 6 rows counts each payment 3 times. The symptom to learn: totals that are an exact multiple of the truth, here 3 times too large. The fix is to collapse each branch to one row per key in its own subquery, and only then join.
db.executescript(""" CREATE TABLE payments (cust_id INT, amount INT); CREATE TABLE tickets (cust_id INT, ticket_id INT); INSERT INTO payments VALUES (1, 100), (1, 50); INSERT INTO tickets VALUES (1, 7), (1, 8), (1, 9); """) show('true total :', 'SELECT SUM(amount) FROM payments') show('joined total:', 'SELECT SUM(p.amount) FROM payments p JOIN tickets t ON t.cust_id = p.cust_id') show('pre-summed :', 'SELECT p.total, t.n FROM (SELECT cust_id, SUM(amount) AS total FROM payments GROUP BY cust_id) p JOIN (SELECT cust_id, COUNT(*) AS n FROM tickets GROUP BY cust_id) t ON t.cust_id = p.cust_id')
Reuses db and show from the previous example
true total : [(150,)] joined total: [(450,)] pre-summed : [(150, 3)]
If a total is exactly 2x, 3x or 10x what another report says, suspect a join that multiplies rows before the SUM. Pre-aggregate each branch to one row per key, then join the results.
NULLs, Keys and Duplicate Rows
NOT IN against a subquery that contains NULL
x NOT IN (2, NULL) expands to x <> 2 AND x <> NULL. The second comparison is UNKNOWN for every x, and a row survives a WHERE only when the predicate is TRUE. One NULL in the subquery therefore empties the whole result, with no warning. NOT EXISTS tests whether a matching row is present rather than comparing against a list, so a NULL in the other table simply never matches and the answer stays correct.
db.executescript(""" CREATE TABLE banned (cust_id INT); INSERT INTO banned VALUES (2), (NULL); """) show('NOT IN :', 'SELECT name FROM customers WHERE cust_id NOT IN (SELECT cust_id FROM banned)') show('NOT EXISTS:', 'SELECT name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM banned b WHERE b.cust_id = c.cust_id)')
Customer 2 is banned, and one banned row has a NULL id
NOT IN : [] NOT EXISTS: [('Asha',), ('Chen',)]
The query is correct today and returns nothing the day a NULL appears in the subquery's column. Make NOT EXISTS your default anti-join, and treat every NOT IN over a subquery as a review flag.
Composite keys and NATURAL JOIN
In a multi-tenant table, user_id is only unique inside a tenant. Join on user_id alone and tenant 1's user 1 is matched with tenant 2's user 1. The same hole opens when you forget the date in a (product, day) key or the currency in a (account, currency) key. The result looks like a normal join with a few extra rows, but it is a data leak, not a performance problem. Always join on the full key.
NATURAL JOIN seems to avoid this, because it joins on every column the two tables share. The trouble is that you no longer control the predicate. It is whatever the column names happen to be today. The example below shows the leak first, then a NATURAL JOIN that works, then the moment someone adds an updated_at column to both tables.
db.executescript(""" CREATE TABLE users (tenant_id INT, user_id INT, name TEXT); CREATE TABLE logins (tenant_id INT, user_id INT, at TEXT); INSERT INTO users VALUES (1, 1, 'Asha'), (2, 1, 'Zed'); INSERT INTO logins VALUES (1, 1, 'mon'), (2, 1, 'tue'); """) Q = 'SELECT u.name, l.at FROM users u {} ORDER BY 1, 2' show('user_id only:', Q.format('JOIN logins l ON l.user_id = u.user_id')) show('full key :', Q.format('JOIN logins l ON l.user_id = u.user_id AND l.tenant_id = u.tenant_id')) show('natural :', Q.format('NATURAL JOIN logins l')) db.executescript(""" ALTER TABLE users ADD COLUMN updated_at TEXT; ALTER TABLE logins ADD COLUMN updated_at TEXT; UPDATE users SET updated_at = '09:00'; UPDATE logins SET updated_at = '09:05'; """) show('natural+col :', Q.format('NATURAL JOIN logins l'))
Two tenants that both have a user 1
user_id only: [('Asha', 'mon'), ('Asha', 'tue'), ('Zed', 'mon'), ('Zed', 'tue')] full key : [('Asha', 'mon'), ('Zed', 'tue')] natural : [('Asha', 'mon'), ('Zed', 'tue')] natural+col : []
A missing key column quietly multiplies rows across tenants, dates or currencies. A NATURAL JOIN is correct only until someone adds a same-named column to both tables, after which the predicate changes and the result goes empty or wrong with no error. Spell out every column in ON.
DISTINCT as a bandage over a wrong join
Duplicate rows are a symptom of a join that multiplies, either a many:many pair or a missing key column. Adding DISTINCT makes the duplicates disappear, so the bug report is closed, but the cause is still there. Other columns may be inflated, sums are still wrong, and the engine now sorts or hashes the whole result to remove rows it should never have produced. Find the join that multiplies and fix it instead.
| DISTINCT on top | Fix the multiplying join | |
|---|---|---|
| Duplicate rows | Hidden | Never produced |
| SUM and COUNT over the rows | Still inflated | Correct |
| Extra cost | Sort or hash of the full result | None, and usually fewer rows to join |
| Next schema change | Can mask a new fan-out | Join stays correct |
Index Killers and Set Operator Precedence
Wrapping the join column in a function
An index is built on the stored value of a column. When the join condition is ON UPPER(a.code) = b.code, the engine would have to compute UPPER for every row before it could compare, so it cannot seek into an index on a.code. The query still returns the right rows, only slowly, and the cost grows with table size. Two clean fixes exist: store the data already normalized, so the join compares plain columns, or create a functional index on the exact expression you join on.
Implicit type conversion in ON
Joining a VARCHAR key to an INT key makes the engine convert one side, and the conversion usually lands on the column you wanted to seek. A cast runs per row, an index on the converted column cannot be used for a seek, and the planner's row estimates get worse. The hidden danger is that it works in every test. Make the two columns the same type in the schema.
| Pattern in ON | What goes wrong | Fix |
|---|---|---|
UPPER(a.code) = b.code | Function runs per row, no index seek | Store normalized values, or index UPPER(code) |
a.varchar_key = b.int_key | A cast per row, seek blocked | Give both columns the same type |
a.k = b.k OR (a.k IS NULL AND b.k IS NULL) | Blocks index use | Use IS NOT DISTINCT FROM where supported |
Both patterns return correct rows on a table of 1,000 rows and crawl on 100 million. Read the plan for a scan where you expected a seek, and flag any join predicate that wraps a column in a function or compares unlike types.
Mixing UNION and EXCEPT without parentheses
UNION and EXCEPT have the same precedence and are evaluated left to right, so a UNION b EXCEPT c means (a UNION b) EXCEPT c. If you meant to subtract c from b only, the result is different. INTERSECT is the exception: it binds tighter than both, so a UNION b INTERSECT c means a UNION (b INTERSECT c). The example uses sets where the two readings disagree: a is 1 and 2, b is 3, c is 2. SQLite does not accept parentheses around compound selects, so the groupings are written as subqueries. In PostgreSQL you would write (a UNION b) EXCEPT c directly.
db.executescript(""" CREATE TABLE a (x INT); CREATE TABLE b (x INT); CREATE TABLE c (x INT); INSERT INTO a VALUES (1), (2); INSERT INTO b VALUES (3); INSERT INTO c VALUES (2); """) show('as written :', 'SELECT x FROM a UNION SELECT x FROM b EXCEPT SELECT x FROM c ORDER BY x') show('left grouped :', 'SELECT x FROM (SELECT x FROM a UNION SELECT x FROM b) EXCEPT SELECT x FROM c ORDER BY x') show('right grouped:', 'SELECT x FROM a UNION SELECT x FROM (SELECT x FROM b EXCEPT SELECT x FROM c) ORDER BY x')
as written : [(1,), (3,)] left grouped : [(1,), (3,)] right grouped: [(1,), (2,), (3,)]
The unparenthesized query silently matches the left-associative reading. If you wanted the other grouping, the rows come back wrong with no error. Whenever a query mixes set operators, write the grouping out so the intent is visible.
Verification Habits and Reconciliation
Verification habits
Every mistake in this section changes the number of rows or the values in them. That means a few cheap checks, repeated as you build the query, catch nearly all of them. Add one join at a time and treat each new join as a hypothesis: the row count should move only in the way you can explain.
- 1Run it for one known IDAdd a LIMIT or a WHERE on an ID whose rows you can count by hand
- 2Count rows after each joinA change you cannot explain means the newest join multiplies or filters
- 3Compare with COUNT(DISTINCT primary_key)If rows exceed distinct keys, something fans out
- 4Fix the join, not the outputPre-aggregate or add the missing key column; do not add DISTINCT
| Check | What it catches |
|---|---|
| Row count after each added join | A many:many join, or a WHERE that turned a LEFT JOIN into an inner one |
Rows versus COUNT(DISTINCT order_id) | Fan-out: more rows than distinct entities you are reporting on |
| Run for a known ID with LIMIT | Wrong match logic you can verify by hand, before it hides in a large result |
| Sum on both sides of a join | Totals that are exact multiples of the truth |
Reconciliation before you ship
When you rewrite a query, the old one is your best test. Run both and compute (A EXCEPT B) UNION ALL (B EXCEPT A), which is every row in one result but not the other. An empty answer means the two return the same set of rows, and any rows returned show exactly where they disagree. The program below applies both habits. It first compares the row count of a join with its distinct customers, then reconciles a rewrite against the old query.
old = 'SELECT c.cust_id, c.name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.cust_id)' joined = 'SELECT c.cust_id, c.name FROM customers c JOIN orders o ON o.cust_id = c.cust_id' only24 = joined + ' AND o.year = 2024' def diff(a, b): sql = (f'SELECT * FROM (SELECT * FROM ({a}) EXCEPT SELECT * FROM ({b})) ' f'UNION ALL SELECT * FROM (SELECT * FROM ({b}) EXCEPT SELECT * FROM ({a}))') return db.execute(sql).fetchall() print('rows :', len(db.execute(joined).fetchall())) print('distinct id:', db.execute(f'SELECT COUNT(DISTINCT cust_id) FROM ({joined})').fetchone()[0]) print('old vs join:', diff(old, joined)) print('old vs 2024:', diff(old, only24))
Reuses db, customers and orders from the first example
rows : 3 distinct id: 2 old vs join: [] old vs 2024: [(2, 'Ben')]
The two checks complement each other. The join returns 3 rows for 2 distinct customers, so the row-count check exposes the duplicate for Asha. The reconciliation of the old query against that same join comes back empty, because EXCEPT collapses duplicates and compares only which distinct rows exist. The second reconciliation finds Ben, a customer the 2024-only rewrite lost.
EXCEPT ignores how many times a row appears, so an empty difference does not prove the row counts match. Compare COUNT(*) of both queries too, or use EXCEPT ALL where your engine supports it.
Check the row count after every join, compare it with the count of distinct primary keys, run a known ID first, and reconcile the new query against the old with (A EXCEPT B) UNION ALL (B EXCEPT A). If that difference is empty and the counts agree, you can merge.
Part 20 · Performance Checklist
Index the join keys
Most slow joins are slow for the same few reasons, and the cheapest fixes come first. A nested loop plan is only as fast as the lookup on its inner side, and a merge join is only cheap when its inputs arrive sorted. Both depend on indexes, so the checklist starts there.
1. Index every foreign key column
In a 1:many join, the parent side is the primary key, which is always indexed. The child side, such as orders.customer_id, is the column the engine must search once per parent row, and it usually has no index. Declaring the foreign key does not create one in most engines. Without it, each probe becomes a full scan of the child table.
| Engine | Indexes the FK column automatically? |
|---|---|
| PostgreSQL | No, you create it |
| SQL Server | No, you create it |
| Oracle | No, you create it |
| SQLite | No, you create it |
| MySQL / InnoDB | Yes, it adds one when you declare the FK |
-- child side of customers 1:many orders CREATE INDEX idx_orders_customer_id ON orders (customer_id);
One statement turns a per-row table scan into a per-row index seek
2. Match composite index column order to the predicate
A composite index is sorted by its first column, then by the second within each first-column value. A seek can only use the leading columns of that order. If the join predicate filters on the second column alone, the engine cannot jump into the index and has to scan it.
CREATE INDEX idx_items_order_line ON order_items (order_id, line_no); -- usable for a seek: leading column is in the predicate ... JOIN order_items i ON i.order_id = o.order_id -- not usable for a seek: leading column is missing ... JOIN order_items i ON i.line_no = o.first_line
Put the columns your joins always supply first
3. Cover the query so the table lookup disappears
Normally an index finds the matching row locations and the engine then visits the table to fetch the other columns. A covering index holds the join key plus every column the query selects, so the index alone answers the question and the table visit is skipped.
-- PostgreSQL / SQL Server syntax; the query reads only these columns CREATE INDEX idx_orders_cust_cover ON orders (customer_id) INCLUDE (order_date, amount); SELECT c.name, o.order_date, o.amount FROM customers c JOIN orders o ON o.customer_id = c.customer_id;
Plans show this as an index-only scan
Adding indexes to primary keys changes nothing, because they already exist. The missing index is nearly always on the child column of the join, and a composite index whose first column is not in the ON clause will not rescue it.
Fresh statistics, early filters, lean rows
Indexes give the optimizer options, but the optimizer still has to choose among them. It chooses using estimates, and the estimates come from stored statistics. The next four items keep those estimates honest and keep the join's working set small.
4. Keep statistics fresh
The planner stores row counts, distinct counts, and value histograms for each column. After a bulk load those numbers describe yesterday's table. It may expect 1,000 rows where 5 million arrived, and then pick a nested loop that runs 5 million probes. Refresh statistics right after any large load or delete.
ANALYZE orders; -- PostgreSQL, SQLite, MySQL UPDATE STATISTICS dbo.orders; -- SQL Server
Make this the last step of every bulk load job
5. Push filters down
The fewer rows enter a join, the less work it does. Filter on the large table in a place where the filter runs before the join. Optimizers usually push simple inner-join filters down on their own, but they cannot always do so across outer joins, subqueries with aggregates, or filters wrapped in functions. Writing the filter early removes the doubt.
-- filter first, then join the survivors SELECT c.name, o.amount FROM customers c JOIN (SELECT customer_id, amount FROM orders WHERE order_date >= DATE '2026-01-01') o ON o.customer_id = c.customer_id;
Only this year's orders ever reach the join
For a LEFT JOIN, put filters on the right table in the ON clause or in a subquery. A WHERE on the right table runs after padding and turns the outer join back into an inner one.
6. Select only the columns you need
A hash join builds an in-memory table from one input, and every selected column of every build-side row goes into it. SELECT * over a wide table, with long text or JSON columns, makes each row fat. The hash table is bigger, it fits in memory less often, and spills become more likely. Naming only the needed columns shrinks the build side with no other change.
7. Watch for hash spills in the plan
When the hash table exceeds the memory budget (work_mem in PostgreSQL), the engine splits it into batches and writes them to disk. Each batch is then read back, which can turn a seconds-long join into minutes. The plan tells you when this happens: look for more than one batch.
Hash Join (actual time=412.8..3190.4 rows=2000000 loops=1) Hash Cond: (o.customer_id = c.customer_id) -> Seq Scan on orders o (actual rows=2000000 loops=1) -> Hash (actual time=410.1..410.1 rows=900000 loops=1) Buckets: 131072 Batches: 8 Memory Usage: 4096kB
Batches: 8 means the build side was split and spilled. Raising the memory setting for the session, say SET work_mem = '256MB';, often brings it back to Batches: 1. The query text does not change, so it is the cheapest experiment to try.
| Plan signal | Meaning | First thing to try |
|---|---|---|
| Batches: 1 | Hash table fit in memory | Nothing, leave it |
| Batches: 2 or more | Build side spilled to disk | Drop unused columns, then raise work_mem |
| Estimated rows far below actual | Stale or missing statistics | Run ANALYZE |
Setting a huge work_mem for every connection is risky, because each hash or sort node in each query can claim that much. Raise it for the one session or query that needs it.
Rewrite the shape of the query
Sometimes the index and statistics are fine and the query itself is asking for more work than the question needs. These four items change the shape of the query or the data model.
8. Prefer EXISTS over IN for large subqueries with duplicates
When the question is only whether a match exists, EXISTS can stop at the first match for each outer row. An IN subquery may first collect the whole list, duplicates included, and then test against it. If the subquery returns millions of repeated customer ids, that list is wasted effort. Many optimizers rewrite both forms to the same semi-join, but EXISTS is the form that is dependably cheap, and it stays correct when NULLs appear.
-- may build a long list full of repeated ids SELECT c.* FROM customers c WHERE c.customer_id IN (SELECT customer_id FROM orders); -- stops at the first matching order per customer SELECT c.* FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
Same customers, one row each, less work
9. Replace adjacent-row self joins with window functions
Comparing each row with the previous one by self joining a table to itself reads the table twice and needs a join key that exactly lines up. A window function such as LAG does it in one pass over the data, sorted once. With several comparisons (previous day, previous week, next day), the self-join approach adds a join for each one, while a window adds just another column.
import sqlite3 con = sqlite3.connect(":memory:") con.execute("CREATE TABLE daily (day INT, value INT)") con.executemany("INSERT INTO daily VALUES (?, ?)", [(1, 10), (2, 15), (3, 13), (4, 20)]) self_join = con.execute( "SELECT t.day, t.value - p.value FROM daily t " "LEFT JOIN daily p ON p.day = t.day - 1 ORDER BY t.day").fetchall() window = con.execute( "SELECT day, value - LAG(value) OVER (ORDER BY day) " "FROM daily ORDER BY day").fetchall() print(self_join) print(window) print(self_join == window)
Both queries give the same deltas, but only the self join needs consecutive day numbers
[(1, None), (2, 5), (3, -2), (4, 7)] [(1, None), (2, 5), (3, -2), (4, 7)] True
The two agree here because the days have no gaps. If day 3 were missing, the self join would produce NULL for day 4, while LAG would still return the previous existing row. Check which behavior you actually want before you switch.
10. Be careful with range joins on dates
A hash join works by hashing an equality key, so it has nothing to hash for ON e.ts BETWEEN p.start_ts AND p.end_ts. A range predicate leaves the engine with a nested loop, or with a merge-style join over sorted input. Two things help: make sure a sorted index exists on the range column, and bound the range tightly so each row only searches a small window.
CREATE INDEX idx_events_ts ON events (ts); SELECT e.id, p.label FROM events e JOIN periods p ON e.ts BETWEEN p.start_ts AND p.end_ts WHERE e.ts >= DATE '2026-09-01' -- bound the range AND e.ts < DATE '2026-10-01';
The date window shrinks the outer side, and the index serves each probe
11. For very large joins, change the data layout
When tuning the query is not enough, change what the query has to do. Three common options trade extra storage or maintenance for faster reads.
| Option | What it does | Cost |
|---|---|---|
| Denormalization | Copy a hot dimension column, such as the customer region, into the fact table so the join disappears | Updates must keep the copies in sync |
| Materialized view | Store the joined result and refresh it on a schedule | Data is only as fresh as the last refresh |
| Partition-wise join | Partition both tables by the join key so each partition pair joins separately | Both tables must be partitioned identically |
CREATE MATERIALIZED VIEW order_totals AS SELECT c.customer_id, c.name, SUM(o.amount) AS spend FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name; REFRESH MATERIALIZED VIEW order_totals;
Dashboards read the small precomputed table instead of re-joining millions of rows
Adding DISTINCT to hide duplicates from a multiplying join does not remove the multiplication. It adds a sort or hash pass on top of it. Fix the join or use EXISTS.
Measure, then tune
12. Use EXPLAIN ANALYZE, not EXPLAIN
Plain EXPLAIN shows the plan the optimizer expects to run, with row counts that are guesses from statistics. EXPLAIN ANALYZE actually runs the query and reports the actual row counts and times beside the estimates. Estimated rows can be wrong by orders of magnitude, but actual rows are what happened. Because it executes the statement, wrap data-changing queries in a transaction you roll back.
Nested Loop (cost=0.43..912.10 rows=40 width=24) (actual time=0.05..2840.30 rows=380000 loops=1) -> Seq Scan on customers c (rows=20 actual rows=19000 loops=1) -> Index Scan on orders o (rows=2 actual rows=20 loops=19000)
Expected 40 rows, got 380,000: the estimates were built on stale data
Read the plan from the innermost nodes outward and compare rows with actual rows at each node. The first node where they differ by about 10x is where the optimizer's picture went wrong, and every join above it inherits the error. Fix there first.
If the estimates are fine and the cost is a scan or a spill, add the missing index, trim the columns, or raise the memory for that query. Then measure again, because only a second EXPLAIN ANALYZE proves the change helped.
The whole checklist at a glance
| # | Check | Symptom it fixes |
|---|---|---|
| 1 | Index every foreign key column | Seq scan on the child side of the join |
| 2 | Composite order matches leading predicate columns | Index exists but is not used for a seek |
| 3 | Covering index | Extra table lookups per matched row |
| 4 | Refresh statistics after bulk loads | Wrong join order or algorithm |
| 5 | Push filters down | Too many rows entering the join |
| 6 | Select only needed columns | Large hash table |
| 7 | Check hash batches, raise work_mem | Spills to disk |
| 8 | EXISTS over IN with duplicates | Huge materialized lists |
| 9 | Window functions over self joins | Repeated scans for adjacent rows |
| 10 | Bound range joins, keep a sorted index | Hash join impossible on BETWEEN |
| 11 | Denormalize, materialize, or partition | Joins too big for any plan |
| 12 | EXPLAIN ANALYZE | Tuning against guesses |
Measure first, fix statistics and indexes second, trim rows and columns third, and change the data layout only when nothing cheaper is left. Each step is cheaper and less risky than the one after it.
Part 21 · Cheat Sheet & Mental Model
Picking the Join and Knowing the Row Count
Nearly every join decision comes down to one question: which rows must survive even when they find no partner? Answer that and the join type follows. The tree below is the whole selection process, and each leaf is a join you already know from earlier sections.
Notice there is no RIGHT JOIN leaf. Needing all of B is just needing all of A with the tables written in the other order, so you swap them and keep reading top to bottom with the preserved table first.
The second thing to carry in your head is how many rows each join can possibly return. These bounds are your first sanity check on any result: if the count falls outside the range, the query is not doing what you think.
| Join | Row count | Why |
|---|---|---|
| CROSS | exactly |A| × |B| | Every pair, no filter |
| INNER | 0 upward, no ceiling | Can be empty, or multiply on 1:many |
| LEFT | at least |A| | Every left row survives, matched or padded |
| FULL | at least max(|A|, |B|) | Both sides survive |
| SEMI / ANTI | at most |A| | Each left row appears once or not at all |
A second way to remember the types is by what each one keeps, using the picture of two overlapping circles. The 'intersection' is the set of rows that found a partner.
| Join | Keeps |
|---|---|
| INNER | the intersection |
| LEFT | all of A, plus the intersection |
| FULL | everything from both sides |
| ANTI | A minus the intersection |
LEFT guarantees every left row appears at least once, not exactly once. If a left row matches three right rows it shows up three times, so the output can be larger than |A|.
Two Rules to Memorize: ON vs WHERE, and NULL
The most expensive join bugs come from two rules that people half-remember. The first: ON decides what matches; WHERE decides what survives. On an inner join the two are interchangeable, but on an outer join they happen at different moments. ON runs while pairs are being formed, before unmatched rows get their NULL padding. WHERE runs afterwards, on the finished rows, padding included.
Using the employees and departments tables from the running schema (six employees, one with no department, plus a childless 'Legal' department), here is the same filter placed in each spot. In ON, every employee stays and only the department name is blanked. In WHERE, the padded rows fail the test and vanish, so the LEFT JOIN quietly turns into an inner join.
import sqlite3 db = sqlite3.connect(':memory:') db.executescript(''' CREATE TABLE departments (dept_id INT, dept_name TEXT); CREATE TABLE employees (emp_id INT, name TEXT, dept_id INT); INSERT INTO departments VALUES (10,'Eng'),(20,'Sales'),(30,'Ops'),(40,'Legal'); INSERT INTO employees VALUES (1,'Ada',10),(2,'Ben',10),(3,'Cy',20), (4,'Di',20),(5,'Eve',30),(6,'Finn',NULL); ''') in_on = db.execute(''' SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id AND d.dept_name = 'Eng' ''').fetchall() in_where = db.execute(''' SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id WHERE d.dept_name = 'Eng' ''').fetchall() print(len(in_on), len(in_where))
Same predicate, two placements
6 2
The one deliberate exception is the anti-join filter, WHERE right.key IS NULL, which exists precisely to select the padded rows.
The second rule is about NULL, and it is an asymmetry. In a join predicate, NULL = NULL is UNKNOWN, so NULL never matches anything, not even another NULL. In UNION, INTERSECT, EXCEPT, GROUP BY, and DISTINCT, two NULLs count as the same value. The next example shows both behaviors side by side.
joined = db.execute(
'SELECT 1 FROM (SELECT NULL AS k) a JOIN (SELECT NULL AS k) b ON a.k = b.k'
).fetchall()
common = db.execute('SELECT NULL INTERSECT SELECT NULL').fetchall()
print(len(joined), common)NULL in a join versus in INTERSECT
0 [(None,)]
| Context | Does NULL equal NULL? |
|---|---|
| Join predicate (ON) | no |
| UNION, INTERSECT, EXCEPT | yes |
| GROUP BY, DISTINCT | yes |
Writing WHERE o.year = 2024 after LEFT JOIN orders o drops every customer with no orders, because their padded NULL fails the test. Move the condition into ON unless you are deliberately writing the IS NULL anti-join.
Set Operators, Algorithms, and the Debug Ritual
Joins widen a result by adding columns; set operators lengthen it by adding rows. All four set operators below compare whole rows, treat NULLs as equal, and require both queries to have the same column count, order, and compatible types. That last requirement is the quickest sign you reached for the wrong tool.
| Operator | What it does | Duplicates |
|---|---|---|
| UNION | stack both, then dedupe | removed |
| UNION ALL | stack both, keep everything | kept, and no comparison work |
| INTERSECT | rows present in both | removed |
| EXCEPT | rows in the first but not the second | removed |
You never choose the join algorithm directly, but you influence it through indexes, fresh statistics, and selective predicates. Knowing what each algorithm is good at tells you what a plan should look like when things are healthy.
| Algorithm | Best when | Limit |
|---|---|---|
| Nested loop | a small outer side and an indexed inner side | slow when the inner side has no index |
| Hash | big equality joins with no useful order | equality only; spills if the build side exceeds memory |
| Merge | inputs already sorted, or inequality and range predicates | needs sorted input or pays to sort |
When a result looks wrong, do not stare at the final query. Use the debug ritual: run the INNER JOIN first and count its rows, then widen to LEFT and see exactly which rows appeared. That difference is your unmatched data, named precisely.
- 1INNER JOINrun it alone
- 2Count rowswrite the number down
- 3Widen to LEFTsame ON clause
- 4Read the deltathose rows are your unmatched data
FROM = 'SELECT e.name, d.dept_name FROM employees e {} departments d ON e.dept_id = d.dept_id' inner = db.execute(FROM.format('JOIN')).fetchall() left = db.execute(FROM.format('LEFT JOIN')).fetchall() print(len(inner), len(left)) print([r for r in left if r not in inner])
The delta names the orphan employee
5 6 [('Finn', None)]
To find the other kind of unmatched row, the department nobody belongs to, swap the tables and keep only the padded rows.
rows = db.execute(''' SELECT d.dept_name FROM departments d LEFT JOIN employees e ON e.dept_id = d.dept_id WHERE e.emp_id IS NULL ''').fetchall() print(rows)
Swap sides to see the childless department
[('Legal',)]Reviewer Red Flags, Defaults, and the Final Frame
When you review someone else's SQL, a handful of patterns deserve an immediate question. None is always wrong, but each is a bug until proven otherwise.
| Red flag | What to ask |
|---|---|
| A WHERE on a LEFT JOIN's right table | Did the outer join just become an inner join? |
| NOT IN with a subquery | Can that subquery ever return a NULL? |
| NATURAL JOIN | What happens when someone adds a shared column? |
| A DISTINCT that appeared after a bug report | Which join is multiplying rows, and why is it hidden? |
| A join predicate wrapped in a function | Is an index being defeated on every row? |
If the subquery returns even one NULL, x NOT IN (...) becomes UNKNOWN for every row and the whole result is empty, with no error. The example below shows it using the same tables.
bad = db.execute(
'SELECT dept_name FROM departments WHERE dept_id NOT IN (SELECT dept_id FROM employees)'
).fetchall()
good = db.execute('''
SELECT dept_name FROM departments d
WHERE NOT EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id)
''').fetchall()
print(bad)
print(good)Finn's NULL department breaks NOT IN
[]
[('Legal',)]Adding DISTINCT to make duplicate rows disappear hides the join that is multiplying them, and it adds a sort you did not need. Find the many-to-many join and fix that instead.
The red flags turn into defaults you can adopt without thinking. Each row pairs the habit with the safer choice it replaces.
| Prefer | Over | Because |
|---|---|---|
| UNION ALL | UNION | skips a sort or hash pass; dedupe only when required |
| NOT EXISTS | NOT IN | NULL-safe and can stop early |
| LEFT JOIN | RIGHT JOIN | the preserved table reads first |
| explicit CROSS JOIN | comma syntax | a forgotten ON no longer looks intentional |
| explicit ON | USING or NATURAL | the predicate cannot change silently |
A join is a filtered cross product plus an optional promise to preserve one or both sides. The row-count bounds, the ON versus WHERE timing, the NULL rule, and the algorithm choices all follow from that single sentence.
Part 22 · Check yourself
Quiz
Each question uses the running schema: employees has 6 rows (one with dept_id = NULL), departments has 4 rows (Legal has nobody), and 5 employee-department pairs match. Predict the answer before you open it.
You run SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id WHERE d.dept_name <> 'Sales';. Assuming Sales has employees, how many rows can the orphan employee (NULL department) contribute, and why?
- Zero rows. The orphan is padded with
d.dept_name = NULL, andNULL <> 'Sales'is UNKNOWN, so the WHERE rejects it. - WHERE runs after the padding, so the LEFT JOIN has quietly become an inner join.
- To keep the orphan, move the test into ON, or write
WHERE d.dept_name <> 'Sales' OR d.dept_id IS NULLif that is what you really mean.
SELECT e.name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id AND d.dept_name <> 'Sales';
A report says SELECT c.name, COUNT(*) AS n_orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.cust_id GROUP BY c.name; shows 1 order for a customer who has never ordered. What is the bug and the fix?
COUNT(*)counts rows, and the LEFT JOIN produces one NULL-padded row for a customer with no orders, so the count is 1.- Count a column from the right table that cannot be NULL when matched:
COUNT(o.order_id)returns 0 for the padded row. - If you also sum amounts, wrap it as
COALESCE(SUM(o.amount), 0)because SUM over only NULLs is NULL, not 0.
SELECT name FROM customers WHERE cust_id NOT IN (SELECT customer_id FROM orders); returns zero rows, yet you know some customers have never ordered. One order row was loaded with customer_id = NULL. Why is the result empty, and what would you write instead?
x NOT IN (1, 2, NULL)expands tox <> 1 AND x <> 2 AND x <> NULL, and the last term is UNKNOWN, so no row can ever evaluate to TRUE.NOT EXISTSdoes not have this problem, because the correlated comparison simply finds no matching row for those customers.- Use
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.cust_id).
SELECT c.name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.cust_id );
A dashboard joins customers to both orders (10 per customer) and addresses (5 per customer), then runs SUM(o.amount). The totals come out exactly 5 times too large. What happened, and how do you repair it?
- This is a fan trap. Two independent 1:many children joined to the same parent multiply: 10 × 5 = 50 rows per customer, so each order amount appears 5 times.
- Adding
DISTINCTwould hide the symptom and can still merge genuinely equal amounts, so it is not the fix. - Aggregate each child in its own subquery first, then join the one-row-per-customer results to the parent.
- Check the row count after each added join: the jump at the addresses join exposes the many:many multiplication.
Table A holds the row (1, 'x') three times and table B holds it once. What do A EXCEPT B, A EXCEPT ALL B and A INTERSECT ALL B return, and what does that say about using EXCEPT to prove two tables are identical?
A EXCEPT Breturns 0 rows: it deduplicates, and the row exists in B.A EXCEPT ALL Breturns 2 copies, since multiset semantics subtract one copy;A INTERSECT ALL Breturns 1.- So
(A EXCEPT B) UNION ALL (B EXCEPT A)can come back empty even though the tables have different multiplicities. Use the ALL variants or compareCOUNT(*)too.
Summary
- A join is a filtered cross product plus an optional promise to preserve one or both sides; run the INNER JOIN first, then see which rows the outer join recovers.
- On an outer join, ON decides what matches and WHERE decides what survives, so a WHERE on the right table of a LEFT JOIN cancels the outer-ness.
- NULL never matches in a join, but NULLs are equal in UNION, INTERSECT, EXCEPT, GROUP BY and DISTINCT.
- Use
COUNT(o.order_id)andCOALESCE(SUM(...), 0)after a LEFT JOIN, and prefer NOT EXISTS over NOT IN. - Inflated totals that are exact multiples of the truth mean a fan or many:many join; pre-aggregate each branch instead of adding DISTINCT.
- Joins widen and can multiply rows; set operators lengthen, compare whole rows and never multiply, so default to UNION ALL.
- Check the row count after every added join and read the plan with EXPLAIN ANALYZE, not plain EXPLAIN.