The Database Interview

Lesson 1 of 6

The SQL Interview: Query Patterns

"In theory, theory and practice are the same. In practice, they are not." — attributed to Yogi Berra (and every engineer who has bombed a live SQL screen)

You know SQL. You have written SELECT, joined tables, grouped and aggregated. And yet the live SQL screen — a shared editor, a ticking clock, an interviewer watching your cursor — has a way of erasing all of that. The reason is rarely knowledge. It is recognition. Interview SQL is not a fresh puzzle each time; it is a small deck of recurring shapes, dealt over and over with the table names swapped. Learn the shapes and the panic evaporates, because you stop inventing a query and start retrieving one.

This chapter is that deck. Eight patterns cover the overwhelming majority of what you will be asked in a screen: the Nth-highest value, top-N per group, finding duplicates, rows with no match, running totals, pivots, comparing a row to its neighbour, and grouped aggregates with a filter. For each you get the problem in plain words, the approach, and a runnable template you can lift and re-point at whatever tables the interviewer gives you.

Treat every block below as a keyboard, not a page. Run it, then break it — change the column, flip the sort, raise the threshold — until the shape is in your fingers, not just your notes. That muscle memory is exactly what the screen is testing.

The big question

What is the smallest set of reusable query templates that lets you walk into almost any SQL interview and recognize the problem instead of reinventing the solution?

Pattern 1 — The Nth-highest value

Problem. "Find the second-highest order amount." This is the single most common opener in the deck, usually phrased as second or third highest salary, price, or score.

Approach. Two idioms, and you should know both because interviewers often ask for the other one. The first is ORDER BY ... DESC with LIMIT 1 OFFSET (N-1) — skip the top N−1 rows, take the next. Use DISTINCT so that ties don't burn a slot.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

That returns 45000 — the amounts descend 250000, 45000, 3000, 3000, 500, 500, and offsetting past the distinct maximum lands on the runner-up. Change OFFSET 1 to OFFSET 2 and you get the third-highest.

The second idiom uses a window function, and it is the one to reach for when the interviewer adds "…and do it without LIMIT" or "…now generalize to the Nth." DENSE_RANK() numbers rows by value, giving tied values the same rank and not leaving gaps — exactly what "Nth distinct highest" means.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Know the difference RANK() leaves gaps after ties (1, 1, 3), DENSE_RANK() does not (1, 1, 2), and ROW_NUMBER() never ties (1, 2, 3). For "Nth highest distinct value" you want DENSE_RANK. For "the Nth row" you want ROW_NUMBER. Picking the wrong one is the classic subtle miss — and interviewers ask precisely to see whether you know why.

Portability note The window version is the portable one — it works unchanged in SQLite, PostgreSQL, MySQL 8+, and SQL Server. The LIMIT ... OFFSET spelling is SQLite/Postgres/MySQL; SQL Server writes OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY or uses TOP. When in doubt in a screen, use DENSE_RANK and you are safe everywhere.

Pattern 2 — Top-N per group

Problem. "For each customer, return their single largest order." The tell is the phrase per (per customer, per department, per category) combined with top / most recent / best. Plain GROUP BY collapses each group to one aggregate row and loses the other columns — you cannot ask "which order?" that way. You need to rank within each group and keep the winners.

Approach. ROW_NUMBER() OVER (PARTITION BY <group> ORDER BY <metric> DESC). PARTITION BY restarts the numbering for every group; filter to = 1 for the top one, <= 3 for the top three.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Each customer appears once, showing their biggest order — Ada's Book (3000) beats her Pen (500), while customers with a single order simply return it. Swap ROW_NUMBER for RANK if the interviewer wants all orders tied for the top rather than an arbitrary one.

Analogy PARTITION BY is GROUP BY that refuses to throw anything away. GROUP BY is a blender — many rows in, one summary row out. A window is a highlighter — the same rows come out, now annotated with a rank you can filter on. Whenever the question needs a per-group calculation and the original rows, that is your signal to reach for a window instead of a group.

Pattern 3 — Find duplicates

Problem. "Which products were ordered more than once?" or, in the classic form, "find duplicate emails in the users table." A data-quality staple, and the gateway to a follow-up about deleting the duplicates.

Approach. GROUP BY the column(s) that define a duplicate, then HAVING COUNT(*) > 1. WHERE filters rows before grouping; HAVING filters groups after — you need HAVING because the count only exists once the group is formed.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Book and Pen each appear twice; Laptop and Chair are filtered out. To dedupe on a combination — say a customer ordering the same product twice — just group by both columns: GROUP BY customer_id, product HAVING COUNT(*) > 1.

The follow-up "Now delete the duplicates, keeping one of each." The pattern is ROW_NUMBER() again — number rows within each duplicate group and delete everything with rn > 1: DELETE FROM t WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) rn FROM t) WHERE rn > 1). Recognizing that "find duplicates" and "delete duplicates" are the same shape wearing two hats scores real points.

Pattern 4 — Rows with no match (anti-join)

Problem. "List customers who have never placed an order." The universal shape is "X with no matching Y" — products never sold, users with no logins, employees with no manager. It is an anti-join: you want the rows in one table that have no partner in another.

