跪拜 Guibai
← All articles
Backend · Database

SQL JOINs Break at the Boundaries — Test the Edge Cases Before They Hit Production

By 一只牛博 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

JOIN mistakes don't throw syntax errors — they silently drop rows, inflate counts, or turn outer joins into inner joins. A developer who hasn't run these boundary cases will ship reports that undercount users or overcount revenue, and the bug will survive code review because the SQL looks correct.

Summary

Three small tables with orphan rows — a user without orders, an order without a user, a payment without an order — expose exactly how each JOIN type behaves at the edges. INNER JOIN drops unmatched rows from both sides. LEFT JOIN preserves every row from the left table and fills unmatched right-side columns with NULL. RIGHT JOIN does the reverse. FULL JOIN keeps everything, making gaps visible on both sides at once.

The real trap is condition placement. Putting a filter on the right table inside the ON clause limits which rows participate in the join but still preserves left-table rows. Moving that same filter to WHERE silently converts the outer join into an inner join by eliminating rows where the right-side columns are NULL. The SQL looks almost identical; the row count can drop by more than half.

Statistical queries compound the confusion. After a one-to-many LEFT JOIN, COUNT(*) counts the expanded result rows — not the distinct entities. A user with two orders contributes two rows, inflating totals. COUNT(DISTINCT left_table.id) recovers the true entity count, while COUNT(right_table.id) correctly counts only matched detail rows. EXISTS handles simple presence checks without the row-multiplication problem at all.

Takeaways
INNER JOIN returns only rows where both tables match; unmatched rows on either side disappear from the result.
LEFT JOIN keeps every row from the left table and fills unmatched right-side columns with NULL — users without orders still appear.
RIGHT JOIN keeps every row from the right table; orders linked to nonexistent users show up with NULL user fields.
FULL JOIN preserves unmatched rows from both sides, making it the right tool for reconciliation when either table may have orphans.
Putting a right-table filter in ON limits which rows participate in the join but still preserves left-table rows.
Moving that same filter to WHERE eliminates rows where the right-side columns are NULL, effectively turning the outer join into an inner join.
After a one-to-many LEFT JOIN, COUNT(*) counts expanded result rows, not distinct entities — a user with two orders contributes two to the total.
COUNT(DISTINCT left_table.id) recovers the true entity count; COUNT(right_table.id) counts only matched detail rows, correctly returning zero for unmatched left-table rows.
EXISTS answers presence questions without row multiplication and without pulling detail columns into the result.
Three-table LEFT JOINs inherit their starting point from the first table; orphan rows in later tables won't appear unless the join chain reaches them.
Conclusions

Most JOIN bugs are invisible in code review because the SQL parses correctly and returns non-empty results — the error is in what's missing or double-counted.

The ON-vs-WHERE distinction is the single most common outer-join mistake in production, and it's rarely caught by ORM abstractions that obscure where the condition actually lands.

FULL JOIN is underused in MySQL-centric teams because MySQL didn't support it until recent versions, yet it's the most direct way to surface orphan data on both sides of a relationship.

COUNT(*) after a join is a loaded footgun: it counts rows, not entities, and the difference only becomes visible when the data contains one-to-many relationships or unmatched rows.

Deliberately seeding test data with orphan rows — users without orders, orders without users — exposes JOIN behavior that clean demo data hides, and should be standard practice before writing any reporting query.

Concepts & terms
INNER JOIN
Returns only rows where the join condition matches on both tables. Rows from either table that lack a counterpart are excluded from the result set.
LEFT JOIN (LEFT OUTER JOIN)
Returns all rows from the left table, plus matching rows from the right table. Where no match exists, right-table columns are filled with NULL.
RIGHT JOIN (RIGHT OUTER JOIN)
Returns all rows from the right table, plus matching rows from the left table. Where no match exists, left-table columns are filled with NULL. Functionally equivalent to swapping table order in a LEFT JOIN.
FULL JOIN (FULL OUTER JOIN)
Returns all rows from both tables. Matching rows are combined; unmatched rows from either side appear with NULLs in the other table's columns. Useful for finding orphan records on both sides simultaneously.
ON vs. WHERE in outer joins
Conditions in ON filter which rows participate in the join but preserve unmatched outer-table rows. Conditions in WHERE filter the final joined result and eliminate rows where outer-table columns are NULL, effectively converting an outer join to an inner join.
COUNT(*) after JOIN
Counts the number of rows in the joined result set, not the number of distinct entities. A one-to-many join expands rows, so COUNT(*) overcounts the entity on the 'one' side. Use COUNT(DISTINCT id) for entity counts.
EXISTS
A SQL predicate that returns true if a subquery returns any rows. Used for presence checks without pulling detail columns or multiplying rows, avoiding the need for JOIN followed by DISTINCT.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