Joins and set operations
Hookโ
Your pipeline's revenue number is exactly double what finance reports. Nothing
looks wrong: the join runs, the sum runs, the dashboard renders. But somewhere
you joined orders to a table that has two rows per order, and every order got
counted twice. A join did not error โ it quietly changed your row count, and
the wrong number shipped. Joins are where correct-looking SQL goes wrong most
often, so this is where a data engineer's SQL gets careful.
Conceptโ
A join combines rows from two tables by matching a condition, usually a key. The type of join decides what happens to rows that have no match, and that decision is the whole game.
Think of two guest lists for a party. An inner join admits only people on both lists. A left join admits everyone on the left list, and fills in blanks for anyone missing from the right. The precise version:
| Join type | Keeps | Pipeline use |
|---|---|---|
INNER JOIN | Only rows with a match in both tables | Enrich rows you know exist in both |
LEFT JOIN | All left rows; NULLs where the right has no match | Keep every source row even if lookup is empty |
| Anti-join | Left rows with no right match (LEFT + IS NULL) | Find what is missing: orphans, gaps, leaks |
Two failure modes matter more than the syntax.
First, an inner join silently drops rows. If you INNER JOIN orders to
customers and one order references a customer that was deleted, that order
vanishes from your result โ no error, just a smaller number. When every source
row must survive, use a LEFT JOIN and handle the NULLs deliberately.
Second, a join can multiply rows. If the right table has more than one row per key โ two shipping addresses per customer, two line items per order โ each left row is repeated once per match. This fan-out is what doubled the revenue in the hook. Before you sum anything after a join, know how many rows per key the other table holds.
Set operations stack the results of two queries that have the same columns, comparing whole rows instead of joining on a key:
UNION ALLconcatenates both result sets, keeping duplicates. It is cheap because it does not deduplicate.UNIONconcatenates and removes duplicate rows. It costs a sort or hash to find the duplicates.EXCEPTreturns rows in the first query that are not in the second โ ideal for "what did we ship that we never invoiced?"INTERSECTreturns rows present in both.
The rule of thumb: reach for UNION ALL unless you actually need duplicates
removed, and use EXCEPT or an anti-join whenever the question is "what is in A
but not in B?"
Worked exampleโ
A shipping team and a billing team run separate systems. Every shipped order should have an invoice, but revenue leaks when something ships and is never billed. Let me find the leak with a reconciliation query.
Here are the two record sets โ shipments and invoices, each just a list of order IDs:
import sqlite3
con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE shipments (order_id INTEGER);
CREATE TABLE invoices (order_id INTEGER);
INSERT INTO shipments VALUES (5001), (5002), (5003), (5004), (5005);
INSERT INTO invoices VALUES (5001), (5002), (5004), (5006);
""")
First, how many orders were both shipped and invoiced? That is the inner join โ the healthy, matched rows:
matched = con.execute("""
SELECT COUNT(*)
FROM shipments s
JOIN invoices i ON s.order_id = i.order_id
""").fetchone()[0]
print("shipped and invoiced:", matched)
shipped and invoiced: 3
Three matched. But five orders shipped, so two shipped without an invoice โ the
leak. I find them with an anti-join: a LEFT JOIN that keeps every shipment,
then a filter for the rows where the invoice side came back NULL.
leaks = con.execute("""
SELECT s.order_id
FROM shipments s
LEFT JOIN invoices i ON s.order_id = i.order_id
WHERE i.order_id IS NULL
ORDER BY s.order_id
""").fetchall()
print("shipped but never invoiced:", [r[0] for r in leaks])
shipped but never invoiced: [5003, 5005]
The same question answers even more directly with EXCEPT โ shipments minus
invoices โ which compares whole rows and needs no join condition:
leaks_set = con.execute("""
SELECT order_id FROM shipments
EXCEPT
SELECT order_id FROM invoices
ORDER BY order_id
""").fetchall()
print("EXCEPT result:", [r[0] for r in leaks_set])
EXCEPT result: [5003, 5005]
Both approaches find orders 5003 and 5005. The anti-join is the tool when
you also need columns from the left table; EXCEPT is the tool when you only
need the keys and want the shortest query. Notice what neither approach did:
throw an error. The leak was invisible until you asked for it directly.
Hands-onโ
Your turn, with a different reconciliation. The exercise below gives you a
users table and an active_sessions table and asks you to find users who have
never started a session, then the sessions whose user no longer exists โ a
gap-and-orphan check on both sides.
Success criteria: your anti-join returns exactly the users with no session, and
your EXCEPT/orphan query returns exactly the sessions with no matching user.
SQL Kingdom checks both result sets against the expected keys automatically.
Recapโ
- You can pick a join type on purpose:
INNERto keep only matches,LEFTto keep every source row, an anti-join to find what has no match. - You can explain the two silent failures โ dropped rows from an inner join and fan-out from a one-to-many join โ before they corrupt a number.
- You can reconcile two datasets with
EXCEPT,INTERSECT, andUNION ALL, and chooseUNION ALLunless you truly need duplicates removed.
Next up: window functions, which let you keep every row from these joins and still compute per-group totals, rankings, and one-row-per-key deduplication.
- Join type is a decision:
INNERdrops unmatched rows,LEFTkeeps them, an anti-join isolates the unmatched ones. - Fan-out from a one-to-many join repeats left rows and double-counts sums โ check row-per-key cardinality before aggregating.
- Use
EXCEPT/INTERSECTto compare whole rows, and preferUNION ALLunless you need duplicates removed.