Library
Learn/Databases/Chapter 12
7 min read

SQL interview questions

SQL rounds draw from a surprisingly small pool. The same ten or so patterns come back again and again, dressed in different tables: the second highest salary, the top three per department, customers who never ordered, duplicates, running totals, streaks. Learn the pattern behind each and you can answer the variations you have never seen.

Every answer below runs on Postgres, and on MySQL 8 except where noted. Each question is followed by the thinking, because the interviewer cares more about that than about the syntax.

Your answer

Before reading on: how would you find the second highest salary in an employees table? Write the query you would give in an interview, and note what happens if two people share the top salary.

First, the order SQL actually runs in#

Half of the confusing errors in SQL come from reading a query top to bottom. The database runs it in this order:

text
FROM / JOIN  ->  WHERE  ->  GROUP BY  ->  HAVING  ->  SELECT  ->  ORDER BY  ->  LIMIT

This explains three things interviewers love to ask. WHERE cannot use an aggregate like COUNT(*), because grouping has not happened yet, which is what HAVING is for. WHERE cannot use a column alias from SELECT, because SELECT runs later. And ORDER BY can use the alias, because it runs after.

Window functions: the tool behind half the answers#

A window function computes a value for each row using other rows around it, without collapsing the rows the way GROUP BY does. The ranking functions are the ones that keep coming up, and the difference between them is all about ties.

Rank with ties
SELECT name, dept, salary,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS r
FROM employees;
namedeptsalaryr
AshaEng180K1
RaviEng150K2
MeeraEng150K2
ZoyaSales140K3
KabirEng120K4
DevSales110K5
IraSales110K5

Shaded rows are ties: same salary within the ranking.

Ties share a rank, and nothing is skipped. This is the one for "the Nth highest salary", because the second highest salary is the second distinct value, however many people earn it.

1. The Nth highest salary#

sql
SELECT DISTINCT salary
FROM (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r
  FROM employees
) ranked
WHERE r = 2;

DENSE_RANK because "second highest salary" means the second distinct value, however many people share the first. ROW_NUMBER would return whoever happened to come second, possibly someone on the top salary. Change 2 to N and it answers the general question. Say out loud what you return when there is no second salary (this returns no rows; some interviewers want NULL, which SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees) gives you).

2. Top three earners in each department#

sql
SELECT name, dept, salary
FROM (
  SELECT name, dept, salary,
         DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS r
  FROM employees
) ranked
WHERE r <= 3;

PARTITION BY dept restarts the ranking for each department. You must filter in an outer query, because a window function cannot appear in WHERE (it is computed in SELECT, which runs after WHERE). Ask whether ties at third place should all be included: DENSE_RANK includes them, ROW_NUMBER cuts at exactly three.

3. Customers who never placed an order#

sql
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

The LEFT JOIN version, LEFT JOIN orders o ON ... WHERE o.id IS NULL, is equally correct. Do not use NOT IN (SELECT customer_id FROM orders): a single NULL customer_id makes it return nothing at all. Mentioning that unprompted is worth more than the query itself.

4. Find duplicates, then delete them#

sql
-- find
SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
 
-- delete all but the oldest of each
DELETE FROM users
WHERE id IN (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at) AS rn
    FROM users
  ) t
  WHERE rn > 1
);

HAVING because the condition is on a group. ROW_NUMBER for the delete because you want exactly one survivor per email, ties or not. Then say how you would stop it happening again: a unique index on email.

5. A running total#

sql
SELECT order_date, amount,
       SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM daily_sales;

SUM as a window function adds up every row from the start to the current one. Add PARTITION BY store_id for a running total per store.

6. Change from the previous month#

sql
SELECT month, revenue,
       revenue - LAG(revenue) OVER (ORDER BY month) AS change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
             / LAG(revenue) OVER (ORDER BY month), 1) AS pct_change
FROM monthly_revenue;

LAG reads the previous row's value, LEAD the next one. The first month has no previous, so its change is NULL, which is the correct answer rather than zero.

7. Longest streak of consecutive login days#

This is the hardest common one, usually called gaps and islands.

sql
WITH days AS (
  SELECT DISTINCT user_id, login_date FROM logins
),
grouped AS (
  SELECT user_id, login_date,
         login_date - CAST(ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS int) AS grp
  FROM days
)
SELECT user_id, COUNT(*) AS streak
FROM grouped
GROUP BY user_id, grp
ORDER BY streak DESC;

The trick: number each user's login days 1, 2, 3 and subtract that many days from the date. For consecutive days, the date and the number both go up by one, so the difference stays the same. A gap breaks the pattern and starts a new group. Count the rows in each group and you have the length of each streak. (That is Postgres, where a date minus a whole number is a date. In MySQL, compute the row number in one CTE and use DATE_SUB(login_date, INTERVAL rn DAY) in the next.)

Quick fire questions#

The one line answers
WHERE vs HAVING
DELETE vs TRUNCATE vs DROP
UNION vs UNION ALL
COUNT(*) vs COUNT(column)
Primary key vs unique key
What is a correlated subquery?
Talk while you write

The strongest candidates say the plan before the SQL: "I will rank salaries within each department with DENSE_RANK so ties are kept, then filter to rank three or less in an outer query, because a window function cannot go in WHERE." A query with a small typo and a clear plan beats a perfect query written in silence.

Checkpoint#

Checkpoint

1. Two employees share the highest salary. Which function correctly finds the second highest salary as a value?

2. Why does this fail: SELECT name, COUNT(*) FROM orders WHERE COUNT(*) > 5 GROUP BY name?

3. In the gaps and islands query, why does login_date minus the row number group consecutive days?

Worth memorising
  • FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT: the order SQL runs.
  • DENSE_RANK for Nth highest, ROW_NUMBER for exactly one per group.
  • NOT EXISTS, never NOT IN, when the subquery can contain NULL.
  • Gaps and islands: date minus row number is constant within a streak.
Say this in 60 seconds

Most SQL interview questions are a handful of patterns. I keep the logical order in mind: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT, which explains why aggregates go in HAVING and window functions need an outer query to filter on. For Nth highest I use DENSE_RANK so ties count once, for top N per group DENSE_RANK with PARTITION BY, and for one row per group ROW_NUMBER. Customers with no orders is NOT EXISTS or a LEFT JOIN with IS NULL, never NOT IN because of NULLs. Running totals and month over month changes are SUM and LAG as window functions, and streaks are gaps and islands, where date minus row number stays constant within a run.