Lesson 29 of 30
Classic SQL Interview Problems
Each problem has a one-line task, a short query, its output, and the technique it tests. Most rely on window functions, joins, and subqueries. Problems run in order in one session, and later ones use tables changed by earlier ones.
Setup
The sample company and shop tables are used as-is. Small temporary tables cover the problems that need extra data: logins (user activity), seq (a sequence with gaps), dupes (repeated emails), quarterly, people, salary_history, a_t/b_t, and bookings.
CREATE TEMP TABLE logins AS
SELECT * FROM (VALUES (1,'2024-01-01'::date),(1,'2024-01-02'),(1,'2024-01-03'),(1,'2024-01-05'),(1,'2024-01-06'),
(2,'2024-01-01'),(2,'2024-01-03'),(2,'2024-01-04'),(2,'2024-01-05'),(3,'2024-01-02')) v(user_id, day);
CREATE TEMP TABLE seq AS SELECT n FROM generate_series(1, 10) n WHERE n NOT IN (4, 7, 8);
CREATE TEMP TABLE dupes (id serial PRIMARY KEY, email text);
INSERT INTO dupes (email) VALUES ('a@x.com'),('b@x.com'),('a@x.com'),('c@x.com'),('b@x.com'),('a@x.com');
CREATE TEMP TABLE quarterly AS
SELECT * FROM (VALUES ('Keyboard',10,20,15),('Mouse',5,8,9)) v(product, q1, q2, q3);
CREATE TEMP TABLE people AS
SELECT * FROM (VALUES (1,'M'),(2,'F'),(3,'F'),(4,'M')) v(id, sex);
CREATE TEMP TABLE salary_history AS
SELECT * FROM (VALUES (1,'2020-01-01'::date,100),(1,'2022-01-01',120),(1,'2024-01-01',150),
(2,'2021-06-01',90),(2,'2023-06-01',95),(3,'2022-03-01',80)) v(emp_id, changed_on, salary);
CREATE TEMP TABLE a_t AS SELECT * FROM (VALUES (1,'x'),(2,'y'),(3,'z')) v(id, val);
CREATE TEMP TABLE b_t AS SELECT * FROM (VALUES (1,'x'),(2,'w'),(4,'v')) v(id, val);
CREATE TEMP TABLE bookings AS
SELECT * FROM (VALUES (1,'A','2024-01-01'::date,'2024-01-05'::date),(2,'A','2024-01-04','2024-01-08'),
(3,'A','2024-01-09','2024-01-10'),(4,'B','2024-01-01','2024-01-03'),
(5,'B','2024-01-03','2024-01-06')) v(id, room, start_date, end_date);Problem 1: Second highest salary with LIMIT OFFSET
Find the second highest distinct salary.
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1; salary
--------
180000
(1 row)DISTINCT first, then skip one row. Returns no row (not NULL) when fewer than two distinct salaries exist. MySQL uses the same syntax; SQL Server uses OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY.
Problem 2: Second highest salary with a subquery
Find the second highest salary without LIMIT.
SELECT max(salary) AS second_highest
FROM employees
WHERE salary < (SELECT max(salary) FROM employees); second_highest
----------------
180000
(1 row)Maximum of everything below the maximum. Returns NULL when there is no second value. Works in every database.
Problem 3: Nth highest salary with DENSE_RANK
Find the 3rd highest distinct salary.
SELECT DISTINCT salary
FROM (SELECT salary, dense_rank() OVER (ORDER BY salary DESC) AS r FROM employees) t
WHERE r = 3; salary
--------
160000
(1 row)DENSE_RANK gives tied salaries the same rank and no gaps, so N is a parameter change. See window functions.
Problem 4: Top N per group
List the two highest-paid employees in each department.
SELECT dept_id, name, salary
FROM (SELECT dept_id, name, salary,
row_number() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) AS rn
FROM employees WHERE dept_id IS NOT NULL) t
WHERE rn <= 2
ORDER BY dept_id, rn; dept_id | name | salary
---------+--------+--------
1 | Meera | 250000
1 | Ravi | 180000
2 | Divya | 160000
2 | Sameer | 90000
3 | Vikram | 120000
3 | Anita | 80000
4 | Rohan | 95000
(7 rows)ROW_NUMBER per partition, filter in an outer query. Use RANK or DENSE_RANK to keep ties.
Problem 5: Find and delete duplicates, keeping one
List emails that appear more than once, then delete the extra rows, keeping the lowest id.
SELECT email, count(*) AS n FROM dupes GROUP BY email HAVING count(*) > 1 ORDER BY email;
DELETE FROM dupes
WHERE ctid IN (SELECT ctid FROM (SELECT ctid, row_number() OVER (PARTITION BY email ORDER BY id) AS rn
FROM dupes) t
WHERE rn > 1);
SELECT * FROM dupes ORDER BY id; email | n
---------+---
a@x.com | 3
b@x.com | 2
(2 rows)
id | email
----+---------
1 | a@x.com
2 | b@x.com
4 | c@x.com
(3 rows)GROUP BY ... HAVING count(*) > 1 finds them; numbering rows per key and deleting rn > 1 removes them. ctid is the physical row address (PostgreSQL only); with a primary key, compare on id instead. Oracle uses rowid.
Problem 6: Earning more than the department average
List employees paid above their department's average.
SELECT e.name, e.dept_id, e.salary
FROM employees e
WHERE e.salary > (SELECT avg(salary) FROM employees WHERE dept_id = e.dept_id)
ORDER BY e.id; name | dept_id | salary
--------+---------+--------
Meera | 1 | 250000
Ravi | 1 | 180000
Divya | 2 | 160000
Vikram | 3 | 120000
(4 rows)Correlated subquery. Tara (NULL department) never matches because dept_id = NULL is not true. See subqueries.
Problem 7: Earning more than their manager
List employees who earn more than their manager.
CREATE TEMP TABLE emp_copy AS SELECT * FROM employees;
UPDATE emp_copy SET salary = 200000 WHERE name = 'Kiran';
SELECT e.name, e.salary, m.name AS manager, m.salary AS manager_salary
FROM emp_copy e JOIN emp_copy m ON m.id = e.manager_id
WHERE e.salary > m.salary; name | salary | manager | manager_salary
-------+--------+---------+----------------
Kiran | 200000 | Ravi | 180000
(1 row)Self join on manager_id. In the real sample data nobody qualifies, so a temp copy is changed to produce a row.
Problem 8: Customers with no orders, departments with no employees
List customers without orders and departments without employees.
SELECT 'customer' AS kind, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)
UNION ALL
SELECT 'department', d.name
FROM departments d LEFT JOIN employees e ON e.dept_id = d.id
WHERE e.id IS NULL; kind | name
------------+-------
customer | Nisha
department | Legal
(2 rows)Two anti-join forms: NOT EXISTS and LEFT JOIN ... IS NULL. Avoid NOT IN when the subquery can return NULL. See joins.
Problem 9: Department with the highest payroll
Find the department with the highest total salary and its share of the payroll.
SELECT d.name, sum(e.salary) AS payroll,
round(100.0 * sum(e.salary) / sum(sum(e.salary)) OVER (), 1) AS pct_of_total
FROM employees e JOIN departments d ON d.id = e.dept_id
GROUP BY d.name
ORDER BY payroll DESC
LIMIT 1; name | payroll | pct_of_total
-------------+---------+--------------
Engineering | 840000 | 57.1
(1 row)The window runs over grouped rows before LIMIT, so the percentage covers all departments. Tara has no department and is excluded by the inner join.
Problem 10: Running total and cumulative percentage
Show each order's amount, the running total, and the cumulative percentage of all revenue.
WITH o AS (SELECT oi.order_id, sum(p.price * oi.quantity) AS amt
FROM order_items oi JOIN products p ON p.id = oi.product_id
GROUP BY oi.order_id)
SELECT order_id, amt,
sum(amt) OVER w AS running,
round(100 * sum(amt) OVER w / sum(amt) OVER (), 1) AS cum_pct
FROM o
WINDOW w AS (ORDER BY order_id)
ORDER BY order_id; order_id | amt | running | cum_pct
----------+--------+---------+---------
1 | 950.00 | 950.00 | 31.6
2 | 50.00 | 1000.00 | 33.3
3 | 460.00 | 1460.00 | 48.6
4 | 75.00 | 1535.00 | 51.1
5 | 900.00 | 2435.00 | 81.0
6 | 75.00 | 2510.00 | 83.5
7 | 200.00 | 2710.00 | 90.2
8 | 170.00 | 2880.00 | 95.8
9 | 125.00 | 3005.00 | 100.0
(9 rows)SUM() OVER (ORDER BY ...) accumulates; dividing by the unordered total gives the percentage.
Problem 11: Month-over-month change
Compute monthly revenue (excluding cancelled orders) and its change from the previous month.
WITH m AS (SELECT date_trunc('month', o.order_date)::date AS month, sum(p.price * oi.quantity) AS rev
FROM orders o JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status <> 'cancelled'
GROUP BY 1)
SELECT month, rev,
rev - lag(rev) OVER (ORDER BY month) AS change,
round(100 * (rev - lag(rev) OVER (ORDER BY month)) / lag(rev) OVER (ORDER BY month), 1) AS pct_change
FROM m; month | rev | change | pct_change
------------+---------+----------+------------
2024-06-01 | 2360.00 | |
2024-07-01 | 570.00 | -1790.00 | -75.8
(2 rows)LAG reads the previous row; the first month has NULL.
Problem 12: Consecutive-day streaks
List every streak of consecutive login days per user.
SELECT user_id, min(day) AS start_day, max(day) AS end_day, count(*) AS days
FROM (SELECT user_id, day,
day - (row_number() OVER (PARTITION BY user_id ORDER BY day))::int AS grp
FROM logins) t
GROUP BY user_id, grp
ORDER BY user_id, start_day; user_id | start_day | end_day | days
---------+------------+------------+------
1 | 2024-01-01 | 2024-01-03 | 3
1 | 2024-01-05 | 2024-01-06 | 2
2 | 2024-01-01 | 2024-01-01 | 1
2 | 2024-01-03 | 2024-01-05 | 3
3 | 2024-01-02 | 2024-01-02 | 1
(5 rows)Gaps and islands: day - row_number() is constant inside a run of consecutive days, so it works as a group key. Requires one row per user per day.
Problem 13: Missing numbers in a sequence
Find the numbers from 1 to 10 missing from seq.
SELECT g AS missing FROM generate_series(1, 10) g
EXCEPT
SELECT n FROM seq
ORDER BY 1; missing
---------
4
7
8
(3 rows)Generate the full range and subtract what exists. LEFT JOIN ... IS NULL is equivalent.
Problem 14: Pivot rows to columns
Count each customer's orders per status as columns.
SELECT c.name,
count(*) FILTER (WHERE o.status = 'delivered') AS delivered,
count(*) FILTER (WHERE o.status = 'shipped') AS shipped,
count(*) FILTER (WHERE o.status = 'placed') AS placed,
count(*) FILTER (WHERE o.status = 'cancelled') AS cancelled
FROM customers c JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY c.name; name | delivered | shipped | placed | cancelled
--------+-----------+---------+--------+-----------
Aarav | 2 | 1 | 0 | 0
Isha | 1 | 0 | 0 | 1
Karan | 2 | 0 | 0 | 0
Lata | 0 | 0 | 1 | 0
Manish | 0 | 0 | 1 | 0
(5 rows)Conditional aggregation. Portable form: sum(CASE WHEN status = 'x' THEN 1 ELSE 0 END). SQL Server and Oracle also have PIVOT.
Problem 15: Unpivot columns to rows
Turn the columns q1, q2, q3 into one row per quarter.
SELECT q.product, x.quarter, x.units
FROM quarterly q
CROSS JOIN LATERAL (VALUES ('q1', q.q1), ('q2', q.q2), ('q3', q.q3)) AS x(quarter, units)
ORDER BY q.product, x.quarter; product | quarter | units
----------+---------+-------
Keyboard | q1 | 10
Keyboard | q2 | 20
Keyboard | q3 | 15
Mouse | q1 | 5
Mouse | q2 | 8
Mouse | q3 | 9
(6 rows)LATERAL (VALUES ...) emits one row per column. UNION ALL of three selects also works; SQL Server and Oracle have UNPIVOT.
Problem 16: Median
Compute the median salary per department.
SELECT dept_id, percentile_cont(0.5) WITHIN GROUP (ORDER BY salary) AS median
FROM employees
WHERE dept_id IS NOT NULL
GROUP BY dept_id
ORDER BY dept_id; dept_id | median
---------+--------
1 | 150000
2 | 90000
3 | 100000
4 | 95000
(4 rows)percentile_cont interpolates between the two middle values for an even count; percentile_disc returns an actual value. MySQL has no median function.
Problem 17: Swap values with one UPDATE
Swap 'M' and 'F' in a single statement.
UPDATE people SET sex = CASE sex WHEN 'M' THEN 'F' ELSE 'M' END;
SELECT * FROM people ORDER BY id; id | sex
----+-----
1 | F
2 | M
3 | M
4 | F
(4 rows)One statement reads old values for every row, so no temp value is needed.
Problem 18: Keep only the latest row per key
Delete all but the latest salary change per employee.
DELETE FROM salary_history h
WHERE EXISTS (SELECT 1 FROM salary_history n
WHERE n.emp_id = h.emp_id AND n.changed_on > h.changed_on)
RETURNING *;
SELECT * FROM salary_history ORDER BY emp_id; emp_id | changed_on | salary
--------+------------+--------
1 | 2020-01-01 | 100
1 | 2022-01-01 | 120
2 | 2021-06-01 | 90
(3 rows)
emp_id | changed_on | salary
--------+------------+--------
1 | 2024-01-01 | 150
2 | 2023-06-01 | 95
3 | 2022-03-01 | 80
(3 rows)Delete a row if a newer row with the same key exists. To only read the latest: SELECT DISTINCT ON (emp_id) ... ORDER BY emp_id, changed_on DESC.
Problem 19: First and last order per customer
Show each customer's first and last order date and order id.
SELECT customer_id, min(order_date) AS first_date, max(order_date) AS last_date,
(array_agg(id ORDER BY order_date, id))[1] AS first_id,
(array_agg(id ORDER BY order_date DESC, id DESC))[1] AS last_id
FROM orders
GROUP BY customer_id
ORDER BY customer_id; customer_id | first_date | last_date | first_id | last_id
-------------+------------+------------+----------+---------
1 | 2024-06-01 | 2024-07-05 | 1 | 6
2 | 2024-06-03 | 2024-07-01 | 3 | 4
3 | 2024-06-20 | 2024-07-10 | 5 | 8
4 | 2024-07-06 | 2024-07-06 | 7 | 7
5 | 2024-07-12 | 2024-07-12 | 9 | 9
(5 rows)min/max for dates; ordered array_agg picks the row id. row_number() in a subquery is the portable alternative.
Problem 20: Customers who bought all of a set of products
Find customers who bought the Keyboard, Mouse and Laptop (ids 1, 2, 4).
SELECT c.name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
WHERE oi.product_id IN (1, 2, 4)
GROUP BY c.name
HAVING count(DISTINCT oi.product_id) = 3; name
-------
Aarav
(1 row)Relational division: count distinct matches and compare with the size of the set. Using all six products returns no rows here because the Desk was never ordered.
Problem 21: Employees hired in the last N months
List employees hired within 24 months before 2024-06-01.
SELECT name, hired_on
FROM employees
WHERE hired_on > DATE '2024-06-01' - INTERVAL '24 months'
ORDER BY hired_on; name | hired_on
-------+------------
Anita | 2023-01-09
Tara | 2024-02-01
(2 rows)Compare the raw column with a computed bound so an index on hired_on stays usable. A fixed date keeps the result repeatable; use CURRENT_DATE in production.
Problem 22: Users active on 3 consecutive days
Find users with logins on three consecutive days.
SELECT DISTINCT user_id
FROM (SELECT user_id, day, lead(day, 2) OVER (PARTITION BY user_id ORDER BY day) AS day_plus_2
FROM logins) t
WHERE day_plus_2 = day + 2
ORDER BY user_id; user_id
---------
1
2
(2 rows)With one row per day, the row two positions ahead is exactly two days later only inside a 3-day run.
Problem 23: Hierarchy depth
Show each employee's depth in the management tree (top = 0).
WITH RECURSIVE t AS (
SELECT id, name, 0 AS depth FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, t.depth + 1 FROM employees e JOIN t ON e.manager_id = t.id)
SELECT name, depth FROM t ORDER BY depth, id; name | depth
--------+-------
Meera | 0
Ravi | 1
Divya | 1
Vikram | 1
Rohan | 1
Tara | 1
Kiran | 2
Neha | 2
Sameer | 2
Pooja | 2
Anita | 2
Arjun | 3
(12 rows)A recursive CTE repeats the self join level by level. See CTEs.
Problem 24: Cumulative distinct count
Show how many distinct customers have ordered by each first-order date.
WITH f AS (SELECT customer_id, min(order_date) AS d FROM orders GROUP BY customer_id)
SELECT d, count(*) AS new_customers, sum(count(*)) OVER (ORDER BY d) AS total_customers
FROM f
GROUP BY d
ORDER BY d; d | new_customers | total_customers
------------+---------------+-----------------
2024-06-01 | 1 | 1
2024-06-03 | 1 | 2
2024-06-20 | 1 | 3
2024-07-06 | 1 | 4
2024-07-12 | 1 | 5
(5 rows)COUNT(DISTINCT) is not allowed in a window. Reduce to each key's first appearance, then take a running sum.
Problem 25: Retention
For each month, how many buyers ordered again the next month (cancelled orders excluded)?
WITH m AS (SELECT DISTINCT customer_id, date_trunc('month', order_date)::date AS month
FROM orders WHERE status <> 'cancelled')
SELECT a.month, count(*) AS buyers, count(b.customer_id) AS retained,
round(100.0 * count(b.customer_id) / count(*), 1) AS pct
FROM m a
LEFT JOIN m b ON b.customer_id = a.customer_id AND b.month = a.month + INTERVAL '1 month'
GROUP BY a.month
ORDER BY a.month; month | buyers | retained | pct
------------+--------+----------+------
2024-06-01 | 3 | 2 | 66.7
2024-07-01 | 4 | 0 | 0.0
(2 rows)Self join the customer-month set to itself shifted by one month; LEFT JOIN keeps months with no retained buyers.
Problem 26: Compare two tables
Find rows that differ between a_t and b_t, in both directions.
SELECT 'only_in_a' AS side, * FROM (SELECT * FROM a_t EXCEPT SELECT * FROM b_t) x
UNION ALL
SELECT 'only_in_b', * FROM (SELECT * FROM b_t EXCEPT SELECT * FROM a_t) y
ORDER BY 1, 2; side | id | val
-----------+----+-----
only_in_a | 2 | y
only_in_a | 3 | z
only_in_b | 2 | w
only_in_b | 4 | v
(4 rows)EXCEPT is one-directional, so run it both ways. It treats NULLs as equal. Oracle spells it MINUS.
Problem 27: Overlapping date ranges
Find pairs of bookings in the same room whose date ranges overlap (end dates inclusive).
SELECT a.id AS booking_a, b.id AS booking_b, a.room
FROM bookings a JOIN bookings b
ON a.room = b.room AND a.id < b.id
AND a.start_date <= b.end_date AND b.start_date <= a.end_date
ORDER BY 1, 2; booking_a | booking_b | room
-----------+-----------+------
1 | 2 | A
4 | 5 | B
(2 rows)Two ranges overlap when each starts before the other ends. a.id < b.id removes mirrored and self pairs.
Problem 28: Top selling product per category
Find the product with the most units sold in each category (cancelled orders excluded).
WITH s AS (SELECT p.category, p.name, sum(oi.quantity) AS units
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE o.status <> 'cancelled'
GROUP BY p.category, p.name)
SELECT DISTINCT ON (category) category, name, units
FROM s
ORDER BY category, units DESC, name; category | name | units
-------------+---------+-------
Accessories | Mouse | 8
Computers | Laptop | 2
Displays | Monitor | 3
(3 rows)DISTINCT ON keeps the first row per category in the given order (PostgreSQL). Furniture is absent because the Desk has no sales.
Problem 29: Rank with ties
Show ROW_NUMBER, RANK and DENSE_RANK side by side for Engineering salaries.
SELECT name, salary,
row_number() OVER w AS row_num,
rank() OVER w AS rnk,
dense_rank() OVER w AS dense
FROM employees
WHERE dept_id = 1
WINDOW w AS (ORDER BY salary DESC)
ORDER BY salary DESC, id; name | salary | row_num | rnk | dense
-------+--------+---------+-----+-------
Meera | 250000 | 1 | 1 | 1
Ravi | 180000 | 2 | 2 | 2
Kiran | 150000 | 3 | 3 | 3
Neha | 150000 | 4 | 3 | 3
Arjun | 110000 | 5 | 5 | 4
(5 rows)Kiran and Neha tie: RANK skips the next number, DENSE_RANK does not, ROW_NUMBER never repeats.
Problem 30: Combined department report with CTEs
Report headcount, payroll, average salary and top earner per department, highest payroll first.
WITH d AS (SELECT dept_id, count(*) AS staff, sum(salary) AS payroll, round(avg(salary)) AS avg_salary
FROM employees GROUP BY dept_id),
top AS (SELECT DISTINCT ON (dept_id) dept_id, name AS top_earner
FROM employees ORDER BY dept_id, salary DESC, id)
SELECT dep.name AS department, d.staff, d.payroll, d.avg_salary, top.top_earner
FROM departments dep
JOIN d ON d.dept_id = dep.id
JOIN top ON top.dept_id = dep.id
ORDER BY d.payroll DESC; department | staff | payroll | avg_salary | top_earner
-------------+-------+---------+------------+------------
Engineering | 5 | 840000 | 168000 | Meera
Sales | 3 | 335000 | 111667 | Divya
Marketing | 2 | 200000 | 100000 | Vikram
HR | 1 | 95000 | 95000 | Rohan
(4 rows)One CTE per intermediate result, joined at the end. Legal has no employees and Tara has no department, so both are absent.
Summary
- Ranking problems (Nth highest, top N per group, ties) use
ROW_NUMBER,RANK, orDENSE_RANK; choose by how ties should behave. - Missing data problems (no orders, no employees, missing numbers, table differences) use anti-joins or
EXCEPT. - Streaks and consecutive days use
day - row_number()orLEAD/LAG. - Pivot with conditional aggregation, unpivot with
LATERAL (VALUES ...). - Deleting duplicates or old versions: number rows per key, or delete rows that have a newer match.
