Joins & Set Operations
A join combines rows from two tables into one wider row. Since relational databases store relationships as shared values rather than pointers, joins are how you reassemble the data model at query time.
The join model
Section titled “The join model”Every join starts from the same primitive: the Cartesian product. Pair every row of A with every row of B, then keep the pairs where a condition holds.
-- Conceptually, this:SELECT * FROM authors JOIN books ON authors.id = books.author_id;
-- Means: form every (author, book) pair, keep those where the ids match.The engine never actually materialises the full product — the planner uses nested loops, hash joins, or merge joins depending on statistics. But the semantics are the product plus a filter, and thinking of it that way makes every join type easy to derive.
The examples below extend the overview schema:
CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE orders ( id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), book_id INTEGER NOT NULL REFERENCES books(id), qty INTEGER NOT NULL DEFAULT 1);
INSERT INTO customers (id, name, city) VALUES (1, 'Amina', 'Casablanca'), (2, 'Bruno', 'Lisbon'), (3, 'Chidi', NULL); -- never ordered anything
INSERT INTO orders (id, customer_id, book_id, qty) VALUES (1, 1, 1, 2), (2, 1, 3, 1), (3, 2, 4, 1);INNER JOIN
Section titled “INNER JOIN”Keeps only pairs where the condition is true. Rows with no match on either side disappear.
SELECT c.name, b.title, o.qtyFROM orders AS oINNER JOIN customers AS c ON c.id = o.customer_idINNER JOIN books AS b ON b.id = o.book_idORDER BY c.name; name | title | qty-------+----------------------+----- Amina | The Dispossessed | 2 Amina | Stories of Your Life | 1 Bruno | Kindred | 1Chidi is gone — no orders, no rows. INNER is the default: bare JOIN means INNER JOIN in both engines.
Table aliases (AS o, AS c) are not decoration. Once you join three tables, unqualified column names become ambiguous and hard to read. Alias everything and qualify every column.
LEFT OUTER JOIN
Section titled “LEFT OUTER JOIN”Keeps all rows from the left table. Where the right table has no match, its columns come back as NULL.
SELECT c.name, COUNT(o.id) AS order_countFROM customers AS cLEFT JOIN orders AS o ON o.customer_id = c.idGROUP BY c.id, c.nameORDER BY c.name; name | order_count-------+------------- Amina | 2 Bruno | 1 Chidi | 0OUTER is optional noise — LEFT JOIN and LEFT OUTER JOIN are identical.
ON vs WHERE in an outer join
Section titled “ON vs WHERE in an outer join”This distinction only matters for outer joins, and it matters a lot.
-- Filter applied DURING the join: Chidi survives with NULLsSELECT c.name, o.idFROM customers AS cLEFT JOIN orders AS o ON o.customer_id = c.id AND o.qty > 1;
-- Filter applied AFTER the join: Chidi's NULL row fails the test and vanishesSELECT c.name, o.idFROM customers AS cLEFT JOIN orders AS o ON o.customer_id = c.idWHERE o.qty > 1;-- first query -- second query name | id name | id-------+---- -------+---- Amina | 1 Amina | 1 Bruno | (1 row) Chidi |(3 rows)The second query has quietly become an inner join. Any WHERE condition on the right-hand table of a LEFT JOIN turns it into an inner join, unless the condition explicitly allows nulls (WHERE o.id IS NULL OR o.qty > 1).
Conditions on the left table belong in WHERE. Conditions on the right table belong in ON.
RIGHT and FULL OUTER JOIN
Section titled “RIGHT and FULL OUTER JOIN”RIGHT JOIN keeps all rows from the right table — it is LEFT JOIN with the operands swapped. FULL OUTER JOIN keeps unmatched rows from both sides.
SELECT c.name, o.idFROM orders AS oRIGHT JOIN customers AS c ON c.id = o.customer_id;
SELECT c.name, o.idFROM customers AS cFULL OUTER JOIN orders AS o ON o.customer_id = c.id;| SQLite | PostgreSQL | |
|---|---|---|
LEFT JOIN |
Yes, always | Yes |
RIGHT JOIN |
3.39+ only | Yes |
FULL OUTER JOIN |
3.39+ only | Yes |
In practice, RIGHT JOIN is rare — most people find it easier to read the query with the tables reordered.
CROSS JOIN
Section titled “CROSS JOIN”The bare Cartesian product, no condition.
SELECT a.name, b.title FROM authors AS a CROSS JOIN books AS b; -- 3 × 4 = 12 rowsThe comma syntax means the same thing: FROM authors, books. Legitimate uses are generating combinations — every size × every colour, every day × every store.
ON vs USING
Section titled “ON vs USING”USING (col) is shorthand for ON left.col = right.col when both tables name the column identically. It also merges the two columns into one in the output.
-- These match the same rowsSELECT * FROM orders JOIN books ON orders.book_id = books.id;SELECT * FROM orders JOIN books USING (id); -- only if both call it "id"
-- Realistic case: a shared column nameSELECT * FROM order_items JOIN books USING (book_id);With USING, book_id appears once in SELECT * output and can be referenced unqualified. With ON, both copies appear. Both engines support USING.
NATURAL JOIN goes further and joins on every commonly named column, with no condition written at all.
Self joins
Section titled “Self joins”A table joined to itself. You must alias both copies to tell them apart. The canonical case is a hierarchy:
CREATE TABLE employees ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager_id INTEGER REFERENCES employees(id));
INSERT INTO employees VALUES (1, 'Rania', NULL), (2, 'Sam', 1), (3, 'Tariq', 1), (4, 'Uma', 2);
SELECT e.name AS employee, m.name AS managerFROM employees AS eLEFT JOIN employees AS m ON m.id = e.manager_idORDER BY e.name; employee | manager----------+--------- Rania | Sam | Rania Tariq | Rania Uma | SamLEFT JOIN keeps Rania, who has no manager. An inner join would drop the root of the hierarchy.
Self joins also find pairs within a table. Use an inequality to avoid duplicates and self-pairing:
SELECT a.title, b.titleFROM books AS aJOIN books AS b ON a.author_id = b.author_id AND a.id < b.id;-- Pairs of books by the same author, each pair listed onceTo walk a hierarchy of unknown depth you need a recursive CTE, not a join — see advanced SQL.
Multi-table joins
Section titled “Multi-table joins”Joins are left-associative: each JOIN combines the accumulated result so far with the next table.
SELECT c.name, c.city, b.title, a.name AS author, o.qtyFROM orders AS oJOIN customers AS c ON c.id = o.customer_idJOIN books AS b ON b.id = o.book_idJOIN authors AS a ON a.id = b.author_idWHERE c.city = 'Casablanca'ORDER BY b.title;Two things to watch:
Row multiplication. Joining a one-to-many relationship multiplies rows. If a customer has 5 orders and you also join a table with 3 addresses per customer, you get 15 rows per customer — and any SUM over them is wrong by 3×. Aggregate each branch separately (in a subquery or CTE) before joining.
Order matters for outer joins. Once you LEFT JOIN table B, an ordinary JOIN to table C afterwards will drop the rows where B was null. Keep outer joins outer all the way down the chain:
FROM customers cLEFT JOIN orders o ON o.customer_id = c.idLEFT JOIN books b ON b.id = o.book_id -- must also be LEFTLATERAL (PostgreSQL only)
Section titled “LATERAL (PostgreSQL only)”A LATERAL subquery in FROM can reference columns from tables listed before it — a per-row correlated join. It is the tidy way to fetch “top N per group”.
-- PostgreSQL: the 2 most recent orders per customerSELECT c.name, o.id, o.qtyFROM customers AS cLEFT JOIN LATERAL ( SELECT id, qty FROM orders WHERE customer_id = c.id ORDER BY id DESC LIMIT 2) AS o ON true;SQLite has no LATERAL. Use a window function with ROW_NUMBER(), which works in both engines.
Set operations
Section titled “Set operations”Set operations combine the results of two queries vertically instead of horizontally. Both queries must produce the same number of columns with compatible types; the column names come from the first query.
SELECT name FROM customersUNIONSELECT name FROM authors;| Operator | Result | SQLite | PostgreSQL |
|---|---|---|---|
UNION |
All rows from both, duplicates removed | Yes | Yes |
UNION ALL |
All rows from both, duplicates kept | Yes | Yes |
INTERSECT |
Rows in both, deduplicated | Yes | Yes |
EXCEPT |
Rows in the first but not the second, deduplicated | Yes | Yes |
INTERSECT ALL / EXCEPT ALL |
Multiset versions that keep duplicate counts | No | Yes |
For deduplication purposes these operators treat NULLs as equal — unlike =.
ORDER BY and LIMIT apply to the whole compound result, not to a branch:
SELECT title FROM books WHERE year < 1980UNION ALLSELECT title FROM books WHERE year >= 2000ORDER BY titleLIMIT 5;To limit an individual branch, wrap it in a subquery — this form works in both engines:
SELECT * FROM (SELECT title FROM books ORDER BY year LIMIT 2)UNION ALLSELECT * FROM (SELECT title FROM books ORDER BY year DESC LIMIT 2);Anti-joins: finding what’s missing
Section titled “Anti-joins: finding what’s missing”An anti-join returns rows from A with no match in B. There are three standard ways to write it.
-- 1. NOT EXISTS — correlated, null-safe, usually the best planSELECT c.nameFROM customers AS cWHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);
-- 2. LEFT JOIN ... IS NULL — join, then keep only the unmatched rowsSELECT c.nameFROM customers AS cLEFT JOIN orders AS o ON o.customer_id = c.idWHERE o.id IS NULL;
-- 3. NOT IN — reads well, but see the warningSELECT c.nameFROM customers AS cWHERE c.id NOT IN (SELECT customer_id FROM orders);All three return Chidi here.
For form 2, test a column that is guaranteed non-null in matching rows — the primary key is the safe choice. Testing a nullable column can’t distinguish “no match” from “matched, value was null”.
The positive version, a semi-join (rows from A that have a match, without duplicating them):
SELECT c.nameFROM customers AS cWHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);Note that a plain JOIN would return Amina twice (she has two orders). EXISTS stops at the first match, so each customer appears once. That difference is the reason semi-joins exist.
Key points
Section titled “Key points”- A join is the Cartesian product plus a condition; every join type follows from that.
INNERdrops unmatched rows;LEFT/RIGHT/FULLpreserve them and fill the other side withNULL.- SQLite only got
RIGHTandFULL OUTER JOINin 3.39;LEFT JOINhas always worked. - A
WHEREcondition on the right table of aLEFT JOINsilently converts it to an inner join — put it inONinstead. - Alias every table, qualify every column, never use
NATURAL JOIN. - Joining one-to-many relationships multiplies rows and corrupts aggregates; aggregate before joining.
UNIONdeduplicates,UNION ALLdoesn’t and is faster;INTERSECT ALL/EXCEPT ALLare PostgreSQL-only.- Use
NOT EXISTSfor anti-joins;NOT INbreaks on nulls.