How do you emulate a FULL OUTER JOIN on an engine that supports only LEFT and RIGHT joins?
answer
- no native support, so build it from parts
- one branch per preserved side
- second branch keeps only non-matching rows
- UNION ALL, never plain UNION
- test the other side's primary key
basics
~20 sRun a LEFT JOIN for the matched and left-only rows, then UNION ALL a second branch that keeps only the right-side rows with no left match. Use UNION ALL, not UNION, so legitimate duplicate rows survive.
solid answer
~50 sSplit the full outer join into its two preserved sides. The first branch is a plain `LEFT JOIN`, which already gives you the matched rows and the left-only rows. The second branch must contribute **only the right-only rows**, so write it as a `RIGHT JOIN` (or an equivalent reversed `LEFT JOIN`) with `WHERE left.pk IS NULL` — an anti-join filter. Combine them with `UNION ALL`. Three details decide whether the rewrite is correct: both branches must project the same columns in the same order and types; the anti-join test must use a column the left table guarantees is populated, such as its primary key, or matched rows will be misclassified; and it must be `UNION ALL` rather than `UNION`, because `UNION` removes duplicate rows that the real join would have returned. This is the standard workaround on MySQL, which has no `FULL OUTER JOIN`.
code
sql · 9 lines-- Emulation: LEFT JOIN branch plus an anti-joined right-only branch
SELECT e.id AS emp_id, e.name AS emp_name, d.id AS dept_id, d.name AS dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id
UNION ALL
SELECT e.id, e.name, d.id, d.name
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.id
WHERE e.id IS NULL; -- e.id is the employees primary keygo deeper
Know that some engines have no FULL OUTER JOIN and that the workaround is a LEFT JOIN combined with a second query using UNION ALL. Be able to read such a query and say what it is doing.
Write the rewrite from scratch and defend each piece: why the second branch needs an anti-join filter, why the filter tests a primary key, and why UNION ALL rather than UNION.
Point out the failure modes you would catch in review — doubled matched rows, mismatched column lists between branches, a nullable column used as the anti-join test — and collapse the pattern back to a native full outer join where the engine supports one.
Weigh the portability cost: whether the codebase targets one engine or several decides if hand-written emulations are acceptable or should be hidden behind a generated layer or a view.
## Why you would need this `FULL OUTER JOIN` is standard SQL, but not every engine implements it — MySQL notably does not, and SQLite only gained it in version 3.39. Since the operation is definable in terms of joins already available, the workaround is mechanical, and interviewers use it to check that you can decompose a join type into its parts. ## The decomposition A full outer join is three disjoint sets of rows: matched pairs, left-only rows NULL-extended on the right, and right-only rows NULL-extended on the left. A `LEFT JOIN` already delivers the first two. So the rewrite is: ``` (LEFT JOIN result) UNION ALL (right-only rows) ``` and the only work is expressing "right-only rows" without a full join. That is an anti-join: run the join with the right side preserved, then keep just the rows where the left side came back empty. ```sql SELECT e.id AS emp_id, e.name AS emp_name, d.id AS dept_id, d.name AS dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.id UNION ALL SELECT e.id, e.name, d.id, d.name FROM employees e RIGHT JOIN departments d ON e.dept_id = d.id WHERE e.id IS NULL; ``` If you would rather avoid `RIGHT JOIN` entirely, flip the tables and keep the projection order the same: ```sql ... UNION ALL SELECT e.id, e.name, d.id, d.name FROM departments d LEFT JOIN employees e ON e.dept_id = d.id WHERE e.id IS NULL; ``` Both forms are equivalent. The second is often preferred stylistically, because a query that mixes `LEFT` and `RIGHT` joins is harder to read. ## Three ways to get it wrong **Forgetting the anti-join filter.** Without `WHERE e.id IS NULL`, the second branch returns the matched rows a second time, so every matched pair appears twice. This is the most common bug in the rewrite, and it is invisible until someone sums a column. **Testing the wrong column, or a nullable one.** The filter must ask "did the left side fail to match?", so it has to test a column that is guaranteed to be populated whenever a left row was found — the primary key is the safe choice. Testing a nullable attribute such as `e.manager_id IS NULL` would also pass matched rows whose manager is unknown, pulling duplicates back in. Testing the wrong side (`WHERE d.id IS NULL` in the branch that preserves `departments`) yields nothing useful at all, because the preserved side is never NULL-extended. **Using `UNION` instead of `UNION ALL`.** `UNION` removes duplicate rows across the whole combined result. If two employees share every projected column value, the real full outer join returns both and `UNION` returns one. It also forces the engine to do extra deduplication work you do not want. Since the branches are already disjoint by construction, `UNION ALL` is both correct and cheaper. ## Column-list discipline Set operators combine branches positionally, not by name: the first column of branch one lines up with the first column of branch two regardless of what they are called, and the types must be compatible. Column names in the final result come from the first branch, which is why the aliases sit there. `SELECT *` in both branches is a trap — `FROM departments d LEFT JOIN employees e` produces a different column order from `FROM employees e LEFT JOIN departments d`, and a positional mismatch may not even raise an error if the types happen to line up. Always list columns explicitly. ## Reconstructing a unified key Since the emulation still emits two key columns, apply the same `COALESCE(e.dept_id, d.id)` treatment you would apply to a real full outer join, and do it identically in both branches so the combined result is consistent. ## A variant worth knowing An alternative decomposition is inner join plus both anti-join branches: matched rows from an `INNER JOIN`, left-only rows from `LEFT JOIN ... WHERE right.pk IS NULL`, right-only rows from the mirror image, all combined with `UNION ALL`. It is three branches instead of two and touches the tables three times, so it is rarely the first choice — but it makes the three-bucket structure explicit, and it is handy when each bucket needs a different projection, for example a status label. ## Reading it back When you meet this pattern in an unfamiliar codebase, recognise it for what it is: a `LEFT JOIN` plus an anti-joined mirror is a full outer join written by hand, usually because the engine lacked one. On an engine that does support `FULL OUTER JOIN`, collapse it back — one statement is clearer and scans each table once.
- What happens if you leave out the WHERE clause on the second branch?Every matched pair is emitted twice — once by the `LEFT JOIN` branch and once by the unfiltered second branch — so counts and sums roughly double for the overlapping keys. The result still looks structurally plausible, which is why the bug survives review; the anti-join filter is what makes the two branches disjoint.
- Why is UNION ALL the correct set operator rather than UNION here?`UNION` deduplicates the combined result, so two distinct source rows that happen to agree on every projected column would collapse into one — a row the real full outer join returns twice. The branches are already disjoint thanks to the anti-join filter, so deduplication can only remove legitimate rows, at extra cost.
- How would you write the rewrite with three branches instead of two?Inner join for the matched rows, `LEFT JOIN ... WHERE right.pk IS NULL` for left-only rows, and the mirrored version for right-only rows, all combined with `UNION ALL`. It reads the tables three times, but each bucket is explicit, which helps when you want to tag rows with a different status label per branch.
saying these in an interview costs you the question
- Combines a LEFT JOIN and an unfiltered RIGHT JOIN
- Uses UNION to hide the duplicate matched rows
- Filters on a nullable attribute instead of the key
- Assumes SELECT * lines the two branches up correctly
- Claims a full outer join cannot be expressed without native support