SQL Fundamentals · Querying Basics

SELECT, WHERE & JOIN

Filtering rows and combining data across tables.

  • 14 min read
  • 28 practice questions
  • 3 graded problems
On this page
  1. Description
  2. The pattern: read a query in the order it runs
  3. SELECT and WHERE
  4. JOINs
  5. Second flavor: GROUP BY and the fan-out trap
  6. Where it breaks
  7. Tradeoffs
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

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:

  1. The order a query really runs in - the one idea that explains most surprises.
  2. SELECT and WHERE, and what NULL (a missing value) does to a filter.
  3. Joins: inner, left, and the "has a match" / "has no match" questions.
  4. GROUP BY, and how a join can silently double your totals.
  5. The common mistakes, and which phrasing to prefer.

Every result quoted below was run in PostgreSQL, on one small dataset used throughout:

customers

idnamecity
1AshaAustin
2BenNULL
3ChenDublin
4DanaAustin

orders (order 14 is a guest checkout, with no customer)

idcustomer_idtotalstatuscreated_at
101250paid2026-01-05 10:00
111100refunded2026-01-31 18:30
123400paid2026-01-20 09:15
13350shipped2026-02-01 00:00
14NULL75paid2026-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:

  1. FROM / JOIN - build the combined rows
  2. WHERE - keep the rows whose condition is true
  3. GROUP BY - collapse rows into groups
  4. HAVING - keep the groups whose condition is true
  5. SELECT - compute the output columns and their aliases
  6. DISTINCT - remove duplicate output rows
  7. ORDER BY - sort
  8. LIMIT / OFFSET - cut

3. Output

2. Groups

1. Rows

FROM / JOIN
build rows

WHERE
keep true rows

GROUP BY
make groups

HAVING
keep true groups

SELECT
compute columns

DISTINCT

ORDER BY

LIMIT

3. Output

2. Groups

1. Rows

FROM / JOIN
build rows

WHERE
keep true rows

GROUP BY
make groups

HAVING
keep true groups

SELECT
compute columns

DISTINCT

ORDER BY

LIMIT

