INTERVIEW PREPSQL15 QUESTIONS

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.

Window FunctionsSelf JoinsRankingDuplicatesCTEsRunning TotalsDate Queries
JUMP TO
Q1: 2nd Highest SalaryQ2: RANK vs DENSE_RANKQ3: Duplicate RecordsQ4: Running TotalQ5: Self Join (Manager)Q6: Top N Per GroupQ7: LAG/LEADQ8: Customers Not in OrdersQ9: Consecutive MonthsQ10: Pivot with CASEQ11: Delete DuplicatesQ12: YoY GrowthQ13: Rolling 3-Month AvgQ14: First & Last OrderQ15: Gaps in Dates
📦 Dataset used throughout this guide

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.

1
Very Frequently AskedSubquery / Window Function

Find the 2nd Highest Salary

📋 employees table
emp_idemp_namedepartmentsalarymanager_id
1Priya SharmaAnalytics950005
2Rohan MehtaEngineering1200005
3Sneha GuptaAnalytics820001
4Arjun SinghMarketing670006
5Neha JoshiEngineering155000—
6Vikram RaoMarketing980005
7Ananya DasAnalytics950001
8Karan PatelEngineering1200002
Question: Write a query to find the 2nd highest salary from the employees table.
Method 1 — Subquery (works in all SQL dialects):
SELECT MAX(salary) AS second_highest_salary
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Result: 120000
Method 2 — DENSE_RANK (preferred in modern SQL):
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: 120000
Why DENSE_RANK is better: With the subquery method, if two employees share the max salary, the subquery still finds the correct 2nd value. But for "Nth highest" (3rd, 4th…), DENSE_RANK scales cleanly — just change rnk = 2 to rnk = N.
🎯 Cross-question: What if there are ties at the 2nd highest salary?
Both Rohan Mehta and Karan Patel earn 120,000 — both should be returned. Method 1 returns 120,000 as a value. To return all employees at that salary level:
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)?
Use DENSE_RANK and replace 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?
If all employees have the same salary, DENSE_RANK gives every row rank 1, and a WHERE rnk = 2 returns no rows. Always handle this with a LIMIT or COALESCE to avoid empty results.
2
Very Frequently AskedWindow Functions

RANK vs DENSE_RANK vs ROW_NUMBER

Question: Explain the difference between ROW_NUMBER, RANK, and DENSE_RANK with an example.
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;
📋 Output — notice how ties are handled differently
emp_namesalaryROW_NUMBERRANKDENSE_RANK
Neha Joshi155000111
Rohan Mehta120000222
Karan Patel120000322
Vikram Rao98000443
Priya Sharma95000554
Ananya Das95000654
Sneha Gupta82000775
Arjun Singh67000886
Key rules:
  • 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?
ROW_NUMBER is useful when you need exactly one row per output position — for example, deduplicating a table while keeping one row per customer, or pagination (LIMIT/OFFSET equivalent). Since it assigns a unique number even to ties, it's unpredictable for ranking purposes.
🎯 Cross-question: How do you rank within each department separately?
Add a PARTITION BY clause:
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
This resets the rank counter for each department independently.
3
Very Frequently AskedData Quality

Find Duplicate Records

📋 orders table
order_idcustomer_idproduct_idorder_dateamount
101C001P012026-10-012500
102C002P022026-10-021800
103C001P012026-10-012500
104C003P032026-10-034200
105C002P022026-10-021800
106C004P012026-10-042500
Question: Find all duplicate rows based on customer_id, product_id, and order_date.
-- 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?
Use GROUP BY on all columns: GROUP BY col1, col2, col3, ... HAVING COUNT(*) > 1. If the table has a surrogate key (like order_id), exclude it from the GROUP BY since it's unique by design.
4
Very Frequently AskedWindow Functions

Running Total (Cumulative Sum)

Question: Write a query to show each order's amount and the running total of amount ordered by date.
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;
📋 Output — running_total accumulates with each row
order_idcustomer_idorder_dateamountrunning_total
101C0012026-10-0125002500
103C0012026-10-0125005000
102C0022026-10-0218006800
105C0022026-10-0218008600
104C0032026-10-03420012800
106C0042026-10-04250015300
ROWS vs RANGE: 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?
Add a PARTITION BY:
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()?
GROUP BY collapses rows — one output row per group. SUM() OVER() is a window function: it adds a column to every existing row without collapsing anything. You can use both in the same query.
5
Very Frequently AskedSelf Join

