SQL JOINs
A join combines rows from two tables into one result by pairing the rows that satisfy a join condition, usually a foreign key equal to the primary key it references. orders JOIN customers ON orders.customer_id = customers.customer_id puts every order next to the customer who placed it, so one query shows whether order 1042 belongs to the right person.
The join type decides what happens to rows without a partner: INNER JOIN drops them, while LEFT, RIGHT and FULL joins keep them and fill the missing side with NULL. That difference is how a tester finds orphan rows, child rows whose parent does not exist.
Why it matters
Many data defects live between tables: an order pointing to a deleted customer, a payment without its order, an import that wrote ids from another system. Each table looks correct on its own; only a join shows the broken references. Undetected, they make screens show fewer items than exist and make reports disagree.
Joins are also a trap for the tester. An inner join hides unmatched rows without any error, and a wrong join condition returns plausible but wrong rows. The right join type and a row count you predicted turn a query into evidence instead of a false pass.
How a join matches rows
You name both tables in FROM … JOIN and write the join condition after ON, usually a relationship like those in the lesson on keys and relationships: the foreign key orders.customer_id equals the primary key it references, here customers.customer_id. The result holds every pair of rows for which the condition is true.
In the tables below, order 104 references customer 9, who does not exist: the kind of broken reference a tester looks for. c and o are aliases, short names for the tables.
-- customers orders
-- customer_id full_name order_id customer_id total
-- 1 Layla 101 1 250.00
-- 2 Omar 102 1 40.00
-- 3 Priya 103 2 120.00
-- 104 9 75.00
SELECT c.customer_id, c.full_name, o.order_id, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id
ORDER BY o.order_id;
-- 3 rows: customer 1 with orders 101 and 102, customer 2 with order 103INNER, LEFT, RIGHT and FULL joins
All four types return the matched pairs and differ only in unmatched rows. Customers, written first, is the left table; orders is the right table.
INNER JOIN, or justJOIN: matched pairs only, 3 rows. Customer 3, who has no orders, and order 104, whose customer is missing, disappear without any warning.LEFT JOIN(LEFT OUTER JOIN): every row of the left table, 4 rows. Customer 3 is kept, with NULL in the order columns.RIGHT JOIN(RIGHT OUTER JOIN): every row of the right table, 4 rows. Order 104 is kept, with NULL in the customer columns.FULL JOIN(FULL OUTER JOIN): every row of both tables, 5 rows. PostgreSQL, SQL Server and Oracle Database support it; MySQL does not, and SQLite supports RIGHT and FULL joins only from version 3.39.0.
| Row | INNER JOINmatches only | LEFT JOIN+ every customer | RIGHT JOIN+ every order | FULL JOIN+ both sides |
|---|---|---|---|---|
| Customer 1 + order 101 | In result | In result | In result | In result |
| Customer 1 + order 102 | In result | In result | In result | In result |
| Customer 2 + order 103 | In result | In result | In result | In result |
| Customer 3has no orders | not in the result | In resultorder columns NULL | not in the result | In resultorder columns NULL |
| Order 104customer 9 does not exist | not in the result | not in the result | In resultcustomer columns NULL | In resultcustomer columns NULL |
| Rows returned | 3 | 4 | 4 | 5 |
- Matched pair: returned by every join type
- Unmatched row: kept only by an outer join that keeps its table, with NULL on the missing side
- Orphan row: the order references a customer that does not exist
Same tables and the same condition o.customer_id = c.customer_id in every column; only the join type changes. An empty cell means the row is not in that result.
SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;
-- customer_id order_id total
-- 1 101 250.00
-- 1 102 40.00
-- 2 103 120.00
-- 3 NULL NULL <- kept by LEFT JOIN, dropped by INNER JOINWriting the join condition
Most wrong join results come from the ON clause, and a wrong condition still returns rows that look reasonable, so compare every row count with the number you expect.
In an outer join, the place of an extra filter matters. In ON it only decides what counts as a match, so unmatched left rows stay. In WHERE it runs after the join, removes the NULL-filled rows and silently turns the left join into an inner join, as the two queries below show. A test for IS NULL is the exception: it keeps exactly the NULL-filled rows, which is how the orphan check in the next section works.
- Join on the columns the relationship uses:
o.customer_id = c.customer_id. The conditiono.order_id = c.customer_idalso runs, but pairs unrelated rows wherever the two numbers happen to be equal. - Use every column of a composite key: if customer ids are unique only within a tenant (one client organisation in a shared, multi-tenant system), join on both
tenant_idandcustomer_id; otherwise an order is also paired with other tenants’ customers who have the same number. - Expect repetition in one-to-many joins: customer 1 appears twice because it has two orders, so counting the rows counts orders, not customers.
- Leaving the condition out pairs every row with every row, a Cartesian product, which
CROSS JOINreturns on purpose: 3 customers and 4 orders give 12 rows. It happens withFROM customers, orders(tables separated by a comma) when noWHERElinks them, and in MySQL with aJOINthat has noON; PostgreSQL rejects such aJOIN. - NULL never equals anything, not even another NULL, so a row whose join column is NULL never matches: an inner join drops it, a left join keeps it.
-- Every customer, with their orders over 100.00 if any: 3 rows
SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id AND o.total > 100.00;
-- 1 | 101 | 250.00 2 | 103 | 120.00 3 | NULL | NULL
-- The same filter in WHERE also removes customer 3: 2 rows
SELECT c.customer_id, o.order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.total > 100.00;
-- 1 | 101 | 250.00 2 | 103 | 120.00Finding orphan rows with LEFT JOIN … IS NULL
An orphan row is a child row whose foreign key points to a parent that does not exist, like order 104. Start from the child table, left join the parent and keep only the rows where no parent matched. A correct database returns zero rows; every returned row is evidence for a bug report. Reversed, the same shape finds parents missing required children, such as an order with no items.
Test IS NULL on the parent’s primary key, which is never NULL after a match; a nullable column such as an optional phone number would also report the orders of customers without a phone. Children whose foreign key is itself NULL appear too: a defect if the column is required, otherwise exclude them with AND o.customer_id IS NOT NULL.
The second query in the block below writes the same check with NOT EXISTS. The query in brackets, a subquery, is checked for each order; NOT EXISTS is true when it finds no row. SELECT 1 only says that the subquery’s columns do not matter, only whether a row exists.
An enforced foreign key constraint prevents orphans, yet they still appear in practice:
- The constraint was never declared, which is common in legacy schemas.
- Constraints were disabled for a bulk import or a migration.
- SQLite enforces foreign keys only when
PRAGMA foreign_keys = ONis set for the connection. - Parent and child live in the databases of two different services.
-- Orders whose customer does not exist (expected: 0 rows)
SELECT o.order_id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL;
-- returns order 104 with customer_id 9: an orphan
-- The same check written with NOT EXISTS
SELECT o.order_id, o.customer_id
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id
);Worked example: orders missing from the order history
After a migration from an old shop system, a support ticket says some customers do not see all their orders on the "My orders" page, which joins orders to customers with an inner join. The tester suspects orders that point to customers who do not exist.
- Expected: every migrated order belongs to an existing customer, so the orphan check returns 0 rows.
- Actual: 13 rows, all with a
customer_idabove 900000, the old system’s numbering. The migration copied old customer numbers instead of mapping them to the new ids, so these orders belong to no existing customer: the page’s inner join can never pair them with the signed-in customer, and nothing raises an error. - Report: the query, the 13 order ids, the expected 0 rows, the suspected cause, and the question whether a foreign key constraint should be added.
- Confirmation testing after the fix: the same query returns 0 rows, and an affected customer sees the missing orders on the page.
-- Orphan check after the migration (expected: 0 rows)
SELECT o.order_id, o.customer_id, o.created_at
FROM orders o
LEFT JOIN customers c ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL
ORDER BY o.order_id;Common mistakes
- Using an inner join to look for missing data: it removes exactly the unmatched rows you are trying to find.
- Filtering the right-hand table in
WHEREafter a left join, which turns it into an inner join. Put such conditions inON; only theIS NULLtest of an orphan check belongs inWHERE. - Trusting plausible output from a join on the wrong column, or on only part of a composite key, without checking the row count.
- Reading the repeated parent rows of a one-to-many join as duplicate data.
- Testing
IS NULLon a nullable column of the parent instead of its primary key. - Assuming orphans cannot exist because the data model shows a relationship; check that the foreign key is declared and enforced.
The QA perspective
Testers who work with databases use joins routinely to verify one change across tables, such as an order created through the API with its customer and item lines. Run orphan checks after anything that writes in bulk or bypasses the application: migrations, imports, batch jobs, manual data fixes, deletions of parent records. Ask developers which relationships lack a foreign key constraint and check those first.
Put the exact query, the expected row count (zero for an integrity check) and the returned rows in the bug report. Keep useful checks as named, read-only queries in a shared set, so they run again after the fix and after later migrations.
Practice exercise
Join orders and payments, then find the orphans
A shop stores orders and payments in the two tables below. On paper, or in a local SQLite (3.39.0 or later, for the FULL JOIN) or PostgreSQL database seeded with these rows, work out what each join returns, then write two integrity checks.
orders payments
order_id status payment_id order_id amount
201 PAID 1 201 90.00
202 PAID 2 207 60.00
203 PENDING 3 201 10.00- Write the result of
orders INNER JOIN paymentsonorder_idand count the rows. - Repeat for a
LEFT JOINfrom orders and aFULL JOIN; mark every NULL. - Write the orphan-payments check and its expected result on a correct database.
- Write the check for PAID orders without a payment, and explain why the status filter belongs in
WHEREand not inON.
Show the model answer
INNER JOIN: 2 rows, order 201 with payment 1 and with payment 3. Orders 202 and 203 and payment 2 are dropped.
LEFT JOIN from orders: 4 rows, adding orders 202 and 203 with NULL payment columns. FULL JOIN: 5 rows, also adding payment 2 with NULL order columns, as order 207 does not exist.
Orphan payments: SELECT p.payment_id, p.order_id FROM payments p LEFT JOIN orders o ON o.order_id = p.order_id WHERE o.order_id IS NULL; Expected on a correct database: 0 rows. Here it returns payment 2, which you report with the query and the row.
PAID orders without a payment: SELECT o.order_id FROM orders o LEFT JOIN payments p ON p.order_id = o.order_id WHERE o.status = 'PAID' AND p.payment_id IS NULL; It returns order 202. The status filter tests the left table, so in WHERE it removes the orders that are not PAID, such as 203, and never removes a row only because its payment columns are NULL. Moved into ON, it would only stop order 203 from matching a payment: the left join would still keep 203 with NULL payment columns, and the check would wrongly report it. The p.payment_id IS NULL test stays in WHERE because it must run after the join.
Frequently asked questions
What is the difference between JOIN and INNER JOIN?
None. JOIN on its own means INNER JOIN, and LEFT JOIN is short for LEFT OUTER JOIN; the word OUTER is optional. Writing INNER or LEFT explicitly makes the intent clear to the next reader of the query.
Why not find orphans with NOT IN?
Because NOT IN (subquery) returns no rows at all when the subquery returns a single NULL, so the check passes while orphans exist. LEFT JOIN … IS NULL and NOT EXISTS give the right answer when NULLs are present.
Do I ever need RIGHT JOIN?
Not strictly: any RIGHT JOIN can be rewritten as a LEFT JOIN with the tables swapped, and most teams prefer LEFT JOIN because the table you start from is read first. You still need to be able to read it in other people’s queries.
Can I join more than two tables?
Yes. Each additional JOIN … ON adds one table to the result built so far, for example orders to customers and then to order_items. Check the row count after every join you add, because one-to-many joins multiply rows.
References
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- PostgreSQL Documentation: SQL SyntaxLexical structure of SQL in PostgreSQL: identifiers, quoting, string constants and escaping.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
- Constraints — PostgreSQL DocumentationCheck, not-null, unique, primary-key, foreign-key and exclusion constraints — the rules a database enforces and a tester verifies.
Practise with Mutqan tools
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.