Rules you can derive instead of memorizing:

  • WHERE can't use a SELECT alias - it doesn't exist yet (ERROR: column "gross" does not exist). ORDER BY can, because it runs after SELECT.
  • WHERE can't use aggregates (ERROR: aggregate functions are not allowed in WHERE) - there are no groups yet. Conditions on groups go in HAVING.
  • Attempt 1: the JOIN step already removed the customers without orders, so WHERE o.id IS NULL has 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)
  • AND binds tighter than OR. status = 'paid' OR status = 'shipped' AND total > 100 means paid OR (shipped AND total > 100) - every paid order, even the 75 one: 10, 12 and 14. Use parentheses, or IN.
  • BETWEEN a AND b includes 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 (ILIKE isn't): name LIKE '_a%' matches only Dana.
  • No ORDER BY, no order. Rows come back in whatever order the plan produces, so LIMIT 10 without ORDER BY returns some 10 rows - which can change between runs.
  • DISTINCT applies 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.

ExpressionResult on the sample data
city = NULLunknown for every row - no rows
city IS NULLBen
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 same customers and orders through an inner join, a left join, and a left join filtered to its NULL-filled rows (the anti-join). Guest order 14 has no customer, so it appears in none of them.

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_id lists 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. Explicit JOIN ... ON makes a missing condition much harder to miss.
  • A join on the wrong column still runs. ON o.id = c.id pairs customer 2 with order 2, whoever placed it: the output has the right shape and the wrong content.
  • EXISTS is a semi-join: it returns each customer at most once however many orders match, where JOIN returns one row per match. SELECT DISTINCT hides the repeats, but it does extra work and would also merge rows that really are duplicates.

Which join do I need?

You wantWriteWatch out for
Rows that have a match, with the match's columnsJOIN (inner)One output row per match - a customer with 3 orders appears 3 times
Every row on one side, matched or notLEFT JOINConditions on the right table go in ON, not WHERE
Rows that have at least one match, once eachWHERE EXISTS (...)Nothing - this is the semi-join; no duplicates to remove
Rows with no matchWHERE NOT EXISTS (...) or LEFT JOIN ... WHERE right.pk IS NULLNOT IN returns nothing if the subquery yields a NULL
Both sides, matched or notFULL JOINRare in practice - usually reconciling two sources
Every combinationCROSS JOINRows 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_id fails: which of a customer's statuses would it show? (PostgreSQL does allow c.name when grouping by c.id, because id is the primary key and so determines the name. MySQL with ONLY_FULL_GROUP_BY turned off returns an arbitrary value instead of an error.)
  • COUNT(*) counts rows; COUNT(col) counts non-NULL values. After the LEFT JOIN, Ben has one row with a NULL order, so COUNT(*) says 1 while COUNT(o.id) correctly says 0. On customers: COUNT(*) is 4, COUNT(city) is 3, COUNT(DISTINCT city) is 2.
  • Aggregates over no rows: SUM, AVG, MIN and MAX of nothing are NULL, not 0 (COUNT is 0) - hence the COALESCE.
  • WHERE filters rows before grouping, HAVING filters groups after: WHERE o.status = 'paid' ... GROUP BY c.id HAVING SUM(o.total) > 300 returns 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 IN with a NULL anywhere in the list - no rows, silently.
  • A WHERE condition on the optional side of a LEFT JOIN - the "including those with none" rows vanish.
  • COUNT(*) after a LEFT JOIN - customers with nothing count as 1.
  • Joining two one-to-many children - sums and counts multiply.
  • OR without parentheses; BETWEEN on timestamps.
  • = NULL matching nothing, and <> value quietly dropping NULL rows.
  • A join on the wrong column - plausible pairs, wrong data.

Tradeoffs

NeedOptionsPrefer
Rows with no matchNOT EXISTS, LEFT JOIN ... IS NULL, NOT INNOT EXISTS (NULL-safe) or LEFT JOIN ... IS NULL; NOT IN only when the column can't be NULL
Rows with at least one matchEXISTS, IN, JOIN + DISTINCTEXISTS or IN - nothing to de-duplicate
Totals from two child tablesJoin then aggregate, or aggregate then joinAggregate first
A filterWHERE on rows, HAVING on groupsWHERE 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 use NOT IN on a column that can be NULL - use NOT EXISTS.
  • Inner joins drop unmatched rows and repeat matched ones; LEFT JOIN keeps every left row, and conditions on the right table belong in ON.
  • COUNT(col) rather than COUNT(*) after a LEFT JOIN; COALESCE sums 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.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

The order a query runs in

FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT

  • WHERE can't see a SELECT alias or an aggregate (neither exists yet). ORDER BY can use the alias.
  • Conditions on groups go in HAVING; conditions on rows go in WHERE (it also shrinks the input to grouping).
  • This is what a query means. The planner may run it in any order that gives the same result.

Pick the join

You wantWriteWatch out for
Rows with a match, plus its columnsJOINOne row per match - parents repeat
Every left row, matched or notLEFT JOINRight-table conditions go in ON, not WHERE
Rows with at least one match, once eachWHERE EXISTS (...)- (semi-join, nothing to de-duplicate)
Rows with no matchWHERE NOT EXISTS (...) or LEFT JOIN ... WHERE right.pk IS NULLNOT IN returns nothing if the subquery holds a NULL
Both sides, matched or notFULL JOINUsually only for reconciling two sources
Every combinationCROSS JOIN1,000 × 1,000 = a million rows

NULL in one table

ExpressionMeans
col = NULLUnknown - matches nothing. Use col IS NULL
col <> 'x'Drops the NULL rows too
col IS DISTINCT FROM 'x'Keeps the NULL rows
id NOT IN (1, NULL)Never true - no rows
id IN (1, NULL)Still fine
COUNT(*) / COUNT(col)Rows / non-NULL values
SUM of no rowsNULL (COUNT is 0) - wrap in COALESCE(..., 0)

GROUP BY, DISTINCT and ORDER BY treat all NULLs as one value. PostgreSQL sorts them last ascending and first descending; MySQL and SQL Server put them first ascending - write NULLS LAST when it matters.

Snippets worth knowing by heart

-- never ordered (NULL-safe)
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- every customer, with their paid orders only
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';

-- per-customer totals, zero for customers with nothing
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;

-- a month of timestamps: half-open, never BETWEEN
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'

The traps

  • NOT IN against a column that can be NULL returns no rows, silently.
  • A WHERE on the right table of a LEFT JOIN removes exactly the rows the left join added.
  • COUNT(*) after a LEFT JOIN counts a customer with nothing as 1. Count the right table's key.
  • Two one-to-many children joined to one parent multiply each other (fan-out). Aggregate each child first, then join.
  • AND binds tighter than OR - add parentheses or use IN.
  • BETWEEN on timestamps includes the end point and misses the last day's later hours.
  • LIMIT without ORDER BY returns some rows, which can change between runs.
  • DISTINCT to "fix duplicates" usually hides a missing join condition.
  • A join on the wrong column runs fine and pairs the wrong rows.

Staff-level points, one line each

  • Three join algorithms: nested loop (small outer side + an index on the inner key), hash join (big unsorted inputs, an equality), merge join (both sides already sorted).
  • Plans depend on row estimates. EXPLAIN ANALYZE puts estimated and actual rows side by side; an estimate of 3 that turns out to be 48,000 is how a nested loop ends up doing 48,000 probes.
  • The planner assumes columns are independent. Correlated filters (city and state) get misestimated; CREATE STATISTICS ... (mcv) or (dependencies) fixes it.
  • NOT EXISTS becomes an anti-join. NOT IN can't (NULLs change its meaning), so it runs a hashed subplan - and when the hash no longer fits in work_mem it degrades to a per-row scan: quadratic.
  • EXISTS and IN both become semi-joins and usually get the same plan.
  • A hash join that outgrows work_mem × hash_mem_multiplier spills to disk in batches (Batches: 8 in EXPLAIN ANALYZE). work_mem is per operation, per connection.
  • A CHECK (total > 0) accepts NULL (it only fails on false); UNIQUE allows repeated NULLs unless it is NULLS NOT DISTINCT (PostgreSQL 15+).
  • More than 8 explicitly joined tables (join_collapse_limit) are planned in pieces, and from 12 (geqo_threshold) by a genetic search.
  • SQL Server caches one plan per query for all callers, so a plan built for a small customer can be reused for a huge one (parameter sniffing).

Say it in an interview

  • "I'd use NOT EXISTS - it's NULL-safe and the planner turns it into an anti-join."
  • "That total is inflated by fan-out; I'll aggregate orders and tickets separately, then join."
  • "If it was fast yesterday, I'd compare estimated and actual rows in EXPLAIN ANALYZE before touching indexes."
  • "An index only helps this join because the outer side is small; for all rows a hash join still wins."

Practice

This skill comes with 28 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice SELECT, WHERE & JOIN free

Paid Orders per Customer

MediumProSQL problem

Customer success wants to see, for every customer, how many of their orders have been paid - so they can reach out to people whose orders keep getting cancelled, or who never ordered at all.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice SELECT, WHERE & JOIN free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime