📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 62 · Modern signals

SQL — the queries they actually ask

📖 Walk me through it — plain English

SQL is the language you use to ask a database questions. A table is just a grid of rows and columns, like a spreadsheet tab — here we have an orders table where each row is one order with a customer_id (who placed it), an id (the order's own number), and an amount (dollars). In a 15-minute SQL interview round, the examiner usually hands you tables like this and asks a "shape" question. The hardest-sounding one is top-N per group: "give me each customer's 3 biggest orders." This lesson's headline trick solves it cleanly with a window function.

Here is the jargon, defined plainly. A window function looks at a row alongside a group of related rows (its "window") and computes something across that group, but — unlike GROUP BY — it does not collapse them into one summary row; every original row survives. ROW_NUMBER() just stamps a counter 1, 2, 3… onto rows. The OVER (...) clause says how to stamp them: PARTITION BY customer_id means "restart the counter at 1 for each new customer," and ORDER BY amount DESC means "within that customer, number them from biggest amount down." So the biggest order gets rn = 1, the next gets rn = 2, and so on. Then the outer query keeps only WHERE rn <= 3 — the top three per customer.

Everyday analogy: imagine each customer hands you a stack of their receipts. You sort each person's stack from most-expensive on top to cheapest on the bottom, then write "1, 2, 3, 4…" on the corner of their receipts — starting fresh at 1 for the next person. Finally you collect every receipt numbered 3 or lower. That sticky-note number is ROW_NUMBER(); "sort each person's pile then renumber from 1" is the PARTITION BY ... ORDER BY; "collect ≤ 3" is the WHERE rn <= 3.

Let's trace it on a tiny orders table. Suppose customer A has orders worth 90, 40, 70, 30, and customer B has orders worth 50, 60. We process one customer's window at a time. The number stamped on each cell below is the rn the engine assigns; green cells are the rows that survive the final rn <= 3 filter, faded/struck-through cells get dropped.

Step 1 · Take customer A's window and sort their amounts biggest-first: 90, 70, 40, 30.
90
70
40
30
Step 2 · ROW_NUMBER() stamps 1,2,3,4 onto A's sorted rows. The number shown IS the rn. Counter restarts at 1 because of PARTITION BY.
1
2
3
4
Step 3 · Apply WHERE rn ≤ 3 to A: keep rn 1,2,3 (amounts 90,70,40); drop rn 4 (amount 30).
1
2
3
4
Step 4 · Customer B is a separate window, so the counter starts over at 1. B's amounts 60,50 get rn 1,2 — both ≤ 3, so both survive.
1
2

Why we wrap it in an outer SELECT ... FROM ( ... ) t: SQL computes rn while building each row, but you cannot filter on that brand-new column in the same WHERE — it does not exist yet at filter time. So you compute rn in an inner query (a subquery, here aliased t), then filter on it from the outside. That two-layer shape is the whole pattern; swap 3 for any N.

One thing the lesson flags that bites people: in a plain GROUP BY query, every column you SELECT must either be in the GROUP BY list or wrapped in an aggregate like SUM() — otherwise strict databases reject it (and loose ones quietly return a random value). Window functions sidestep that, which is exactly why they shine for "keep the detail rows but also rank them" questions. The related sidebars — index the columns you filter or join on, batch WHERE id IN (...) instead of looping one query per item (the "N+1" trap), and read EXPLAIN to spot a full-table "seq scan" where an index should be — are the follow-up performance questions that often come right after you nail the query itself.

Many backend interviews include a 15-min SQL round. The shapes that come up: top-N per group, running totals, joins with missing rows, "did this user do X within 7 days of Y."

The on-ramp: what a query even is

If you have never written SQL, start here. A database is a collection of tables. A table is a grid: each horizontal row (also called a "record") is one thing — one order, one user — and each vertical column (also called a "field") is one attribute of that thing, with a fixed type (number, text, date). A query is a single sentence that describes the rows you want back; the database figures out how to fetch them. You almost never tell it "loop over the file" — you describe the result and let the engine plan.

Concretely, picture a two-table world. users(id, name) and orders(id, customer_id, amount). The customer_id in orders points back at a users.id — that is how the two tables are linked. Almost every interview question is some combination of: pick columns, filter rows, link tables, group-and-count, sort, and limit.

The six clauses, defined

A SELECT statement is built from a small set of clauses. Each does exactly one job:

  • SELECTwhich columns come back (and computed/aggregated values). SELECT * means "every column."
  • FROMwhich table(s) the rows come from.
  • WHEREfilter individual rows before any grouping. Keeps rows matching a condition.
  • GROUP BYcollapse rows that share a value into one summary row per group, so aggregates can run per group.
  • HAVINGfilter the groups after grouping/aggregation (this is WHERE's post-aggregation cousin).
  • ORDER BYsort the final rows (ASC ascending, the default; DESC descending).
  • LIMIT nkeep only the first n rows after sorting (some engines spell it FETCH FIRST n ROWS or TOP n).

A tiny end-to-end example reading them all at once. Given orders with rows (1, A, 90), (2, A, 40), (3, B, 50), (4, B, 60), (5, B, 20):

SELECT   customer_id, SUM(amount) AS total
FROM     orders
WHERE    amount > 25
GROUP BY customer_id
HAVING   SUM(amount) > 100
ORDER BY total DESC
LIMIT    10;
-- WHERE drops the (5,B,20) row first.
-- Groups: A -> 90+40=130, B -> 50+60=110.
-- HAVING keeps both (both > 100). ORDER BY total DESC.
-- Result:  A | 130
--          B | 110

The logical order of execution

You write the clauses in one order, but the database runs them in another. Knowing the real order explains almost every confusing error and is a favourite interview probe. The logical pipeline is:

  • 1. FROM / JOIN — assemble the raw row set from the tables.
  • 2. WHERE — drop rows that fail the row-level condition.
  • 3. GROUP BY — bucket the surviving rows into groups.
  • 4. HAVING — drop whole groups that fail the group-level condition.
  • 5. SELECT — compute the output columns (this is when aliases and window functions are created).
  • 6. ORDER BY — sort the result.
  • 7. LIMIT — cut to the first n.

Two consequences fall straight out of this list. First, WHERE runs before grouping, so it cannot reference an aggregate like SUM() — that's HAVING's job (step 4, after grouping). Second, an alias you define in SELECT (step 5) is not visible in WHERE (step 2) — it does not exist yet — which is the exact reason the top-N pattern wraps rn in a subquery. ORDER BY (step 6) can use a SELECT alias, because it runs after.

JOINs, with a tiny example of each

A JOIN stitches two tables together row-by-row using an ON condition (usually "this table's foreign key equals that table's id"). The four types differ only in which unmatched rows they keep. Use these two tiny tables for every example below — users(id, name) = (1,Ana), (2,Bo), (3,Cy) and orders(id, customer_id) = (10, 1), (11, 1), (12, 2). Note user 3 (Cy) has no orders, and every order belongs to a real user.

INNER JOIN

Only rows that match on both sides. Unmatched rows from either side are dropped.

SELECT u.name, o.id
FROM users u
INNER JOIN orders o
  ON o.customer_id = u.id;
-- Ana,10  Ana,11  Bo,12
-- Cy is dropped (no orders).
LEFT JOIN

Keep all left rows; fill the right side with NULL when there is no match.

SELECT u.name, o.id
FROM users u
LEFT JOIN orders o
  ON o.customer_id = u.id;
-- Ana,10  Ana,11  Bo,12
-- Cy,NULL  <- kept, right side NULL
RIGHT JOIN

Mirror image: keep all right rows, NULL on the left when no match. A RIGHT JOIN B = B LEFT JOIN A; most people just use LEFT.

SELECT u.name, o.id
FROM users u
RIGHT JOIN orders o
  ON o.customer_id = u.id;
-- Ana,10  Ana,11  Bo,12
-- (every order had a user, so no NULLs here)
FULL OUTER JOIN

Keep everything from both sides; NULL-fill whichever side is missing. The union of LEFT and RIGHT.

SELECT u.name, o.id
FROM users u
FULL OUTER JOIN orders o
  ON o.customer_id = u.id;
-- Ana,10  Ana,11  Bo,12  Cy,NULL
-- (orphan orders would show NULL,id)
JOIN cheat card
  • INNER JOIN — only rows present on both sides.
  • LEFT JOIN — keep all left rows; right side is NULL when missing. "Users with no orders" = LEFT JOIN + WHERE right.id IS NULL.
  • CROSS JOIN — cartesian product. Rarely intended; usually a sign of a missing ON clause.
  • Self join — same table twice with aliases. Pattern for "find pairs in the same table" (employee/manager, friend-of-friend).

Aggregates, subqueries, CTEs, and indexes

A few more terms you'll hear in every round, defined inline:

  • Aggregate function — a function that takes many rows and returns one value: COUNT(*) (how many rows), SUM(x), AVG(x), MIN(x), MAX(x). With GROUP BY they run once per group.
  • Subquery — a SELECT nested inside another query, used either as a value (WHERE amount > (SELECT AVG(amount) FROM orders)), a list (WHERE id IN (SELECT ...)), or a derived table in FROM (the (...) t in the top-N pattern).
  • CTE / WITH — a "common table expression": a named, temporary result you define up front with WITH name AS (SELECT ...), then reference by name below. Same power as a subquery but far more readable, and you can chain several. Example: WITH ranked AS (SELECT ..., ROW_NUMBER() OVER(...) rn FROM orders) SELECT * FROM ranked WHERE rn <= 3; — the exact top-N pattern, rewritten readably.
  • Window function (briefly) — like an aggregate but it does not collapse rows; it computes across a "window" of related rows while every row survives. ROW_NUMBER(), RANK(), SUM() OVER(...), LAG()/LEAD() are the common ones. This lesson's headline trick is a window function.
  • Index — a sorted lookup structure (think the index at the back of a book) the database keeps on one or more columns so it can find matching rows without scanning the whole table. Speeds up reads on indexed columns; slightly slows writes.
  • Primary key — the column(s) that uniquely identify a row (e.g. users.id); automatically unique and indexed. Foreign key — a column that references another table's primary key (e.g. orders.customer_idusers.id), enforcing that the pointed-to row exists.
  • NULL — the marker for "unknown / no value." It is not zero and not an empty string. Crucially, NULL = NULL is not true (it's "unknown"), so you must test with IS NULL / IS NOT NULL, never = NULL.
Window functions — the modern SQL move
-- top 3 orders per customer by amount
SELECT *
FROM (
  SELECT customer_id, id, amount,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY amount DESC) AS rn
  FROM orders
) t
WHERE rn <= 3;

Also useful: SUM() OVER (ORDER BY ts) for running totals, LAG() for "compare to previous row."

The classic interview queries

Three questions show up so often they're almost a handshake. Memorise the shape of each — and be ready to explain why.

1 · Nth-highest salary

"Find the 2nd-highest salary." The robust answer uses DENSE_RANK() (a window function that gives tied values the same rank and does not skip numbers), so duplicates don't break it:

SELECT salary
FROM (
  SELECT salary,
         DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
) t
WHERE rnk = 2;       -- swap 2 for any N
-- The old-school trick: ORDER BY salary DESC LIMIT 1 OFFSET 1
-- (skip the top 1, take the next) — but ties can fool it.

2 · Find duplicates

"Which emails appear more than once?" Group by the column and keep groups whose count exceeds 1 — a textbook HAVING job (the filter is on an aggregate, so it cannot live in WHERE):

SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- e.g.  a@x.com | 3
--       b@x.com | 2

3 · Users with no orders

"Who never ordered?" The idiomatic answer is an anti-join: LEFT JOIN orders, then keep only the rows where the right side came back NULL (meaning nothing matched). o.id IS NULL is the "no match" signal:

SELECT u.*
FROM users u
LEFT JOIN orders o ON o.customer_id = u.id
WHERE o.id IS NULL;
-- returns Cy (had no orders).
-- NOT IN (SELECT customer_id FROM orders) also works,
-- but breaks if customer_id contains a NULL — prefer this.

Pitfalls they probe for

Three traps account for most "gotcha" follow-ups:

  • WHERE vs HAVINGWHERE filters individual rows before grouping and cannot see aggregates; HAVING filters groups after aggregation and is the only place an aggregate condition (COUNT(*) > 1) can live. Putting SUM(x) > 100 in WHERE is an error.
  • NULL comparisons — anything compared to NULL with =, !=, <, etc. yields "unknown," which behaves like false in a filter, so the row silently vanishes. Use IS NULL / IS NOT NULL. This is also why NOT IN (subquery) returns zero rows if the subquery contains a single NULL.
  • N+1 queries — in app code, fetching a list (1 query) then looping to fetch each item's details (N more queries) means N+1 round-trips. Replace the loop with one batched query (WHERE id IN (...) or a JOIN). It's the single biggest perf killer in ORM-heavy code, and an index won't fix it — the cost is the number of round-trips, not the per-query speed.
GROUP BY pitfalls

Every non-aggregated column in SELECT must be in GROUP BY (or be an aggregate). Postgres/MySQL strict mode enforce this — others silently pick a random value.

Indexes 101

Index columns you filter on (WHERE) or join on. Composite index (a,b) helps WHERE a = ? AND b = ? and WHERE a = ?, but NOT WHERE b = ? alone (leftmost prefix rule).

N+1 query

Loop fetches 1 query per item. Fix: batch with WHERE id IN (...) or a JOIN. Single biggest perf killer in app code.

EXPLAIN

Read the plan. Seq scan on a big table with a WHERE = missing index. Nested loop with high row counts = consider a hash join / different index.

Go deeper (optional): for a free, hands-on sequence on joins, aggregation, and window functions, "Mode's SQL Tutorial" and PostgreSQL's own documentation ("Tutorial" → "Advanced Features") are the two most-recommended references. You do not need them to pass this lesson — everything above is self-contained — but they're useful if you want more reps.

Stop reading, start querying — this runs a real Postgres engine compiled to WebAssembly, right here in the page:

→ Going deeper: SELECT statements assume tables were modeled correctly. See Data modeling.