Approach. Two idioms again. The first is LEFT JOIN the other table, then keep only the rows where the join failed — WHERE <right>.something IS NULL. Our seed happens to give every customer an order, so the block first adds one order-less customer, then finds them. (The database resets on every run, so this is repeatable.)

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Ivy is the only customer with no matching order, so the failed join leaves her orders columns NULL, and the WHERE isolates her. The second idiom reads more like the English sentence — NOT EXISTS with a correlated subquery — and is often the clearer choice:

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Avoid the NULL trap A third spelling — WHERE c.id NOT IN (SELECT customer_id FROM orders) — looks equivalent and usually works, but if any customer_id in the subquery is NULL, the whole NOT IN returns no rows at all, silently. That is three-valued logic biting you. Prefer NOT EXISTS (or the LEFT JOIN … IS NULL) — both are NULL-safe. Interviewers love this one because it separates people who memorized syntax from people who understand it.

Pattern 5 — Running total

Problem. "Show each order with a cumulative sum of amounts over time." Running totals, running counts, and cumulative averages all share this shape — a total that grows as you walk down the rows.

Approach. An aggregate used as a window: SUM(amount) OVER (ORDER BY id). Adding ORDER BY to a window turns the plain aggregate into a cumulative one — each row sees itself plus everything ordered before it. Without ORDER BY, SUM(...) OVER () gives the grand total on every row instead (itself a handy trick for "% of total").

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

The running_total column climbs 3000, 3500, 253500, … as each order is added in. Partition it — SUM(amount) OVER (PARTITION BY customer_id ORDER BY id) — and the total resets per customer, the pattern behind "cumulative spend per user."

In production Running totals power the charts every dashboard leans on — cumulative revenue, growing user counts, rolling 7-day windows (via ROWS BETWEEN 6 PRECEDING AND CURRENT ROW). Before window functions landed in standard SQL, people computed these with self-joins that were quadratic and painful. Knowing the window form marks you as someone who has written analytics SQL for real, not just CRUD.

Pattern 6 — Pivot / conditional aggregation

Problem. "For each customer, show total spend on Books in one column and total spend on Pens in another." Turning rows into columns — a pivot — is how you build report-shaped output: months across the top, categories as columns, one row per entity.

Approach. SUM(CASE WHEN <condition> THEN <value> ELSE 0 END), one such expression per output column, wrapped in a GROUP BY. The CASE picks which rows contribute to which column; the SUM collapses them.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Each customer becomes one row with a column per product family. The same trick counts instead of sums — COUNT(CASE WHEN status='paid' THEN 1 END) — which is how you produce "paid vs unpaid orders side by side."

Why not a real PIVOT? SQL Server and Oracle have a PIVOT keyword, but it is dialect-specific and rigid. SUM(CASE WHEN …) is standard, runs in every engine including SQLite, and is what most interviewers expect to see. It is the portable pivot — commit it to memory and you will never be stuck.

Pattern 7 — Compare a row to its neighbour

Problem. "For each order, show the previous order's amount and the change from it." Month-over-month growth, day-over-day deltas, "time since last event" — all need a row to see its neighbour.

Approach. LAG(col) OVER (ORDER BY ...) fetches the value from the previous row; LEAD fetches the next. Give LAG a default (LAG(amount, 1, 0)) so the first row — which has no predecessor — shows 0 instead of NULL.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Each row now carries the previous order's amount and the delta — the exact engine behind "growth vs last period." Partition by a group (PARTITION BY customer_id ORDER BY id) and each customer's comparison stays inside their own history rather than bleeding across customers.

Think about it "Find months where revenue grew over the prior month" and "find users whose session gap exceeded 30 minutes" sound like different problems. They are the same pattern: LAG to bring the neighbour onto the row, then a WHERE/HAVING on the difference. How many interview questions do you think collapse into "put the neighbouring value on this row, then compare"?

Pattern 8 — Grouped aggregates with a filter

Problem. "List customers whose total spend exceeds 3,000." The bread-and-butter shape: aggregate per group, then keep only the groups that clear a bar. It usually rides on top of a join, since the thing you group by and the thing you sum often live in different tables.

Approach. JOIN, then GROUP BY the entity, SUM/COUNT/AVG the metric, and HAVING to filter on that aggregate. Remember the logical order: FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE filters rows before grouping; HAVING filters groups after.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Lin (250000), Zoe (45000), and Ada (3500) clear the bar; Sam (3000) and Tom (500) fall below it and drop out. Note that we GROUP BY c.id (the key) as well as the name — grouping by a name alone risks merging two people who share it.

Inner join hides the zeroes This query can only ever return customers who have orders — an INNER JOIN silently drops order-less customers. If the question were "including those who spent nothing," you would switch to LEFT JOIN and wrap the sum in COALESCE(SUM(o.amount), 0). Reading which join the question implies is half of getting it right.

