Guide contents

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.

SQL
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.

SQL
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;
Output
 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.

SQL
SELECT max(salary) AS second_highest
FROM employees
WHERE salary < (SELECT max(salary) FROM employees);
Output
 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.

SQL
SELECT DISTINCT salary
FROM (SELECT salary, dense_rank() OVER (ORDER BY salary DESC) AS r FROM employees) t
WHERE r = 3;
Output
 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.

SQL
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;
Output
 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.

SQL
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;
Output
  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.

SQL
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;
Output
  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.

SQL
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;
Output
 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.

SQL
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;
Output
    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.

SQL
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;
Output
    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.

SQL
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;
Output
 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.

SQL
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;
Output
   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.

SQL
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;
Output
 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.

SQL
SELECT g AS missing FROM generate_series(1, 10) g
EXCEPT
SELECT n FROM seq
ORDER BY 1;
Output
 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.

SQL
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;
Output
  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.

SQL
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;
Output
 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.

SQL
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;
Output
 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.

SQL
UPDATE people SET sex = CASE sex WHEN 'M' THEN 'F' ELSE 'M' END;
SELECT * FROM people ORDER BY id;
Output
 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.

SQL
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;
Output
 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.

SQL
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;
Output
 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).

SQL
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;
Output
 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.

SQL
SELECT name, hired_on
FROM employees
WHERE hired_on > DATE '2024-06-01' - INTERVAL '24 months'
ORDER BY hired_on;
Output
 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.

SQL
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;
Output
 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).

SQL
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;
Output
  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.

SQL
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;
Output
     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)?

SQL
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;
Output
   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.

SQL
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;
Output
   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).

SQL
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;
Output
 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).

SQL
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;
Output
  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.

SQL
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;
Output
 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.

SQL
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;
Output
 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, or DENSE_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() or LEAD/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.