SQL Server JOIN Types Explained: INNER, LEFT, RIGHT, FULL, CROSS, SELF

SQL Server JOIN Types Explained: INNER, LEFT, RIGHT, FULL, CROSS, SELF

Joining tables is where SQL starts feeling genuinely powerful — and where a subtle mistake silently produces wrong results with zero errors. This lesson covers all six JOIN types with real output differences, then spends real time on the single bug that catches nearly everyone at least once.

Six Ways to JOIN Two Tables(same two tables, six different results)INNER JOINonly the matchesLEFT JOINall left + matchesRIGHT JOINall right + matchesFULL OUTEReverything, both sidesCROSS JOINevery combinationSELF JOINDrivertable joined to itselfGotcha: a WHERE filter on the right table’s columnsilently turns LEFT JOIN back into INNER JOIN — filter in ON instead.

The Core Two

-- INNER JOIN: only rows that match in both tables
SELECT d.full_name, t.fare_usd
FROM dbo.Driver d
INNER JOIN dbo.Trip t ON d.driver_id = t.driver_id;

-- LEFT JOIN: all rows from the left table, matched rows from the right (NULL if no match)
SELECT d.full_name, t.fare_usd
FROM dbo.Driver d
LEFT JOIN dbo.Trip t ON d.driver_id = t.driver_id;

INNER JOIN LEFT JOIN

Concretely, if a driver named ‘Amir Khan’ exists but has never had a trip logged: INNER JOIN omits him from the result entirely; LEFT JOIN still shows one row for him, with fare_usd as NULL. That NULL is the entire reason LEFT JOIN exists — it’s how you answer “show me every driver, including ones with zero trips.”

The Rest of the Set

-- RIGHT JOIN: mirror of LEFT — all rows from the right table instead
SELECT d.full_name, t.fare_usd FROM dbo.Trip t RIGHT JOIN dbo.Driver d ON d.driver_id = t.driver_id;

-- FULL OUTER JOIN: everything from both sides, matched where possible
SELECT d.full_name, t.trip_id FROM dbo.Driver d FULL OUTER JOIN dbo.Trip t ON d.driver_id = t.driver_id;

-- CROSS JOIN: every combination (Cartesian product) — rarely intentional by accident
SELECT d.full_name, x.label FROM dbo.Driver d CROSS JOIN (VALUES ('Gold'),('Silver')) AS x(label);

-- SELF JOIN: a table joined to itself — e.g. drivers sharing a city
SELECT d1.full_name, d2.full_name, d1.city
FROM dbo.Driver d1 INNER JOIN dbo.Driver d2 ON d1.city = d2.city AND d1.driver_id < d2.driver_id;

In practice, RIGHT JOIN is rarely used on purpose — anything expressible with RIGHT JOIN can be rewritten as a LEFT JOIN by swapping which table comes first, and most style guides prefer that for consistency. FULL OUTER JOIN is genuinely useful for reconciliation tasks ("what's in table A but not B, and vice versa, in one query"). CROSS JOIN's real, non-accidental use case is generating combinations — like every product paired with every size, before either exists in a real order.

The SELF JOIN's d1.driver_id < d2.driver_id condition deserves its own note: without it, every pair of same-city drivers would appear twice (Amir/Priya and Priya/Amir), plus every driver paired with themselves. The inequality keeps exactly one direction of each unique pair.

The Bug That Gets Everyone at Least Once

Using LEFT JOIN correctly, then adding a WHERE filter on the right-hand table's column, silently turns it back into an INNER JOIN:

-- BUG: this discards the NULL rows LEFT JOIN was specifically trying to preserve
SELECT d.full_name, t.fare_usd
FROM dbo.Driver d LEFT JOIN dbo.Trip t ON d.driver_id = t.driver_id
WHERE t.fare_usd > 20;

NULL fails the > 20 comparison (from Chapter 3's three-valued-logic rule), so unmatched drivers with no trips disappear from the result — exactly what LEFT JOIN was meant to prevent. The query runs without error and looks completely reasonable; you only notice something's wrong when a driver you know exists is mysteriously missing from a report.

-- The fix: move the condition into the ON clause instead of WHERE
SELECT d.full_name, t.fare_usd
FROM dbo.Driver d LEFT JOIN dbo.Trip t ON d.driver_id = t.driver_id AND t.fare_usd > 20;
-- Now unmatched drivers still appear (fare_usd NULL); only trips are filtered before the join completes
The rule to memorize: a WHERE condition on the "preserved" side's columns filters the final result, potentially undoing your LEFT JOIN. The same condition inside the ON clause filters before the join decides what counts as a match, which is almost always what you actually want when the goal is "keep all drivers, but only join in their high-value trips."
Practice tip: Run the buggy version and the fixed version side by side and count the rows returned by each. Seeing the actual row-count difference with your own data makes this rule permanent in a way that reading about it doesn't.

Enjoyed this?

Subscribe to get every new SQL Server lesson as soon as it's published, and share it with a developer who'd find it useful.

📡 Subscribe via RSS  | 
Share on X  | 
Share on LinkedIn  | 
Share on Facebook

Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server Fundamentals, coming soon on this site.