Find Employees Earning More Than Their Manager

Question: Using the employees table (which has a manager_id column pointing to another emp_id), find all employees who earn more than their direct 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
Common mistake: Using a LEFT JOIN here is wrong if you only want employees WHO HAVE a manager and earn more than them. Use INNER JOIN (or just JOIN). Use LEFT JOIN only if you want to include employees with no manager (CEO) in the output.
🎯 Cross-question: What type of join is a self-join? Does it need a special keyword?
A self-join is just a regular JOIN where both sides reference the same table. There is no SELF JOIN keyword — you use a normal JOIN with table aliases (e for employee, m for manager) to distinguish the two copies.
🎯 Cross-question: How do you find the entire reporting chain (recursive hierarchy)?
Use a recursive CTE:
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.
6
Very Frequently AskedWindow Functions

Top N Employees Per Department

Question: Find the top 2 highest-paid employees in each 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;
📋 Output — top 2 per department
emp_namedepartmentsalary
Priya SharmaAnalytics95000
Ananya DasAnalytics95000
Neha JoshiEngineering155000
Rohan MehtaEngineering120000
Karan PatelEngineering120000
Vikram RaoMarketing98000
Arjun SinghMarketing67000
Note: Engineering shows 3 rows for "top 2" because Rohan and Karan both have the same salary at rank 2. DENSE_RANK includes all tied employees — if you want exactly 2 rows per department regardless of ties, use ROW_NUMBER instead.
🎯 Cross-question: What is the difference between using DENSE_RANK vs ROW_NUMBER for top-N?
DENSE_RANK includes all tied employees — you might get more than N rows per group. ROW_NUMBER gives exactly N rows but arbitrarily breaks ties (the choice of which tied row gets rank 1 vs 2 is not deterministic without an additional ORDER BY tiebreaker).
🎯 Cross-question: How would you get the bottom 2 instead of top 2?
Change ORDER BY salary DESC to ORDER BY salary ASC inside the OVER clause. Or use dept_rank >= (MAX(dept_rank) - 1) which is more complex — simpler to just flip the sort direction.
7
Frequently AskedLAG / LEAD

Month-over-Month Revenue Change with LAG

Question: Calculate monthly revenue and the % change vs the previous month.
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;
LAG(column, n, default) looks back n rows in the ordered window. LEAD looks forward. The optional 3rd argument is the default value when there is no previous row (otherwise NULL).
🎯 Cross-question: What is the difference between LAG and LEAD?
LAG accesses the value from a previous row (looking back). LEAD accesses the value from a future row (looking forward). LAG(revenue, 1) gives last month's revenue. LEAD(revenue, 1) gives next month's revenue.
🎯 Cross-question: How do you handle NULL for the first month (no previous period)?
Use COALESCE or the default parameter: LAG(revenue, 1, 0) returns 0 for the first month. Or use COALESCE(LAG(revenue,1) OVER (...), 0).
8
Very Frequently AskedJoins / Set Logic

Customers Who Never Placed an Order

📋 customers table
customer_idcustomer_namecitysignup_date
C001Amit VermaNoida2026-01-15
C002Riya KapoorDelhi2026-02-20
C003Suresh NairMumbai2026-03-10
C004Pooja TiwariNoida2026-04-05
C005Dev SharmaGurgaon2026-05-12
Question: Find all customers who have never placed an order. (3 approaches — know all three.)
-- 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
Critical gotcha with NOT IN: If the subquery returns even one NULL, the entire NOT IN condition returns UNKNOWN (not TRUE) for every row, giving you an empty result. Always add WHERE customer_id IS NOT NULL inside a NOT IN subquery. Interviewers specifically test for this.
🎯 Cross-question: Which method is fastest and why?
NOT EXISTS is generally fastest on large tables because the database can short-circuit — it stops searching as soon as it finds one matching row. LEFT JOIN + NULL can be slower because it has to materialise the full join before filtering. NOT IN is slowest when the subquery is large.
🎯 Cross-question: What is the difference between NOT IN and NOT EXISTS?
NOT IN fails silently with NULLs in the subquery (returns empty result instead of the correct rows). NOT EXISTS handles NULLs correctly — it checks for row existence without comparing values, so NULLs in the subquery don't affect the result.
9
Moderately AskedDate Logic

