SQL JOINs — 800 Orders Dropped by Wrong INNER JOIN
A wrong INNER JOIN dropped 800 orders/month, causing a 15-20% revenue reporting gap.
- INNER JOIN returns only rows matching on both sides — unmatched rows are silently dropped
- LEFT JOIN preserves every row from the left table, NULLs fill the right side where no match
- RIGHT JOIN is LEFT JOIN with tables swapped — prefer LEFT JOIN for readability
- FULL OUTER JOIN shows all rows from both tables, NULLs on the unmatched side
- Anti-join pattern: LEFT JOIN + WHERE right_table.id IS NULL finds rows with no match
- Biggest mistake: aggregating across multiple one-to-many JOINs causes row explosion — SUM doubles silently
Imagine your school has two notebooks: one lists every student's name and ID, and another lists which students signed up for after-school clubs. A JOIN is just the teacher combining those two notebooks to answer questions like 'which students are in Drama club?' or 'which students haven't joined ANY club yet?' The database does the same thing — it stitches two tables together along a shared column so you can ask questions that span both. That shared column is the bridge, and the type of JOIN you pick decides what happens when the bridge only goes one way.
Every real-world application stores data across multiple tables. An e-commerce site keeps customers in one table, orders in another, and products in a third. That's not sloppy design — it's intentional. Splitting data this way (called normalisation) eliminates duplication and keeps your database consistent. But the moment you need to answer a question like 'show me every customer and what they ordered last month,' you need a way to bring those tables back together. That's exactly what JOINs were built for, and they're one of the most-used features in SQL.
Before JOINs existed, developers would pull raw data from each table separately and stitch it together in application code. This was slow, bug-prone, and pushed enormous amounts of data across the network. JOINs moved that logic into the database engine itself — where it can be optimised, indexed, and executed orders of magnitude faster than anything you'd write by hand in Python or Java.
By the end of this article you'll know not just the syntax of each JOIN type, but — more importantly — you'll know which JOIN to reach for in a given situation, why the wrong JOIN silently returns misleading results, and how to spot JOIN-related performance traps before they bite you in production.
INNER JOIN: Only the Records That Match on Both Sides
An INNER JOIN returns rows only when a matching value exists in both tables. Think of it as the intersection in a Venn diagram. If a customer has never placed an order, they won't appear in the result. If an order somehow has no matching customer, it won't appear either. Only the overlap makes the cut.
This is the right JOIN when you genuinely only care about complete relationships. 'Show me every order alongside the customer who placed it' — that's INNER JOIN territory. You're not interested in orders without customers (data anomalies you'd fix separately) or customers who haven't ordered yet (a different business question entirely).
One subtlety worth knowing: INNER JOIN is the default JOIN in most SQL dialects. Writing JOIN without a keyword in front of it gives you an INNER JOIN. Being explicit with INNER JOIN makes your intent clearer to anyone reading the query later — including future-you at 11pm debugging a production incident.
LEFT JOIN: Keep Everything From the Left, Match What You Can From the Right
A LEFT JOIN returns every row from the left (first) table, and fills in matching data from the right table where it exists. Where there's no match on the right side, SQL fills those columns with NULL. The left table is never filtered — it always shows up in full.
This is the JOIN you reach for when the left table contains the 'source of truth' and the right table has optional enrichment data. Classic scenarios: 'show me all customers and their orders (including customers who haven't ordered yet),' or 'list all products and how many times each has been reviewed (including products with zero reviews).'
NULL in the right-side columns is actually meaningful signal here — it tells you 'this row exists but has no matching data on the right.' You can exploit that intentionally by filtering WHERE right_table.column IS NULL, which gives you only the rows with no match. That pattern is called an anti-join and it's surprisingly powerful for finding gaps: unreviewed products, uninvoiced contracts, customers who never completed onboarding.
RIGHT JOIN and FULL OUTER JOIN: Completing the Picture
A RIGHT JOIN is the mirror image of LEFT JOIN — every row from the right table is preserved, and unmatched rows from the left get NULLs. In practice, most developers avoid RIGHT JOIN entirely because you can always rewrite it as a LEFT JOIN by swapping the table order. The result is identical, but LEFT JOIN reads more naturally left-to-right.
FULL OUTER JOIN is the union of LEFT and RIGHT JOIN — every row from both tables appears in the result. Where a match exists, the columns are populated. Where there's no match on either side, you get NULLs for the missing half. This is rare in everyday queries, but it's invaluable for data reconciliation work: comparing two systems to find records that exist in one but not the other, like syncing a CRM against a billing platform.
Not all databases support FULL OUTER JOIN natively. MySQL doesn't — but you can emulate it cleanly with a UNION of a LEFT JOIN and a RIGHT JOIN.
JOIN Performance: Why Your Query Slows Down at Scale
Understanding JOIN types is only half the battle. In production, a perfectly correct JOIN can bring a database to its knees if you haven't thought about how the engine executes it.
The most important thing you can do for JOIN performance is index your JOIN columns. When you write ON orders.customer_id = customers.customer_id, both columns should be indexed. The primary key side is usually indexed automatically. The foreign key side often isn't — and that's where most JOIN slowdowns originate. Without an index, the database performs a full table scan for every row on the other side.
The second trap is joining on expressions or functions: ON LOWER(a.email) = LOWER(b.email). This forces a full scan because the index can't be used against a computed result. Store data in a consistent format at write time, not query time.
Finally, watch your row explosion problem. JOINing a one-to-many relationship and then running SUM() on it is a classic mistake. If a customer has 10 orders and you join to a promotions table where they have 3 active promos, your SUM suddenly operates on 30 rows instead of 10.
Silent Revenue Under-Reporting from a Wrong JOIN Type
- INNER JOIN is never 'safe by default' — it makes a hard assumption that referential integrity is perfect
- Always cross-check JOIN results against a raw COUNT(*) on the source table
- Missing rows don't produce errors — they produce wrong numbers that look plausible
SUM() or COUNT() returns values that are multiples of the expected answerKey takeaways
Common mistakes to avoid
3 patternsUsing INNER JOIN when LEFT JOIN is needed
Filtering a LEFT JOIN's right table in the WHERE clause
Aggregating across multiple one-to-many JOINs in a single query
SUM(), COUNT() or AVG() returns values that are exact multiples (2x, 3x) of the correct answer — query runs fine and returns plausible-looking resultsInterview Questions on This Topic
What's the difference between a LEFT JOIN and an INNER JOIN, and how do you decide which to use?
Frequently Asked Questions
That's SQL Basics. Mark it forged?
4 min read · try the examples if you haven't