SQL Interview Questions: 30 Real Ones With Worked Answers
Thirty SQL interview questions with worked, runnable answers: joins, NULL semantics, window functions, dedupe, dates, pivots and query plans.

SQL interview questions look easier than they are. Almost everyone can write a join; far fewer can say what happens to that join when the right-hand table is filtered in the WHERE clause, or why a NOT IN against a nullable column silently returns nothing at all. Those are the questions that decide the round. What follows is thirty questions grouped by theme, each with a query you can actually run and a line on what the interviewer is measuring.
How to use this page#
Every query below was executed against a small SQLite database before being published, so the syntax is real rather than plausible. Where a statement is MySQL- or Postgres-specific, the text says so instead of pretending the dialects agree — that inconsistency is itself a common interview topic.
Read this as a set of sql interview questions and answers to work through with a terminal open, not as a list to skim. Paste this schema into sqlite3 first and every query below will run against it:
CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT,
salary INTEGER, -- nullable on purpose
department_id INTEGER, -- nullable on purpose
manager_id INTEGER, -- NULL for the two people at the top
hired_on TEXT
);
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, country TEXT);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER,
amount REAL,
ordered_at TEXT
);
CREATE TABLE order_items (id INTEGER PRIMARY KEY, order_id INTEGER, sku TEXT, qty INTEGER);
CREATE TABLE logins (user_id INTEGER, login_date TEXT);
CREATE TABLE weather (id INTEGER PRIMARY KEY, recorded_on TEXT, temperature INTEGER);
CREATE TABLE person (id INTEGER PRIMARY KEY, email TEXT);Those two nullable columns on employees are the reason half the questions in the NULL section have a wrong answer that looks right. Fill the tables with whatever you like — the row counts quoted in the comments below come from seven employees across three departments, where one employee has no salary and no department, one department is empty, and one of four customers has never ordered.
Joins#
Joins are the warm-up. The screen is not testing whether you know the keyword — it is testing whether you know what the join does to the row count, which is a different question and the one that separates people who write SQL from people who read it.
1. Find every customer who has never placed an order.
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;The anti-join. Testing whether you reach for LEFT JOIN … IS NULL rather than NOT IN, which behaves badly against nullable columns (question 10). NOT EXISTS is equally correct and often clearer. Practise on Customers Who Never Order.
2. Count each customer's orders before March, keeping customers with zero.
This is the trap. Filtering the right-hand table in WHERE turns the outer join back into an inner one, because the NULL rows the LEFT JOIN produced fail the predicate:
-- WRONG: Cyclone and Delta disappear entirely
SELECT c.name, COUNT(o.id) AS orders_q1
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.ordered_at < '2025-03-01'
GROUP BY c.name;Move the predicate into the ON clause and the outer join survives:
SELECT c.name, COUNT(o.id) AS orders_q1
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.ordered_at < '2025-03-01'
GROUP BY c.name
ORDER BY c.name;Testing whether you understand that ON filters before the outer join fills in NULLs and WHERE filters after. This is the single most common cause of quietly missing rows in a production dashboard.
3. Who earns more than their own manager?
SELECT e.name AS employee, e.salary, m.name AS manager, m.salary AS manager_salary
FROM employees e
JOIN employees m ON m.id = e.manager_id
WHERE e.salary > m.salary;The self join. Testing alias discipline more than anything — the interviewer wants to see you name both sides so the ON clause reads unambiguously. The self-join version of this shows up on almost every SQL problem set.
4. Revenue doubled after I joined the line items table. Why?
-- 7 orders in, 6 rows out, and the SUM matches neither number
SELECT COUNT(*) AS joined_rows, SUM(o.amount) AS inflated_revenue
FROM orders o
JOIN order_items i ON i.order_id = o.id;Join fan-out: an order with two line items contributes its amount twice, so SUM(o.amount) counts those orders double — while the inner join simultaneously drops the orders that have no line items at all. Both errors are in the same row count, which is why "the total went down, so it can't be double counting" is not a safe inference. Aggregate the many-side first, then join:
SELECT o.id, o.amount, i.units
FROM orders o
JOIN (
SELECT order_id, SUM(qty) AS units
FROM order_items
GROUP BY order_id
) i ON i.order_id = o.id
ORDER BY o.id;Testing whether you check cardinality before trusting an aggregate. Saying "let me confirm this join is one-to-one first" out loud is worth more than the query.
5. MySQL has no FULL OUTER JOIN. How do you get one?
Two outer joins and a UNION, which deduplicates the overlap for you:
SELECT c.name AS customer, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
UNION
SELECT c.name, o.id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY customer, order_id;Postgres, SQL Server and SQLite 3.39+ support FULL OUTER JOIN natively; MySQL does not. Testing dialect awareness and whether you know that UNION deduplicates while UNION ALL does not.
Aggregation and GROUP BY#
Most sql query interview questions in this group are really asking one thing: do you know the logical order in which a SELECT is evaluated? FROM, then WHERE, then GROUP BY, then HAVING, then SELECT, then ORDER BY. Every answer below falls out of that sequence.
6. What is the difference between WHERE and HAVING?
SELECT d.name AS department, COUNT(*) AS headcount
FROM employees e
JOIN departments d ON d.id = e.department_id
WHERE e.hired_on >= '2022-01-01' -- filters rows, before grouping
GROUP BY d.name
HAVING COUNT(*) >= 2; -- filters groups, afterWHERE cannot see an aggregate because the groups do not exist yet. Testing the processing order directly. A follow-up often asks why ORDER BY can use a SELECT alias while WHERE cannot — same reason, SELECT runs later than WHERE and earlier than ORDER BY.
7. COUNT(*), COUNT(col), COUNT(DISTINCT col) — what is different?
SELECT
COUNT(*) AS rows_total, -- 7
COUNT(salary) AS rows_with_salary, -- 6, NULL not counted
COUNT(DISTINCT salary) AS distinct_salaries, -- 5
COUNT(DISTINCT department_id) AS distinct_departments -- 2, NULL not counted
FROM employees;COUNT(*) counts rows; COUNT(expr) counts non-NULL values of the expression. Testing whether you have internalised that every aggregate except COUNT(*) skips NULLs.
The applied version of the same rule is the follow-up: show headcount per department, including departments with nobody in them.
SELECT d.name AS department, COUNT(e.id) AS headcount
FROM departments d
LEFT JOIN employees e ON e.department_id = d.id
GROUP BY d.name
ORDER BY headcount DESC, department;COUNT(e.id) returns 0 for the empty department. COUNT(*) would return 1, because the LEFT JOIN produced one all-NULL row and COUNT(*) counts rows regardless of their contents. Getting this wrong produces a plausible number rather than an error, which is why it survives code review.
8. Which departments pay above the company average?
SELECT d.name AS department, AVG(e.salary) AS dept_avg
FROM employees e
JOIN departments d ON d.id = e.department_id
GROUP BY d.name
HAVING AVG(e.salary) > (SELECT AVG(salary) FROM employees);Testing whether you know a scalar subquery is legal inside HAVING and evaluates once, not per group.
NULL semantics#
This is where interviews are decided, because every wrong answer here still runs.
9. WHERE department_id <> 1 returns 2 rows, but 3 people are not in department 1. Where did the third go?
Into the void, along with every other row where department_id is NULL. NULL <> 1 is UNKNOWN, not TRUE, and WHERE keeps only TRUE.
-- standard SQL, and supported by Postgres and SQLite 3.39+
SELECT COUNT(*) FROM employees WHERE department_id IS DISTINCT FROM 1;
-- portable everywhere
SELECT COUNT(*) FROM employees
WHERE department_id <> 1 OR department_id IS NULL;MySQL spells the NULL-safe comparison <=>, so the equivalent is NOT (department_id <=> 1). Testing three-valued logic — the interviewer wants to hear "unknown", not "false".
10. Why did this NOT IN return zero rows?
-- returns 0, because manager_id contains a NULL
SELECT COUNT(*) FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);id NOT IN (1, 4, NULL) expands to id <> 1 AND id <> 4 AND id <> NULL. That last term is UNKNOWN, so the whole conjunction can never be TRUE. NOT EXISTS is immune:
-- returns 5
SELECT COUNT(*)
FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.id);Testing whether you can debug a query that produces no error and no rows. This one shows up constantly because it is invisible until the data changes.
11. SUM(salary) / COUNT(*) and AVG(salary) disagree. Which is right?
SELECT
AVG(salary) AS avg_over_non_null, -- 141666.67
SUM(salary) * 1.0 / COUNT(*) AS avg_over_all_rows, -- 121428.57
AVG(COALESCE(salary, 0)) AS avg_with_null_as_zero
FROM employees;Both are right; they answer different questions. AVG divides by the count of non-NULL values. Testing whether you ask what a missing salary means — unknown, or genuinely zero — before choosing.
12. Sort by salary descending with the unknowns at the bottom.
SELECT name, salary FROM employees
ORDER BY CASE WHEN salary IS NULL THEN 1 ELSE 0 END, salary DESC;Postgres and SQLite accept ORDER BY salary DESC NULLS LAST; MySQL does not, so the CASE expression is the portable answer. Testing whether you know that NULL ordering is engine-defined rather than universal.
Subqueries, CTEs and recursion#
13. Rewrite this correlated subquery as a join.
-- executes the inner query once per customer
SELECT c.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;-- aggregates once, then joins
WITH per_customer AS (
SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS revenue
FROM orders
GROUP BY customer_id
)
SELECT c.name,
COALESCE(p.order_count, 0) AS order_count,
COALESCE(p.revenue, 0) AS revenue
FROM customers c
LEFT JOIN per_customer p ON p.customer_id = c.id
ORDER BY revenue DESC, c.name;Testing whether you can name the cost of the correlated form and whether you remember COALESCE — the LEFT JOIN produces NULL for the customer with no orders, and a report showing NULL instead of 0 is a bug.
14. Walk the reporting chain from the top down.
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE manager_id IS NULL -- anchor
UNION ALL
SELECT e.id, e.name, e.manager_id, chain.depth + 1
FROM employees e
JOIN chain ON chain.id = e.manager_id -- recursive term
)
SELECT depth, name FROM chain ORDER BY depth, name;Testing whether you can state the two halves — anchor and recursive term — and what stops the recursion. Postgres and MySQL 8.0+ require the RECURSIVE keyword; SQL Server omits it.
The senior follow-up to both of these is when is a CTE the wrong choice? When you reference it several times and the engine re-evaluates it each time, or when it blocks predicate pushdown. Older Postgres always materialised CTEs, making them an optimisation fence; Postgres 12 added MATERIALIZED and NOT MATERIALIZED so you can force either behaviour. Treat a CTE as a readability tool with a performance profile, not as free.
Window functions#
If you only have an evening, spend it here. Window functions are what separate a data-adjacent engineer from a data engineer in a screen.
15. Find the second highest salary.
-- shortest
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;-- generalises to Nth, and states its tie policy
SELECT salary FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk = 2 LIMIT 1;-- returns NULL instead of zero rows when there is no second salary
SELECT (
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1
) AS second_highest;Testing whether you volunteer the edge case. The graded version of this problem requires the NULL, not an empty result — see Second Highest Salary.
16. Explain ROW_NUMBER, RANK and DENSE_RANK on tied values.
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense
FROM employees
WHERE salary IS NOT NULL;With two people on 150000 the columns read 1,2,3,4,5,6 / 1,2,2,4,5,6 / 1,2,2,3,4,5. ROW_NUMBER never repeats and breaks ties arbitrarily unless the ORDER BY is fully deterministic; RANK repeats and skips; DENSE_RANK repeats and does not skip. Testing precision.
17. Top two earners in each department.
WITH ranked AS (
SELECT d.name AS department, e.name AS employee, e.salary,
DENSE_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS rnk
FROM employees e
JOIN departments d ON d.id = e.department_id
)
SELECT department, employee, salary
FROM ranked
WHERE rnk <= 2
ORDER BY department, salary DESC, employee;Testing PARTITION BY, and whether you ask what to do about ties at the boundary before you pick a ranking function. The hard version of this is Department Top Three Salaries.
18. Produce a running total of revenue by date.
SELECT ordered_at, amount,
SUM(amount) OVER (ORDER BY ordered_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM orders
ORDER BY ordered_at, id;The explicit frame is the point. Without it the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which lumps peer rows — rows with an equal ORDER BY value — into the same frame, so two orders on the same date both show the day's full total. The same default is why LAST_VALUE(x) OVER (ORDER BY x) returns the current row rather than the last one. Testing whether you know frames exist.
19. Show the change against the previous order.
SELECT ordered_at, amount,
LAG(amount) OVER (ORDER BY ordered_at, id) AS prev_amount,
amount - LAG(amount) OVER (ORDER BY ordered_at, id) AS delta
FROM orders
ORDER BY ordered_at, id;The first row's delta is NULL — wrap it in COALESCE only if NULL is genuinely wrong for the report. LAG(amount, 1, 0) supplies a default directly. Testing whether you notice the boundary row.
20. Each employee's salary as a percentage of their department's payroll.
SELECT d.name AS department, e.name AS employee, e.salary,
ROUND(100.0 * e.salary / SUM(e.salary) OVER (PARTITION BY e.department_id), 1) AS pct_of_dept
FROM employees e
JOIN departments d ON d.id = e.department_id
WHERE e.salary IS NOT NULL
ORDER BY department, pct_of_dept DESC;Testing the key insight that an aggregate with an OVER clause keeps every input row instead of collapsing them — a GROUP BY would have destroyed the detail this query needs.
21. Why can't I filter on a window function in WHERE?
-- error: misuse of window function RANK()
SELECT name, salary FROM employees
WHERE RANK() OVER (ORDER BY salary DESC) = 1;Window functions are evaluated after WHERE, so the value does not exist when WHERE runs. Compute it in a CTE or derived table and filter outside, as questions 15 and 17 do. Snowflake and BigQuery offer a QUALIFY clause for exactly this; Postgres, MySQL and SQLite do not. Testing the processing order again, from a different angle.
Finding and deleting duplicates#
22. Find the duplicated emails, then delete them, keeping one row each.
Finding them is a group with a HAVING:
SELECT email, COUNT(*) AS copies
FROM person
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY email;The follow-up is always "now do it for a duplicate defined by three columns" — add all three to both the GROUP BY and the SELECT. Deleting is the same grouping, inverted: keep the surviving id and remove the rest.
DELETE FROM person
WHERE id NOT IN (SELECT MIN(id) FROM person GROUP BY email);The window-function version lets you choose the survivor by something other than the lowest id:
DELETE FROM person
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM person
) WHERE rn > 1
);MySQL will not read the table it is deleting from inside a subquery, so it needs the inner query wrapped in an extra derived table to force materialisation — which is precisely what the second form already does. Testing whether you consider what happens if the query is run twice, and whether you would wrap it in a transaction. Practise on Delete Duplicate Emails.
Dates, gaps and streaks#
Date functions are the least portable part of SQL. The queries below are SQLite; the equivalents are noted inline.
23. Find every day warmer than the day before.
SELECT w.id, w.recorded_on
FROM weather w
JOIN weather p ON p.recorded_on = date(w.recorded_on, '-1 day')
WHERE w.temperature > p.temperature;Postgres writes the offset as w.recorded_on - INTERVAL '1 day', MySQL as DATE_SUB(w.recorded_on, INTERVAL 1 DAY). The join must be on the date, never on id - 1 — ids are not guaranteed to be consecutive, and the classic wrong answer assumes they are. This is Rising Temperature.
The window-function alternative reads better and scans the table once:
SELECT recorded_on FROM (
SELECT recorded_on, temperature,
LAG(temperature) OVER (ORDER BY recorded_on) AS prev_temp,
LAG(recorded_on) OVER (ORDER BY recorded_on) AS prev_day
FROM weather
) w
WHERE temperature > prev_temp AND recorded_on = date(prev_day, '+1 day');The second predicate matters: LAG gives you the previous row, which is not the previous day when a day is missing.
24. Bucket revenue by month.
SELECT strftime('%Y-%m', ordered_at) AS month,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
GROUP BY month
ORDER BY month;Postgres uses date_trunc('month', ordered_at) or to_char(ordered_at, 'YYYY-MM'); MySQL uses DATE_FORMAT(ordered_at, '%Y-%m'). Testing whether you group by a derived expression rather than by raw dates.
25. Report the days in a range with no activity at all.
Missing days cannot be produced by a table that does not contain them, so you generate a date spine and outer-join to it:
WITH RECURSIVE spine(day) AS (
SELECT (SELECT MIN(login_date) FROM logins)
UNION ALL
SELECT date(day, '+1 day') FROM spine
WHERE day < (SELECT MAX(login_date) FROM logins)
)
SELECT s.day, COUNT(l.user_id) AS logins
FROM spine s
LEFT JOIN logins l ON l.login_date = s.day
GROUP BY s.day
ORDER BY s.day;Postgres has generate_series for this. Testing whether you recognise the shape of the problem: zero-row reporting always needs a spine.
26. Find each user's longest streak of consecutive login days.
The gaps-and-islands trick: subtract a row number from the date, and every consecutive run collapses to a constant.
WITH marked AS (
SELECT user_id, login_date,
date(login_date,
'-' || ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) || ' day') AS grp
FROM logins
)
SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS days
FROM marked
GROUP BY user_id, grp
HAVING COUNT(*) >= 2
ORDER BY user_id;Testing pattern recognition. Consecutive-row problems are all this shape underneath, and it is the same instinct as reaching for a hash map when a nested loop asks "is there another row such that…".
Pivoting#
27. Turn months into columns.
Standard SQL has no PIVOT operator, so you conditionally aggregate:
SELECT c.country,
SUM(CASE WHEN strftime('%Y-%m', o.ordered_at) = '2025-01' THEN o.amount ELSE 0 END) AS jan,
SUM(CASE WHEN strftime('%Y-%m', o.ordered_at) = '2025-02' THEN o.amount ELSE 0 END) AS feb,
SUM(CASE WHEN strftime('%Y-%m', o.ordered_at) = '2025-03' THEN o.amount ELSE 0 END) AS mar
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.country
ORDER BY c.country;Postgres and SQLite also accept the FILTER (WHERE …) spelling, which is cleaner; MySQL does not have it:
SELECT c.country,
SUM(o.amount) FILTER (WHERE strftime('%Y-%m', o.ordered_at) = '2025-01') AS jan,
SUM(o.amount) FILTER (WHERE strftime('%Y-%m', o.ordered_at) = '2025-02') AS feb
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.country;Testing whether you know the column list must be fixed at write time — a genuinely dynamic pivot needs generated SQL, and saying so is the correct answer to the inevitable follow-up.
Advanced SQL interview questions: indexes and query plans#
The senior signal is not more syntax. It is being able to say why a query is slow.
28. Which index would you add for WHERE ordered_at BETWEEN … AND …?
One on orders(ordered_at). You can watch the plan change:
CREATE INDEX idx_orders_ordered_at ON orders(ordered_at);
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE ordered_at >= '2025-03-01' AND ordered_at < '2025-04-01';
-- SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at>? AND ordered_at<?)SQLite prints SEARCH … USING INDEX for an index seek and SCAN for a full table scan; Postgres and MySQL use EXPLAIN and EXPLAIN ANALYZE, with Index Scan and Seq Scan in Postgres. Testing whether you have ever read a plan rather than guessed at one.
29. Why does wrapping the column in a function stop the index being used?
CREATE INDEX idx_orders_ordered_at ON orders(ordered_at);
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE strftime('%Y-%m', ordered_at) = '2025-03';
-- SCAN orders ← the index is there and the planner still won't use itThe index stores ordered_at, not strftime(ordered_at), so the engine has to compute the function for every row before it can compare anything. The term is sargable: keep the bare column alone on one side and move the transformation to the constant, as question 28 does with a half-open range.
A prefix LIKE 'name12%' is the same idea in reverse — it is a range, so it can become an index seek, while a leading wildcard LIKE '%12' has no range to seek and always scans. SQLite adds a wrinkle worth knowing: its LIKE is case-insensitive by default, so a plain CREATE INDEX ix ON t(name) is not used for the prefix form and you need t(name COLLATE NOCASE) before the planner will seek. Postgres has the same class of problem with non-C collations, and offers expression indexes as the general escape hatch.
30. Does column order matter in a composite index?
Yes, and it is the most common index mistake:
CREATE INDEX idx_orders_cust_date ON orders(customer_id, ordered_at);
-- uses the index: leading column present
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 1 AND ordered_at >= '2025-02-01';
-- SEARCH orders USING INDEX idx_orders_cust_date (customer_id=? AND ordered_at>?)
-- cannot: leading column absent
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE ordered_at >= '2025-02-01';
-- SCAN ordersA composite index is usable left-to-right, so put equality columns before range columns. Testing whether you would add a second index or reorder the one you have. The related idea is a covering index — one that contains every column the query touches, so the engine never visits the table:
CREATE INDEX idx_orders_cover ON orders(customer_id, amount);
EXPLAIN QUERY PLAN SELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id;
-- SCAN orders USING COVERING INDEX idx_orders_coverPractising these under interview conditions#
Solving these at your desk and solving them while someone watches are different skills. Three things make the difference.
Say the assumption before the query. Ties, NULLs and whether an empty result is acceptable are decisions, not details. Announcing "I am treating a tied second salary as still second, so DENSE_RANK" turns a silent guess into visible reasoning, and it lets the interviewer correct your assumption instead of failing your answer.
Check the row count before the numbers. After any join, COUNT(*) before and after tells you whether you fanned out. It is a two-second habit that catches question 4 every time.
Work from the shape, not from memory. Consecutive rows means gaps-and-islands. Per-group top N means a ranking function in a CTE. Zero-row reporting means a spine. That mapping is the same discipline the algorithm side rewards, which is why the LeetCode patterns framing transfers — and the database set has its own version of it in the SQL problem hub.
Where you meet these questions changes the format more than the content. A take-home assessment on a platform like HackerRank is scored on what your query returns, so the edge cases decide it whether or not you get to explain yourself — which makes the NULL section above the highest-value part of this page. A live round in a shared editor like CoderPad inverts that: the reasoning is most of the signal, and an interviewer who cannot follow it will assume there was none.
For the graded versions with real schemas to run against, work through the SQL problem set. And if you want a second pair of eyes during the round itself, Stealth Interview is a desktop app for macOS and Windows that reads a problem straight from a screenshot and returns a step-by-step explanation with its complexity — invisible to screen sharing, with live audio transcription for the parts of the interview that are not the query.
Frequently asked questions
- What SQL topics come up most in interviews?
- Joins and aggregation open almost every round, and they are the part candidates rarely fail. The rounds are decided further in: NULL semantics, window functions, and the difference between a correlated subquery and a join. If you have limited time, spend it on window functions and NULL behaviour — they are where a technically fluent candidate still produces a wrong number without noticing.
- How do I find the second highest salary in SQL?
- Three answers are accepted. `SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1` is the shortest. `DENSE_RANK() OVER (ORDER BY salary DESC)` filtered to rank 2 generalises to Nth and handles ties the way most interviewers want. Wrapping the first form in a scalar subquery makes it return NULL rather than zero rows when there is no second salary, which is what the LeetCode version of the problem grades on. Say which tie semantics you are assuming before you write any of them.
- What is the difference between RANK, DENSE_RANK and ROW_NUMBER?
- ROW_NUMBER always produces 1, 2, 3 with no repeats — ties are broken arbitrarily unless your ORDER BY is fully deterministic. RANK gives tied rows the same number and then skips: 1, 2, 2, 4. DENSE_RANK gives tied rows the same number and does not skip: 1, 2, 2, 3. For top-N-per-group, ROW_NUMBER returns exactly N rows and DENSE_RANK returns everyone tied at the boundary, so the choice is a product decision, not a syntax preference.
- Why does NOT IN return no rows when the subquery contains a NULL?
- `x NOT IN (a, b, NULL)` expands to `x <> a AND x <> b AND x <> NULL`, and `x <> NULL` evaluates to UNKNOWN rather than TRUE. An AND chain containing UNKNOWN can never be TRUE, so every row is filtered out and the query returns an empty result with no error. Use NOT EXISTS, which uses a different truth test, or filter the NULLs out of the subquery explicitly.
- Can I use a window function in a WHERE clause?
- No. Window functions are evaluated after WHERE in the logical processing order, so the value does not exist yet when WHERE runs — engines reject it as a syntax error. Compute the window function in a CTE or derived table and filter on it in the outer query. Snowflake and BigQuery add a QUALIFY clause for this; Postgres, MySQL and SQLite do not have it.
- How do I delete duplicate rows but keep one?
- Group by the columns that define a duplicate, keep the surviving id, and delete the rest: `DELETE FROM person WHERE id NOT IN (SELECT MIN(id) FROM person GROUP BY email)`. The window-function version — ROW_NUMBER partitioned by the duplicate key, delete where the row number is above 1 — is more flexible because you can pick the survivor by recency instead of by id. In MySQL, wrap the subquery in an extra derived table, because MySQL will not read the table it is deleting from inside a subquery.
Keep reading

The Coding Interview Cheat Sheet: Complexity, Patterns and Python Idioms
This coding interview cheat sheet is the reference sheet I would want open during preparation: the complexity budget implied by each input size, what every…

Amazon Online Assessment: Format, What It Tests, and How to Prepare
The Amazon online assessment is the automated screen that stands between an application and a human interviewer for most software engineering roles, including…

Blind 75: What the List Is and How to Finish It in Six Weeks
The Blind 75 is a seventy-five-problem list that has become the default answer to "what should I actually solve before an interview". It is named after Blind,…