SQL Joins Explained: Inner, Left, Right and Full
What INNER, LEFT, RIGHT and FULL joins actually do, why rows duplicate or disappear, and how to pick the right one for a report.
Short answer:
WHEREfilters individual rows before any grouping happens.GROUP BYcollapses the remaining rows into groups.HAVINGfilters those groups after aggregation. You can’t useWHEREto filter on an aggregate likeSUM(), because at the pointWHEREruns, no aggregate has been computed yet.
If you’ve ever gotten column "total" must appear in the GROUP BY clause or wondered why WHERE SUM(amount) > 100 throws an error, the cause is the same: SQL doesn’t execute in the order you write it.
You write a query as SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY, but the database evaluates it in a different sequence:
FROM (and any joins) — build the working set of rowsWHERE — filter individual rowsGROUP BY — collapse the remaining rows into groupsHAVING — filter the groupsSELECT — compute the output columnsORDER BY — sort the resultThis is why WHERE can’t reference a column alias defined in SELECT, and why it can’t filter on an aggregate: WHERE runs before SELECT computes anything and before GROUP BY has produced any groups to aggregate over.
Say you want customers with total spend over $1,000.
-- This fails
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE SUM(amount) > 1000
GROUP BY customer_id;
At the moment WHERE runs, rows haven’t been grouped yet, so there’s no per-customer sum to compare against. The database has no idea what SUM(amount) means at that stage. The fix is HAVING, which runs after grouping:
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 1000;
WHERE answers “which rows do I even want to look at?” — applied before anything is summarized.GROUP BY answers “how do I want to bucket the rows that survived?”HAVING answers “which of those buckets meet a condition on the summary?”A useful way to remember it: if the condition mentions a raw column (status = 'paid', order_date >= '2026-01-01'), it belongs in WHERE. If it mentions an aggregate function (SUM, COUNT, AVG, MAX), it belongs in HAVING.
It can run, but it’s slower and often confusing. HAVING filters after grouping, so if you filter something that could have been filtered before grouping, you’ve made the database group rows it was going to throw away anyway.
-- Works, but wasteful: filters after grouping every row, including ones we don't want
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING customer_id != 0;
-- Better: filter before grouping
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE customer_id != 0
GROUP BY customer_id;
On a small table you won’t notice. On a large fact table, filtering early with WHERE reduces how much data ever reaches the grouping step, which usually reduces both compute and memory.
This is the normal case, not an edge case: filter the rows you care about, group them, then filter the resulting groups.
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING COUNT(*) >= 3;
Read it as: only look at paid orders, group what’s left by customer, then keep only customers with three or more paid orders.
WHERE. The database will simply reject the query. Move the condition to HAVING.HAVING for something WHERE could have done. It still returns the right answer, just slower on large tables.GROUP BY list. Most databases require every non-aggregated column in SELECT to also appear in GROUP BY, because otherwise it’s ambiguous which row’s value to show for that group.GROUP BY changes the grain of the result. A query with GROUP BY customer_id returns one row per customer, no matter how many order rows fed into it. If you also need the order-level detail, that’s a separate query, or a window function instead of GROUP BY — see SQL window functions, which keep every row while still calculating across them.My take: write WHERE first, always, even for conditions that technically could live in HAVING. It’s a habit that keeps queries fast by default and makes the two clauses’ jobs obvious to whoever reads it after you.
For more patterns like this one, see the SQL cheat sheet, and for a deeper look at how row-by-row calculations differ from grouping, CTE vs subquery vs temp table covers the next layer of query structure.
Part of: SQL & Local Analytics