Customers Who Ordered in at Least 3 Consecutive Months

Question: Find customers who placed at least one order in 3 or more consecutive calendar 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.
10
Very Frequently AskedCASE WHEN / Pivot

Pivot Table Using CASE WHEN

Question: Write a query to show total order amount for each department broken down by month (Oct, Nov, Dec) as separate columns.
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;
SQL PIVOT shorthand: Some databases (SQL Server, Oracle) have a native PIVOT keyword. In PostgreSQL, BigQuery, and MySQL, use CASE WHEN inside SUM/COUNT — this is the universal pattern that works everywhere.
🎯 Cross-question: How do you un-pivot (melt) a wide table into a long table?
Use UNION ALL:
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.
11
Very Frequently AskedData Cleaning

Delete Duplicates — Keep One Row

Question: From the orders table, delete duplicate rows based on customer_id + product_id + order_date, keeping the row with the lowest order_id.
-- 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
Always run a SELECT first with the same CTE to see which rows will be deleted before executing the DELETE. Interviewers look for this cautious habit.
🎯 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.
12
Frequently AskedDate / Growth

Year-over-Year Revenue Growth

Question: Calculate YoY revenue growth percentage by month.
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?
MoM (Month-over-Month) shows short-term trends but is noisy for seasonal businesses. YoY compares the same period across years, removing seasonality. For e-commerce, December MoM always looks great (holiday bump) but YoY tells you if this December is actually better than last December.
13
Moderately AskedWindow Functions

Rolling 3-Month Average Revenue

Question: Calculate a 3-month rolling average of revenue per month.
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 2 PRECEDING AND CURRENT ROW means: current month + the 2 months before it = 3 months total. For a 7-day rolling average on daily data, use ROWS BETWEEN 6 PRECEDING AND CURRENT ROW.
🎯 Cross-question: What is the difference between ROWS and RANGE in the window frame?
ROWS is physical row-based (count N rows before this one). RANGE is value-based (include rows where the ORDER BY value is within N of the current row's value). For dates/timestamps with no ties, both give the same result. For numeric data with ties, they can differ — ROWS is usually what you want.
14
Frequently AskedAggregation

First and Last Order Date Per Customer

Question: For each customer, find the date of their first order, last order, total orders, and total spend.
-- 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;
Follow-up: Also show the amount of their first and last order (not just the date).
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?
MIN(amount) gives the minimum amount across all orders, which is not necessarily the first order's amount. You need to identify the first row by date (ROW_NUMBER) and then extract the amount from that specific row.
15
Hard / Senior LevelGaps & Islands

Gaps in Login Dates (Gaps & Islands Problem)

📋 user_logins table
user_idlogin_date
U0012026-10-01
U0012026-10-02
U0012026-10-03
U0012026-10-06
U0012026-10-07
U0022026-10-01
U0022026-10-04
Question: Find the longest streak of consecutive login days per user.
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
The key insight: For consecutive integers (or dates), value − row_number remains constant across the entire island. This is the classic "gaps and islands" technique. Memorise it — it appears in senior data analyst and data engineer interviews.
🎯 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

Window Functions
Questions: Q2, Q4, Q6, Q7, Q13
PARTITION BY, ORDER BY in OVER, ROWS frame
Self / Complex Joins
Questions: Q5, Q8
Table alias discipline, NULL awareness
CTEs vs Subqueries
Questions: Q4, Q9, Q11, Q15
Readability, can you refactor a subquery into CTE?
NULL handling
Questions: Q8, Q7
NOT IN with NULLs, COALESCE, IS NULL vs = NULL
Date arithmetic
Questions: Q7, Q9, Q12, Q14
DATE_TRUNC, EXTRACT, INTERVAL arithmetic
Performance awareness
Questions: Q8, Q11
NOT EXISTS > NOT IN, ROW_NUMBER for dedup

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 Class

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

← Back to all articles
🎓 Free Demo Class — Online & Offline · Noida Sector 51