On this page
Description
Most of what you'll ever ask a database comes down to three moves: pick the rows you want (WHERE), pick the columns you want (SELECT), and bring related tables together (JOIN). They're the first SQL anyone learns, and they're also where most real SQL bugs live - because a wrong query rarely errors. It runs, returns rows that look plausible, and quietly answers a different question.
Here's a small example. "Which customers have never placed an order?" Both of these look reasonable:
-- Attempt 1
SELECT c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- Attempt 2
SELECT name
FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
On the sample data below, both return zero rows, with no error. The right answer is Ben and Dana. By the end of this lesson you'll be able to say exactly why each attempt fails, and write the version that works.
We'll start simple and get more precise as we go:
- The order a query really runs in - the one idea that explains most surprises.
SELECTandWHERE, and whatNULL(a missing value) does to a filter.- Joins: inner, left, and the "has a match" / "has no match" questions.
GROUP BY, and how a join can silently double your totals.- The common mistakes, and which phrasing to prefer.
Every result quoted below was run in PostgreSQL, on one small dataset used throughout:
customers
| id | name | city |
|---|---|---|
| 1 | Asha | Austin |
| 2 | Ben | NULL |
| 3 | Chen | Dublin |
| 4 | Dana | Austin |
orders (order 14 is a guest checkout, with no customer)
| id | customer_id | total | status | created_at |
|---|---|---|---|---|
| 10 | 1 | 250 | paid | 2026-01-05 10:00 |
| 11 | 1 | 100 | refunded | 2026-01-31 18:30 |
| 12 | 3 | 400 | paid | 2026-01-20 09:15 |
| 13 | 3 | 50 | shipped | 2026-02-01 00:00 |
| 14 | NULL | 75 | paid | 2026-01-31 23:59 |
tickets (support tickets): Asha has three (ids 100-102), Chen has one (103).
The pattern: read a query in the order it runs
A query is written SELECT-first but evaluated in a different logical order. Each step receives the rows the previous step produced:
FROM/JOIN- build the combined rowsWHERE- keep the rows whose condition is trueGROUP BY- collapse rows into groupsHAVING- keep the groups whose condition is trueSELECT- compute the output columns and their aliasesDISTINCT- remove duplicate output rowsORDER BY- sortLIMIT/OFFSET- cut
Rules you can derive instead of memorizing:
WHEREcan't use aSELECTalias - it doesn't exist yet (ERROR: column "gross" does not exist).ORDER BYcan, because it runs afterSELECT.WHEREcan't use aggregates (ERROR: aggregate functions are not allowed in WHERE) - there are no groups yet. Conditions on groups go inHAVING.- Attempt 1: the
JOINstep already removed the customers without orders, soWHERE o.id IS NULLhas nothing left to find.
Read more — logical order vs. the execution plan
This order defines what a query means, not how the database runs it. The planner freely pushes filters down, reorders joins and uses indexes, as long as the result is the one the logical order describes. Reason about results with the logical order, and about speed with EXPLAIN.
SELECT and WHERE
SELECT id, total * 1.18 AS gross
FROM orders
WHERE status IN ('paid', 'shipped')
AND created_at >= '2026-01-01' AND created_at < '2026-02-01'
ORDER BY gross DESC
LIMIT 10; -- 12 (472.00), 10 (295.00), 14 (88.50)
ANDbinds tighter thanOR.status = 'paid' OR status = 'shipped' AND total > 100meanspaid OR (shipped AND total > 100)- every paid order, even the 75 one: 10, 12 and 14. Use parentheses, orIN.BETWEEN a AND bincludes both ends. On a timestamp,BETWEEN '2026-01-01' AND '2026-01-31'stops at midnight starting January 31 and misses orders 11 and 14; widening it to'2026-02-01'wrongly takes order 13, placed at exactly midnight. Use a half-open range:>= '2026-01-01' AND < '2026-02-01'.LIKE:%matches any run of characters,_exactly one. It is case-sensitive in PostgreSQL (ILIKEisn't):name LIKE '_a%'matches only Dana.- No
ORDER BY, no order. Rows come back in whatever order the plan produces, soLIMIT 10withoutORDER BYreturns some 10 rows - which can change between runs. DISTINCTapplies to the whole selected row, not the first column.
NULL: the third truth value
NULL means "unknown", and a comparison with an unknown value is unknown - not true and not false. WHERE keeps only the rows where the condition is true.
| Expression | Result on the sample data |
|---|---|
city = NULL | unknown for every row - no rows |
city IS NULL | Ben |
city <> 'Austin' | Chen only - Ben's unknown city is dropped too |
city IS DISTINCT FROM 'Austin' | Ben and Chen |
id NOT IN (1, NULL) | never true - no rows |
id IN (1, NULL) | Asha - IN is still safe |
id NOT IN (1, NULL) expands to id <> 1 AND id <> NULL; the second part is unknown, so the whole condition can't be true. That's Attempt 2's bug: SELECT customer_id FROM orders contains a NULL. The fix is NOT EXISTS, below.
NULLs are not treated as unknown in three places: DISTINCT and GROUP BY put all NULLs into one group, and ORDER BY sorts them together - last in ascending order in PostgreSQL, first in descending (MySQL and SQL Server put them first in ascending order; write NULLS LAST when it matters).
JOINs
SELECT c.name, o.id AS order_id
FROM customers c
JOIN orders o ON o.customer_id = c.id; -- INNER: only matching pairs
That returns (Asha, 10), (Asha, 11), (Chen, 12), (Chen, 13). Ben and Dana have no match and vanish; guest order 14 has no customer and vanishes too. A customer appears once per matching order - joins repeat the "one" side of a one-to-many.
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id; -- every customer; NULLs where no order
That returns 6 rows: Asha twice, Ben with NULL, Chen twice, Dana with NULL. Side by side, on the rows themselves (not Venn circles - a join pairs rows, it doesn't intersect sets):
The two correct answers to "never ordered" follow from it:
-- anti-join with LEFT JOIN: keep the rows the join had to fill with NULLs
SELECT c.name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- NOT EXISTS: NULL-safe, and reads like the question
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Both return Ben and Dana. Test a column that can't be NULL in a real match, such as the primary key.
ON vs. WHERE in an outer join. "Every customer, with their paid orders":
... LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' -- 4 rows: Ben and Dana kept, with NULLs
... LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid' -- 2 rows: the NULL-filled rows fail WHERE
A WHERE condition on the right-hand table of a LEFT JOIN removes exactly the rows the LEFT JOIN added, turning it back into an inner join.
Read more — self-joins, cross joins and semi-joins
- A table can join itself under two aliases:
employees e LEFT JOIN employees m ON m.id = e.manager_idlists each employee with their manager -LEFT, so the CEO, who has no manager, isn't lost. - Leave out the join condition (
FROM customers c, orders o WHERE c.city = 'Austin') and you get a cross join: 2 Austin customers × 5 orders = 10 rows, each customer paired with every order. ExplicitJOIN ... ONmakes a missing condition much harder to miss. - A join on the wrong column still runs.
ON o.id = c.idpairs customer 2 with order 2, whoever placed it: the output has the right shape and the wrong content. EXISTSis a semi-join: it returns each customer at most once however many orders match, whereJOINreturns one row per match.SELECT DISTINCThides the repeats, but it does extra work and would also merge rows that really are duplicates.
Which join do I need?
| You want | Write | Watch out for |
|---|---|---|
| Rows that have a match, with the match's columns | JOIN (inner) | One output row per match - a customer with 3 orders appears 3 times |
| Every row on one side, matched or not | LEFT JOIN | Conditions on the right table go in ON, not WHERE |
| Rows that have at least one match, once each | WHERE EXISTS (...) | Nothing - this is the semi-join; no duplicates to remove |
| Rows with no match | WHERE NOT EXISTS (...) or LEFT JOIN ... WHERE right.pk IS NULL | NOT IN returns nothing if the subquery yields a NULL |
| Both sides, matched or not | FULL JOIN | Rare in practice - usually reconciling two sources |
| Every combination | CROSS JOIN | Rows multiply: 1,000 × 1,000 is a million |
RIGHT JOIN exists too; it's a LEFT JOIN with the tables swapped, and most teams write the LEFT form so every query reads in the same direction.
Second flavor: GROUP BY and the fan-out trap
SELECT c.name, COUNT(o.id) AS orders, COALESCE(SUM(o.total), 0) AS spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY spent DESC; -- Chen 2 / 450, Asha 2 / 350, then Ben and Dana 0 / 0
- Every selected column must be grouped or aggregated.
SELECT customer_id, status, COUNT(*) FROM orders GROUP BY customer_idfails: which of a customer's statuses would it show? (PostgreSQL does allowc.namewhen grouping byc.id, becauseidis the primary key and so determines the name. MySQL withONLY_FULL_GROUP_BYturned off returns an arbitrary value instead of an error.) COUNT(*)counts rows;COUNT(col)counts non-NULL values. After theLEFT JOIN, Ben has one row with a NULL order, soCOUNT(*)says 1 whileCOUNT(o.id)correctly says 0. Oncustomers:COUNT(*)is 4,COUNT(city)is 3,COUNT(DISTINCT city)is 2.- Aggregates over no rows:
SUM,AVG,MINandMAXof nothing are NULL, not 0 (COUNTis 0) - hence theCOALESCE. WHEREfilters rows before grouping,HAVINGfilters groups after:WHERE o.status = 'paid' ... GROUP BY c.id HAVING SUM(o.total) > 300returns Chen (400).
Fan-out. Join two independent one-to-many tables to the same parent, and they multiply:
SELECT c.name, SUM(o.total) AS spent, COUNT(t.id) AS tickets
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN tickets t ON t.customer_id = c.id
GROUP BY c.id, c.name; -- Asha: 1050 and 6 (really 350 and 3); Chen: 450 and 2
Asha's 2 orders × 3 tickets make 6 joined rows, so each order total is counted three times and each ticket twice. Aggregate each child table on its own first, then join the one-row-per-customer results.
Read more — why fan-out survives code review
The query raises no error, the numbers look plausible, and customers with a single ticket come out right - Chen's 450 is correct. Only customers with several rows in both children are wrong, each total inflated by the other child's count. COUNT(DISTINCT t.id) rescues the count but not the sum: two different orders can have the same total, so SUM(DISTINCT o.total) would undercount. Aggregating before joining is the fix that holds.
Where it breaks
NOT INwith a NULL anywhere in the list - no rows, silently.- A
WHEREcondition on the optional side of aLEFT JOIN- the "including those with none" rows vanish. COUNT(*)after aLEFT JOIN- customers with nothing count as 1.- Joining two one-to-many children - sums and counts multiply.
ORwithout parentheses;BETWEENon timestamps.= NULLmatching nothing, and<> valuequietly dropping NULL rows.- A join on the wrong column - plausible pairs, wrong data.
Tradeoffs
| Need | Options | Prefer |
|---|---|---|
| Rows with no match | NOT EXISTS, LEFT JOIN ... IS NULL, NOT IN | NOT EXISTS (NULL-safe) or LEFT JOIN ... IS NULL; NOT IN only when the column can't be NULL |
| Rows with at least one match | EXISTS, IN, JOIN + DISTINCT | EXISTS or IN - nothing to de-duplicate |
| Totals from two child tables | Join then aggregate, or aggregate then join | Aggregate first |
| A filter | WHERE on rows, HAVING on groups | WHERE whenever no aggregate is needed - it shrinks the input to grouping |
Going further. The deep dive (next tab) leaves the logical model and opens up the engine: the three algorithms a database uses to run a join, how it chooses between them from row estimates, why NOT EXISTS and NOT IN get different plans, and what goes wrong at scale.
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. SELECT c.name, COUNT(*) FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id, c.name reports Dana with 1 order. She has none. Why, and what's the fix?
Show answer
The LEFT JOIN gives Dana one row with NULL order columns, and COUNT(*) counts rows. COUNT(o.id) counts non-NULL order ids, so it reports 0.
2. "Every customer with their orders, excluding refunds" is written as a LEFT JOIN plus WHERE o.status <> 'refunded'. Which customers disappear, and why?
Show answer
Ben and Dana. Their rows have a NULL o.status, NULL <> 'refunded' is unknown, and WHERE drops unknown rows - the result is Asha 10, Chen 12 and Chen 13. Moving the condition into ON keeps them with NULLs.
3. SELECT customer_id, SUM(total) FROM orders GROUP BY customer_id returns three rows, not two. Where does the third come from?
Show answer
From the guest order. GROUP BY puts all NULL keys into one group, so order 14 forms its own group: customer_id NULL with a sum of 75, next to 1 (350) and 3 (450).
Key takeaways
- Read a query in its logical order - FROM/JOIN, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT - and most surprises explain themselves.
- NULL is unknown: use
IS NULL, remember<>drops NULLs, and never useNOT INon a column that can be NULL - useNOT EXISTS. - Inner joins drop unmatched rows and repeat matched ones;
LEFT JOINkeeps every left row, and conditions on the right table belong inON. COUNT(col)rather thanCOUNT(*)after aLEFT JOIN;COALESCEsums that may cover no rows.- Two one-to-many children multiply each other - aggregate before joining.
- No
ORDER BY, no order - and SQL bugs rarely raise errors, so check results against a case you can work out by hand.