Top SQL Interview Questions
for Data Analysts
The 15 most asked SQL questions in data analyst interviews — each with a real dataset, a complete answer, a step-by-step explanation, and the follow-up cross-questions interviewers actually ask.
All questions use three tables: employees, orders, and products. The schema and sample data are shown before each question where relevant. Questions build in difficulty — start from Q1 even if you are experienced, as interviewers use early questions to warm up before harder cross-questions.
Find the 2nd Highest Salary
| emp_id | emp_name | department | salary | manager_id |
|---|---|---|---|---|
| 1 | Priya Sharma | Analytics | 95000 | 5 |
| 2 | Rohan Mehta | Engineering | 120000 | 5 |
| 3 | Sneha Gupta | Analytics | 82000 | 1 |
| 4 | Arjun Singh | Marketing | 67000 | 6 |
| 5 | Neha Joshi | Engineering | 155000 | — |
| 6 | Vikram Rao | Marketing | 98000 | 5 |
| 7 | Ananya Das | Analytics | 95000 | 1 |
| 8 | Karan Patel | Engineering | 120000 | 2 |
SELECT MAX(salary) AS second_highest_salary FROM employees WHERE salary < (SELECT MAX(salary) FROM employees); -- Result: 120000
SELECT salary AS second_highest_salary
FROM (
SELECT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk = 2
LIMIT 1;
-- Result: 120000rnk = 2 to rnk = N.🎯 Cross-question: What if there are ties at the 2nd highest salary?
SELECT emp_name, salary FROM employees WHERE salary = ( SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees) ); -- Returns: Rohan Mehta, Karan Patel — both at 120000
🎯 Cross-question: How would you find the Nth highest salary (generalised)?
rnk = 2 with rnk = N. For the 3rd highest, use rnk = 3. This is cleaner than nesting N-1 subqueries.🎯 Cross-question: What does DENSE_RANK return if there is only one distinct salary?
RANK vs DENSE_RANK vs ROW_NUMBER
SELECT emp_name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, RANK() OVER (ORDER BY salary DESC) AS rnk, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk FROM employees;
| emp_name | salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| Neha Joshi | 155000 | 1 | 1 | 1 |
| Rohan Mehta | 120000 | 2 | 2 | 2 |
| Karan Patel | 120000 | 3 | 2 | 2 |
| Vikram Rao | 98000 | 4 | 4 | 3 |
| Priya Sharma | 95000 | 5 | 5 | 4 |
| Ananya Das | 95000 | 6 | 5 | 4 |
| Sneha Gupta | 82000 | 7 | 7 | 5 |
| Arjun Singh | 67000 | 8 | 8 | 6 |
- ROW_NUMBER — always unique, no ties. Assign an arbitrary tiebreaker internally.
- RANK — ties get the same rank, but the next rank skips (2, 2, 4). Gaps appear.
- DENSE_RANK — ties get the same rank, next rank does NOT skip (2, 2, 3). No gaps.
🎯 Cross-question: When would you use ROW_NUMBER instead of RANK?
🎯 Cross-question: How do you rank within each department separately?
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rankThis resets the rank counter for each department independently.
Find Duplicate Records
| order_id | customer_id | product_id | order_date | amount |
|---|---|---|---|---|
| 101 | C001 | P01 | 2026-10-01 | 2500 |
| 102 | C002 | P02 | 2026-10-02 | 1800 |
| 103 | C001 | P01 | 2026-10-01 | 2500 |
| 104 | C003 | P03 | 2026-10-03 | 4200 |
| 105 | C002 | P02 | 2026-10-02 | 1800 |
| 106 | C004 | P01 | 2026-10-04 | 2500 |
-- Step 1: Find which combinations are duplicated SELECT customer_id, product_id, order_date, COUNT(*) AS occurrences FROM orders GROUP BY customer_id, product_id, order_date HAVING COUNT(*) > 1; -- Shows: C001/P01/2026-10-01 appears 2 times -- C002/P02/2026-10-02 appears 2 times -- Step 2: Return all duplicate rows with their order_id SELECT * FROM orders WHERE (customer_id, product_id, order_date) IN ( SELECT customer_id, product_id, order_date FROM orders GROUP BY customer_id, product_id, order_date HAVING COUNT(*) > 1 ) ORDER BY customer_id, order_date;
🎯 Cross-question: How would you keep only one row and delete the rest?
-- Keep the row with the lowest order_id, delete the rest DELETE FROM orders WHERE order_id NOT IN ( SELECT MIN(order_id) FROM orders GROUP BY customer_id, product_id, order_date );In databases that don't allow DELETE with a subquery referencing the same table, wrap the subquery in a CTE or use ROW_NUMBER (see Q11).
🎯 Cross-question: How do you find duplicates across ALL columns?
Running Total (Cumulative Sum)
SELECT
order_id,
customer_id,
order_date,
amount,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY order_date;| order_id | customer_id | order_date | amount | running_total |
|---|---|---|---|---|
| 101 | C001 | 2026-10-01 | 2500 | 2500 |
| 103 | C001 | 2026-10-01 | 2500 | 5000 |
| 102 | C002 | 2026-10-02 | 1800 | 6800 |
| 105 | C002 | 2026-10-02 | 1800 | 8600 |
| 104 | C003 | 2026-10-03 | 4200 | 12800 |
| 106 | C004 | 2026-10-04 | 2500 | 15300 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is explicit and safe. The default (RANGE) can produce unexpected results when there are ties on the ORDER BY column — both rows with the same date get the cumulative sum up to all rows with that date, not just up to themselves.🎯 Cross-question: How do you reset the running total for each customer?
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)This resets the counter to 0 for each new customer.🎯 Cross-question: How is SUM() OVER() different from GROUP BY SUM()?
Find Employees Earning More Than Their Manager
-- Self-join: join the table to itself SELECT e.emp_name AS employee, e.salary AS employee_salary, m.emp_name AS manager, m.salary AS manager_salary FROM employees e JOIN employees m ON e.manager_id = m.emp_id WHERE e.salary > m.salary; -- Result: Priya Sharma (95k) earns less than Neha Joshi (155k) — no result here -- But if Sneha Gupta earned 100k with Priya (95k) as manager, she would appear
🎯 Cross-question: What type of join is a self-join? Does it need a special keyword?
🎯 Cross-question: How do you find the entire reporting chain (recursive hierarchy)?
WITH RECURSIVE hierarchy AS ( SELECT emp_id, emp_name, manager_id, 0 AS depth FROM employees WHERE manager_id IS NULL -- top level UNION ALL SELECT e.emp_id, e.emp_name, e.manager_id, h.depth + 1 FROM employees e JOIN hierarchy h ON e.manager_id = h.emp_id ) SELECT * FROM hierarchy ORDER BY depth;This is a common senior-level follow-up question.
Top N Employees Per Department
SELECT emp_name, department, salary
FROM (
SELECT
emp_name,
department,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS dept_rank
FROM employees
) ranked
WHERE dept_rank <= 2
ORDER BY department, salary DESC;| emp_name | department | salary |
|---|---|---|
| Priya Sharma | Analytics | 95000 |
| Ananya Das | Analytics | 95000 |
| Neha Joshi | Engineering | 155000 |
| Rohan Mehta | Engineering | 120000 |
| Karan Patel | Engineering | 120000 |
| Vikram Rao | Marketing | 98000 |
| Arjun Singh | Marketing | 67000 |
🎯 Cross-question: What is the difference between using DENSE_RANK vs ROW_NUMBER for top-N?
🎯 Cross-question: How would you get the bottom 2 instead of top 2?
dept_rank >= (MAX(dept_rank) - 1) which is more complex — simpler to just flip the sort direction.Month-over-Month Revenue Change with LAG
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY 1
),
with_prev AS (
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue
FROM monthly_revenue
)
SELECT
TO_CHAR(month, 'Mon YYYY') AS month,
revenue,
prev_month_revenue,
ROUND(
100.0 * (revenue - prev_month_revenue) / prev_month_revenue, 1
) AS mom_change_pct
FROM with_prev
ORDER BY month;🎯 Cross-question: What is the difference between LAG and LEAD?
🎯 Cross-question: How do you handle NULL for the first month (no previous period)?
LAG(revenue, 1, 0) returns 0 for the first month. Or use COALESCE(LAG(revenue,1) OVER (...), 0).Customers Who Never Placed an Order
| customer_id | customer_name | city | signup_date |
|---|---|---|---|
| C001 | Amit Verma | Noida | 2026-01-15 |
| C002 | Riya Kapoor | Delhi | 2026-02-20 |
| C003 | Suresh Nair | Mumbai | 2026-03-10 |
| C004 | Pooja Tiwari | Noida | 2026-04-05 |
| C005 | Dev Sharma | Gurgaon | 2026-05-12 |
-- Method 1: LEFT JOIN + NULL check (most readable) SELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.customer_id IS NULL; -- Method 2: NOT IN subquery SELECT customer_id, customer_name FROM customers WHERE customer_id NOT IN ( SELECT DISTINCT customer_id FROM orders WHERE customer_id IS NOT NULL -- ← CRITICAL: NOT IN fails silently if NULLs exist ); -- Method 3: NOT EXISTS (best performance on large tables) SELECT customer_id, customer_name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ); -- Result: C005 (Dev Sharma) — no orders in the orders table
WHERE customer_id IS NOT NULL inside a NOT IN subquery. Interviewers specifically test for this.🎯 Cross-question: Which method is fastest and why?
🎯 Cross-question: What is the difference between NOT IN and NOT EXISTS?
Customers Who Ordered in at Least 3 Consecutive Months
WITH customer_months AS (
-- Distinct months each customer ordered in
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS order_month
FROM orders
),
with_prev AS (
SELECT
customer_id,
order_month,
LAG(order_month, 1) OVER (PARTITION BY customer_id ORDER BY order_month) AS prev1,
LAG(order_month, 2) OVER (PARTITION BY customer_id ORDER BY order_month) AS prev2
FROM customer_months
)
SELECT DISTINCT customer_id
FROM with_prev
WHERE order_month = prev1 + INTERVAL '1 month'
AND order_month = prev2 + INTERVAL '2 months';
-- A customer appears only if they have 3 consecutive months🎯 Cross-question: How would you find customers who ordered every month for the past 6 months?
SELECT customer_id
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY customer_id
HAVING COUNT(DISTINCT DATE_TRUNC('month', order_date)) = 6;This counts distinct active months and checks if the customer has exactly 6 — meaning no month was skipped. Adjust the INTERVAL and the HAVING count together.Pivot Table Using CASE WHEN
SELECT
e.department,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = '2026-10-01' THEN o.amount ELSE 0 END) AS oct_revenue,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = '2026-11-01' THEN o.amount ELSE 0 END) AS nov_revenue,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = '2026-12-01' THEN o.amount ELSE 0 END) AS dec_revenue,
SUM(o.amount) AS total_revenue
FROM orders o
JOIN employees e ON o.customer_id = e.emp_id::text -- join via employee who placed order
GROUP BY e.department
ORDER BY total_revenue DESC;🎯 Cross-question: How do you un-pivot (melt) a wide table into a long table?
SELECT customer_id, 'oct_revenue' AS month, oct_revenue AS amount FROM wide_table UNION ALL SELECT customer_id, 'nov_revenue', nov_revenue FROM wide_table UNION ALL SELECT customer_id, 'dec_revenue', dec_revenue FROM wide_table;PostgreSQL also has UNNEST with array syntax for a cleaner approach.
Delete Duplicates — Keep One Row
-- The ROW_NUMBER approach (works in PostgreSQL, SQL Server, BigQuery)
WITH ranked AS (
SELECT
order_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id, product_id, order_date
ORDER BY order_id ASC -- keep the lowest order_id
) AS rn
FROM orders
)
DELETE FROM orders
WHERE order_id IN (
SELECT order_id FROM ranked WHERE rn > 1
);
-- Verify: run SELECT COUNT(*) FROM orders before and after🎯 Cross-question: How do you do this in MySQL, which doesn't support DELETE with a CTE?
DELETE FROM orders
WHERE order_id NOT IN (
SELECT min_id FROM (
SELECT MIN(order_id) AS min_id
FROM orders
GROUP BY customer_id, product_id, order_date
) AS keepers
);MySQL doesn't allow you to reference the table you're deleting from in a subquery directly, so you need the double-nesting.Year-over-Year Revenue Growth
WITH monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
EXTRACT(YEAR FROM order_date) AS yr,
EXTRACT(MONTH FROM order_date) AS mo,
SUM(amount) AS revenue
FROM orders
GROUP BY 1, 2, 3
),
yoy AS (
SELECT
cur.month,
cur.revenue AS current_revenue,
prev.revenue AS prev_year_revenue,
ROUND(
100.0 * (cur.revenue - prev.revenue) / prev.revenue, 1
) AS yoy_growth_pct
FROM monthly cur
LEFT JOIN monthly prev
ON cur.mo = prev.mo
AND cur.yr = prev.yr + 1 -- same calendar month, one year earlier
)
SELECT * FROM yoy ORDER BY month;🎯 Cross-question: How is YoY different from MoM? When would you use each?
Rolling 3-Month Average Revenue
WITH monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY 1
)
SELECT
TO_CHAR(month, 'Mon YYYY') AS month,
revenue,
ROUND(
AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 0
) AS rolling_3m_avg
FROM monthly
ORDER BY month;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW.🎯 Cross-question: What is the difference between ROWS and RANGE in the window frame?
First and Last Order Date Per Customer
-- Simple GROUP BY approach SELECT customer_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order, COUNT(order_id) AS total_orders, SUM(amount) AS total_spend, CURRENT_DATE - MAX(order_date) AS days_since_last_order FROM orders GROUP BY customer_id ORDER BY total_spend DESC;
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date ASC) AS rn_first,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn_last
FROM orders
)
SELECT
customer_id,
MAX(CASE WHEN rn_first = 1 THEN order_date END) AS first_order_date,
MAX(CASE WHEN rn_first = 1 THEN amount END) AS first_order_amount,
MAX(CASE WHEN rn_last = 1 THEN order_date END) AS last_order_date,
MAX(CASE WHEN rn_last = 1 THEN amount END) AS last_order_amount
FROM ranked
GROUP BY customer_id;🎯 Cross-question: Why can't you just do MIN(amount) to get the first order's amount?
Gaps in Login Dates (Gaps & Islands Problem)
| user_id | login_date |
|---|---|
| U001 | 2026-10-01 |
| U001 | 2026-10-02 |
| U001 | 2026-10-03 |
| U001 | 2026-10-06 |
| U001 | 2026-10-07 |
| U002 | 2026-10-01 |
| U002 | 2026-10-04 |
WITH
-- Assign a sequential row number per user per date
ranked AS (
SELECT
user_id,
login_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn
FROM (
SELECT DISTINCT user_id, login_date FROM user_logins
) deduped
),
-- The island key: login_date - rn stays constant within a consecutive streak
-- because consecutive dates decrease the gap by 1, and rn increases by 1
island_key AS (
SELECT
user_id,
login_date,
login_date - CAST(rn AS INT) AS grp -- constant within each consecutive run
FROM ranked
),
-- Count streak lengths
streaks AS (
SELECT
user_id,
MIN(login_date) AS streak_start,
MAX(login_date) AS streak_end,
COUNT(*) AS streak_length
FROM island_key
GROUP BY user_id, grp
)
SELECT
user_id,
streak_start,
streak_end,
streak_length AS longest_streak
FROM streaks
WHERE (user_id, streak_length) IN (
SELECT user_id, MAX(streak_length) FROM streaks GROUP BY user_id
)
ORDER BY user_id;
-- U001: Oct 1–3 = 3 days, Oct 6–7 = 2 days → longest = 3
-- U002: Oct 1 = 1 day, Oct 4 = 1 day → longest = 1🎯 Cross-question: How would you find the GAPS (days not logged in) instead of the streaks?
SELECT
user_id,
prev_login + INTERVAL '1 day' AS gap_start,
login_date - INTERVAL '1 day' AS gap_end,
login_date - prev_login - 1 AS gap_days
FROM (
SELECT
user_id,
login_date,
LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date) AS prev_login
FROM (SELECT DISTINCT user_id, login_date FROM user_logins) d
) lagged
WHERE login_date - prev_login > 1;Each row where the gap between this login and the previous is more than 1 day is a gap.Quick Reference: What Interviewers Are Testing
Practice These Questions Live at EVIKA ACADEMY
Hands-on SQL training with mock interviews, real datasets, and expert feedback. Batches in Noida Sector 51.
Book a Free Demo ClassFrequently Asked Questions
What SQL topics are asked in data analyst interviews?
Window functions (ROW_NUMBER, RANK, LAG/LEAD), self-joins, Nth highest value, duplicate detection, CTEs, running totals, GROUP BY with HAVING, NULL handling, and date calculations are the most common topics.
How do you find the second highest salary in SQL?
Use DENSE_RANK: SELECT salary FROM (SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk FROM employees) t WHERE rnk = 2. This handles ties correctly and generalises to Nth highest.
What is the difference between RANK and DENSE_RANK?
RANK skips numbers after a tie (1, 2, 2, 4). DENSE_RANK never skips (1, 2, 2, 3). Use DENSE_RANK for finding Nth highest values — RANK can skip your target rank entirely.
What is the gaps and islands problem in SQL?
It refers to finding consecutive sequences (islands) and breaks in sequences (gaps) within ordered data. The classic solution uses ROW_NUMBER: for consecutive dates, date − row_number stays constant within each island.