Core CS · DBMS
Twelve pairs, and the three you keep
A join does not go and fetch matching rows. It considers every pairing of a row from one table with a row from the other, and returns the pairings your condition calls true. Once you can see the pairings, INNER, LEFT, RIGHT and FULL stop being four things to memorise.
Step through all four joins on the same rows →01 The idea
The data was split on purpose
Good schema design pulls one wide table apart into several narrow ones so that each fact is stored exactly once. A student’s department is recorded as a short code in the student row, and the readable department name is recorded once in a department row. That is the right thing to do for writing: change the name of a department and you change one row, not five hundred.
It leaves you with a reading problem. No single table can answer “which department is Meera in”, because the answer is half in one table and half in the other. A join is how you put the two halves back together for one query, without ever putting them back together on disk. You name two tables and a condition, and you get one result table back.
What the database does with that is worth taking literally, because every other question about joins falls out of it. It considers every pairing of a row from the first table with a row from the second. Four student rows and three department rows are twelve pairings. It tests your condition once per pairing and keeps the pairings where the condition came out true. That is an INNER JOIN, and it is the whole mechanism.
Real databases do not literally build all twelve and throw nine away. A query planner picks a hash join or a merge join and touches far fewer rows than that. But the twelve pairings are the definition of the answer, and the planner is only allowed to be clever as long as it returns exactly what the definition says. So when a result surprises you, go back to the pairings. They are always right.
02 Worked example
Four students, three departments, twelve pairings
Here are the two tables this whole lesson runs on. They are deliberately small enough to check by hand, and they are deliberately untidy in two specific ways: one student has not been assigned a department yet, and one department has no students in it. Those two rows are where all the interesting behaviour lives.
student deptsid name did mentor did dnameS1 Asha D1 NULL D1 CSES2 Ravi D2 S1 D2 ECES3 Meera D2 S1 D3 MECHS4 Kiran NULL S2 -- Kiran has no department. MECH has no students. Both are real states.-- mentor points at another sid in this same table; it is used in section 03.
The condition we will use everywhere is s.did = d.did. Follow the flow left to right and watch where the row count comes from at each step.
Node 3 is where this topic is won or lost, so read it slowly. Kiran’s did is NULL, and NULL = ‘D1’ does not come out false. It comes out unknown, which is a third value SQL comparisons are allowed to return whenever a NULL is involved. A join keeps only the pairings its condition calls true, and unknown is discarded in exactly the same way false is. So Kiran is in no true pairing, and no amount of adding departments will ever change that.
Node 4 hides the other surprise. Both Ravi and Meera have did = ‘D2’, so the ECE department row appears in two of the three surviving pairings. A join is not a lookup that returns one row per row. One row on one side produces one result row per matching row on the other side: none, one, or many.
Everything in the next section is a decision about node 5. Which of the two leftover rows do you want back, and what goes in the columns the other table cannot fill.
03 Mechanics
Five join types over the same twelve pairings
One row per join type, same two tables, same condition s.did = d.did. The third column is the exact result on our data, hand-counted, and the last column is what actually goes wrong with each one in practice. The four that take an ON clause all test the same 12 pairings and all find the same 3 true ones. CROSS JOIN is the odd one out: it has no condition to test, so it hands back all 12.
| Join | What it keeps | Result on our four students and three departments | Rows | What goes wrong with it |
|---|---|---|---|---|
| CROSS JOIN | Every pairing. There is no condition at all. | All 12 pairs, including Kiran paired with each of the three departments. | 12 |
Almost always an accident. Two tables listed in FROM separated by a comma, with the join condition missing from the WHERE, is a cross join, and the row count is the product of the two table sizes. |
| INNER JOIN … ON | Only the pairings where ON comes out true. | Asha CSE, Ravi ECE, Meera ECE. | 3 |
Rows that matched nothing vanish from both sides with no warning. The word INNER is optional, so a bare JOIN is this one. |
| LEFT OUTER JOIN | All of INNER, plus every left row in no true pairing, with the right table’s columns set to NULL. | The same 3, plus Kiran with did and dname NULL. | 4 |
The left table’s row count is a floor, not a ceiling. Ravi and Meera both matched D2, so a left row can still fan out into several. |
| RIGHT OUTER JOIN | All of INNER, plus every right row in no true pairing, with the left table’s columns set to NULL. | The same 3, plus MECH with sid and name NULL. | 4 |
It is a LEFT JOIN with the two table names swapped, and nothing else. Mixing both directions in one query makes a chain of joins very hard to read. |
| FULL OUTER JOIN | All of INNER, plus the unmatched rows from both sides. | The same 3, plus Kiran, plus MECH. | 5 |
The only join type where a result row can be NULL on either side, and the only one that is not available everywhere. It is the honest answer to “show me everything from both tables and line up what matches”. |
There are three ways to write the condition itself, and only one of them keeps meaning what you wrote after somebody else edits the tables.
| Form | Written out | What it returns today | Why it is or is not safe |
|---|---|---|---|
| ON | student s JOIN dept d ON s.did = d.did | 3 rows, and SELECT * shows both s.did and d.did. | Explicit. The condition is written down in the query, so adding or renaming a column in either table cannot silently change what the query means. It is also the only form that takes a condition other than equality. |
| USING | student JOIN dept USING (did) | The same 3 rows, but did appears once instead of twice. | Safe, because you name the column yourself. It only works when the column has the same name in both tables, and it can only ever mean equality. |
| NATURAL JOIN | student NATURAL JOIN dept | The same 3 rows, today. | It guesses. It joins on every column name the two tables happen to share, so the schema decides what your query means. Add a name column to dept and this query silently starts joining on did and name together, which on our rows matches nothing. Rename dept.did and the two tables share no column at all, which in standard SQL makes it a cross join returning 12. |
Two more shapes you are expected to recognise on sight. A self join is a table joined to itself, which is what you need when a row points at another row of the same table. An anti-join is not a keyword at all: it is a LEFT JOIN plus an IS NULL test, and it is the standard way to ask “which rows matched nothing”.
-- self join: one table, two aliases. The aliases are not a style choice.SELECT s.name AS student, m.name AS mentorFROM student sJOIN student m ON s.mentor = m.sid; -- 3 rows. Asha's mentor is NULL, so INNER JOIN drops her. -- anti-join: the left rows that were in no true pairing, and only thoseSELECT s.sid, s.nameFROM student sLEFT JOIN dept d ON s.did = d.didWHERE d.did IS NULL; -- 1 row: S4 Kiran -- same shape, other direction: departments with no studentsSELECT d.did, d.dnameFROM dept dLEFT JOIN student s ON s.did = d.didWHERE s.sid IS NULL; -- 1 row: D3 MECH
In the self join, s and m are two independent copies of the same four rows, and without them sid would be ambiguous with no way to say which copy you meant. In the anti-join, the trick is that only NULL-extended rows can have a NULL in d.did, so testing that one column for NULL isolates exactly the rows that matched nothing. Test a column that could legitimately be NULL in a real row and you will throw away rows that did match.
The last shape is the one that costs people offers. The same extra test placed in ON and in WHERE gives two different answers on an outer join.
-- (A) the extra test is part of the join conditionSELECT s.name, d.dnameFROM student sLEFT JOIN dept d ON s.did = d.did AND d.dname = 'ECE';-- 4 rows: Asha NULL | Ravi ECE | Meera ECE | Kiran NULL -- (B) the extra test is a filter on the finished joinSELECT s.name, d.dnameFROM student sLEFT JOIN dept d ON s.did = d.didWHERE d.dname = 'ECE';-- 2 rows: Ravi ECE | Meera ECE
In (A) the extra test runs while the pairings are being judged. Asha’s only candidate is the CSE row, and ‘CSE’ = ‘ECE’ is false, so she ends up in no true pairing at all. LEFT JOIN then does what LEFT JOIN always does and gives her a row anyway, with NULLs. All four students survive.
In (B) the join finishes first, and its ON carries no extra test, so it produces the plain LEFT JOIN result: Asha with CSE, Ravi with ECE, Meera with ECE, and Kiran with NULLs. Four rows again, but not the same four. Only then does WHERE start deleting from that result. Asha’s row holds dname = ‘CSE’, which is not ‘ECE’, so it goes. Kiran’s row holds dname = NULL, and NULL = ‘ECE’ is unknown rather than true, so it goes too. What survives is the inner-join answer with a filter on it. On an INNER JOIN the two placements are interchangeable and nobody can tell the difference. On an outer join they are not.
05 Cheat sheet
Every join type on one card
The row counts are for the lesson’s four students and three departments joined on s.did = d.did. Learn the counts with the reason attached, because the number on its own proves nothing.
| Join | What it keeps | Rows here | The one thing to remember |
|---|---|---|---|
| CROSS JOIN | every pairing, no condition | 12 | 4 × 3. A comma between two tables in FROM with no WHERE is this, by accident. |
| INNER JOIN | pairings where ON is true | 3 | Rows that matched nothing disappear from both sides, silently. A bare JOIN means this. |
| LEFT OUTER JOIN | INNER, plus unmatched left rows, right columns NULL | 4 | Kiran survives with NULLs. Use it when the absence of a match is itself an answer. |
| RIGHT OUTER JOIN | INNER, plus unmatched right rows, left columns NULL | 4 | Identical to a LEFT JOIN with the table names swapped. It buys you nothing. |
| FULL OUTER JOIN | INNER, plus the unmatched rows from both sides | 5 | The only one where a row can be NULL on either side. MySQL does not have it, so you assemble it out of the joins MySQL does have. |
| Self join | a table joined to itself on s.mentor = m.sid | 3 | Not a keyword. It is two aliases on one table name, and the aliases are compulsory. |
| Anti-join | left rows that matched nothing | 1 | Not a keyword either. LEFT JOIN plus WHERE d.key IS NULL, tested on a column that is never NULL in a real row. |
06 Where & why
What your database actually does with a join
The twelve pairings are the definition of the answer, not the plan. No mainstream database forms them and then discards nine. What differs between real systems is which join types you are allowed to type at all, and what the engine does underneath, and interviewers ask about both.
INNER, LEFT, RIGHT, FULL and CROSS, plus USING and NATURAL. Put EXPLAIN in front of your query and it names the strategy it chose: Nested Loop, Hash Join or Merge Join. A hash join builds a small hash table on one side and probes it once per row of the other, so the twelve pairings in this lesson are never formed. The answer is identical either way, which is the point: the pairings define the result, the plan only has to agree with it.
Everything except FULL OUTER JOIN, which you have to assemble out of the join types MySQL does have. The trap in assembling it is that the matched rows are produced by both halves, so a careless version reports them twice. MySQL also treats JOIN, INNER JOIN and CROSS JOIN as synonyms in FROM, so a CROSS JOIN b ON … is accepted there and rejected by the standard.
Oracle supports the standard syntax in full, but legacy code is full of its older (+) operator: WHERE s.did = d.did(+) is a LEFT JOIN keeping all students. The (+) marks the side that is allowed to be missing and will supply the NULLs, which is the opposite of what most people guess. It also cannot be used with OR or IN, and cannot be mixed with standard join syntax in the same query, so converting old code is rarely a straight substitution.
SQLite has had INNER, LEFT, CROSS, NATURAL and USING for years, but RIGHT JOIN and FULL OUTER JOIN only arrived in version 3.39 in 2022, and plenty of Android and embedded builds still ship something older. SQL Server has FULL OUTER JOIN but no NATURAL JOIN and no USING. Between them they are the practical argument for writing explicit ON conditions and LEFT joins by default.
07 Interview questions
What they actually ask
Joins are the most reliably asked SQL topic in a placement interview, and the questions almost always start easy and then go straight at NULLs and row counts. Answer with numbers from a concrete pair of tables rather than with definitions.
What is a join, in one sentence?
Does the database actually build the Cartesian product?
What is the difference between an INNER JOIN and a LEFT OUTER JOIN?
A row has NULL in the join column. Which rows does it match?
Can a join return more rows than either table has?
What is a self join, and why does it need aliases?
How do you find the rows in one table that have no match in the other?
NATURAL JOIN, USING, or ON. Which do you write?
Your LEFT JOIN returned exactly the INNER JOIN rows. What happened?
Is a RIGHT JOIN ever necessary?
Which join types are not available everywhere?
Have you ever written a CROSS JOIN on purpose?
08 Practice problems
Six to work through
Every one of these uses the four students and three departments from section 02. Write the pairings out on paper before you write any SQL. Deriving a row count by reasoning about what the query “should” return is exactly the habit these problems are built to break.