Common Mistakes

  • Using WHERE where you need HAVING (or vice-versa). You cannot filter on COUNT(*) in WHERE — the aggregate doesn't exist yet. Filter individual rows in WHERE, filter aggregated groups in HAVING.
  • GROUP BY and selecting a non-aggregated column. Every column in the SELECT must be either grouped-by or wrapped in an aggregate. SQLite will let you select a stray column and return an arbitrary value — a portability footgun that errors in Postgres and strict MySQL. Don't rely on it.
  • NOT IN with a NULL-able subquery. As in Pattern 4 — one NULL and NOT IN returns nothing. Reach for NOT EXISTS.
  • ROW_NUMBER when you meant DENSE_RANK (or the reverse). "Top 3 distinct salaries" and "any 3 top-paid employees" are different questions; the wrong function quietly gives the wrong answer.
  • Forgetting ORDER BY inside a window. SUM(x) OVER () is a grand total; SUM(x) OVER (ORDER BY d) is a running total. The clause changes the meaning entirely.
  • Ranking without PARTITION BY when the question said "per". You get one global ranking instead of one per group — a top-1-overall answer to a top-1-per-customer question.

Predict the output Before running it, decide what this returns and how many rows: SELECT city, COUNT() FROM customers GROUP BY city HAVING COUNT() > 1;. Which cities qualify, and which single-customer city drops out? Then run it in any block above to check. Training yourself to predict before you run is exactly the habit a live screen rewards — the interviewer is watching whether you know what your query will do.

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
Write a query for the second-highest order amount.
Model answer — SELECT DISTINCT amount FROM orders ORDER BY amount DESC LIMIT 1 OFFSET 1; or the DENSE_RANK() ... WHERE rnk = 2 version. Say which you would use and why: DENSE_RANK generalizes to the Nth and is portable across every engine.
Intermediate
For each customer, return only their most expensive order (product and amount).
Model answer — ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) in a CTE, then filter WHERE rn = 1. The key insight to state aloud: plain GROUP BY can give you the max amount but not which product it belongs to — that is why you need a window, not an aggregate.
Advanced
Find customers who have never placed an order, and explain why you avoided NOT IN.
Model answer — LEFT JOIN orders ... WHERE orders.id IS NULL, or NOT EXISTS. The explanation is the point: NOT IN against a subquery containing a NULL returns the empty set due to three-valued logic, whereas NOT EXISTS and the anti-join are NULL-safe. Bonus if you mention that NOT EXISTS can also short-circuit and use an index efficiently.
Company lens — how the same topic gets asked
micro1the AI-led SQL screen leans hard on exactly these templates — expect a live editor and a "find the second-highest / top per group / duplicates" prompt, graded as much on speaking your approach as on the final query.
Googlepushes past the template — after you write the window query they will ask which function and why (RANK vs DENSE_RANK vs ROW_NUMBER), and how it behaves on ties and NULLs.
Metaruns a dedicated, timed SQL round for product-analytics and data-engineering roles built almost entirely on these eight shapes — often chained (top-N per group then a running total) — where speed and fluency are the bar.
Amazonfavours the analytics patterns — running totals, pivots with SUM(CASE WHEN), and month-over-month LAG — often framed around a business metric, with a "Dive Deep" follow-up on what your query costs at scale.

Hands-on Chain two patterns, the way a real screen escalates. Start from the per-customer top order below, then extend it: (1) run it as-is; (2) change rn = 1 to rn <= 2 to get each customer's top two orders; (3) as a stretch, wrap it in an outer query that adds a running total of amount across the result with a window SUM. Narrate each step out loud as if an interviewer were listening — that is the skill being graded.

SQL — Try it Yourself
Loading editor…
Result

Click Run to execute the query.

Summary

  • Interview SQL is a small deck of recurring shapes; recognition beats invention. Learn the eight and most screens become retrieval, not puzzle-solving.
  • Nth-highest: LIMIT/OFFSET for quick, DENSE_RANK for portable and generalizable. Top-N per group: ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) then filter the rank.
  • Duplicates: GROUP BY … HAVING COUNT(*) > 1. No match / anti-join: LEFT JOIN … IS NULL or NOT EXISTS — never NOT IN against NULL-able columns.
  • Running total: aggregate as a window with ORDER BY. Pivot: SUM(CASE WHEN … ). Neighbour comparison: LAG/LEAD. Grouped filter: GROUP BY … HAVING on the aggregate.
  • Know why each choice matters — RANK vs DENSE_RANK vs ROW_NUMBER, WHERE vs HAVING, the NOT IN NULL trap — because the follow-up question always probes the why.
  • Practice by predicting output before running; that is the exact habit the live screen rewards.

Next, we widen the lens from writing the query to reasoning about how the engine runs it — reading a query plan, spotting the full-table scan, and answering the "now make it fast" follow-up that turns a good SQL answer into a great one.

Editorial reflection The engineers who stay calm in a SQL screen are not the ones who memorized the most syntax — they are the ones who learned to see the shape underneath the words. "Second-highest salary," "runner-up score," "the price just below the max" are one problem in three costumes. Cultivating that pattern-vision is a career skill far bigger than interviews: it is how you look at a messy real request and recognize the clean, solved query hiding inside it. Learn to see the shapes, and the questions stop being scary — they start being familiar.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.