SQL Interview Questions for Data Engineers - Real Scenarios, Real Answers
Master SQL the way interviewers actually ask it. This course covers the most commonly tested SQL concepts for data engineer and data analyst roles, from window functions and CTEs to joins, aggregations, query optimisation, and real-world data reconciliation scenarios. Every question is written the way a real interviewer frames it, with a structured thinking approach before any code, clean solutions across SQL Server, PostgreSQL, and MySQL, and sharp answers to every follow-up question. No textbook theory. No syntax dumps. Just the patterns that actually come up in interviews at product companies, startups, and data-first teams.
Course Topics
Finding Repeat Customers
The Interviewer Says
We have an orders table from our e-commerce platform. The business team wants to identify loyal customers, specifically, customers who have placed more than one order. For each of those customers, I want to see their total spend and a collected list of all products they purchased. Walk me through how you would solve this.
Input Table: orders
| order_id | customer_id | product | amount | order_date |
|---|---|---|---|---|
| 1001 | C01 | Laptop | 75000 | 2026-01-05 |
| 1002 | C02 | Phone | 25000 | 2026-01-05 |
| 1003 | C01 | Headphones | 3000 | 2026-01-06 |
| 1004 | C03 | Laptop | 75000 | 2026-01-07 |
| 1005 | C02 | Tablet | 30000 | 2026-01-08 |
| 1006 | C03 | Charger | 1500 | 2026-01-08 |
How to Handle This in the Interview
Before writing anything, think like this: Let me break this into steps before I write the query.
Step 1: Understand the filter condition
- More than one order means we need to group by customer and count rows. Only customers with COUNT > 1 qualify.
Step 2: Identify the aggregations needed
- Total spend =
SUM(amount). Product list =STRING_AGG(product, ', ')in SQL Server orARRAY_AGG(product)in PostgreSQL. Both require GROUP BY oncustomer_id.
Step 3: Know where to filter
- You cannot use WHERE with aggregate functions, WHERE runs before grouping happens. You need
HAVING COUNT(*) > 1which runs after grouping.
Step 4: Ask about edge cases
- Say: Should the product list include duplicates if the same product was ordered twice, or only distinct values? This shows you think beyond the happy path.
Solution
SQL Server
SELECT customer_id, SUM(amount) AS total_spend, STRING_AGG(product, ', ') AS products_bought FROM orders GROUP BY customer_id HAVING COUNT(*) > 1 ORDER BY customer_id;
PostgreSQL
SELECT customer_id, SUM(amount) AS total_spend, ARRAY_AGG(product) AS products_bought FROM orders GROUP BY customer_id HAVING COUNT(*) > 1 ORDER BY customer_id;
MySQL 8+
SELECT customer_id, SUM(amount) AS total_spend, JSON_ARRAYAGG(product) AS products_bought FROM orders GROUP BY customer_id HAVING COUNT(*) > 1 ORDER BY customer_id;
What to say: I am using HAVING instead of WHERE because HAVING filters after aggregation. WHERE cannot reference COUNT values, it runs before any grouping happens. Also I would clarify with the interviewer which database we are using so I can pick the right function for collecting the product list.
Expected Output
| customer_id | total_spend | products_bought |
|---|---|---|
| C01 | 78000 | Laptop, Headphones |
| C02 | 55000 | Phone, Tablet |
| C03 | 76500 | Laptop, Charger |
Follow-up Questions & Answers
Q1. What is the difference between WHERE and HAVING?
WHERE filters rows before any grouping or aggregation happens, it works on individual row values. HAVING filters after GROUP BY so it can reference aggregated values like COUNT, SUM, or AVG. A common mistake is writing WHERE COUNT(*) > 1 which throws an error because COUNT does not exist yet at the WHERE stage. The mental model is: WHERE runs first, then GROUP BY, then HAVING, in that exact order.
Q2. What is the difference between STRING_AGG and GROUP_CONCAT? When would you use each?
STRING_AGG is available in MSSQL (2017+) and PostgreSQL, it concatenates values into a single string with a separator you define. GROUP_CONCAT is the MySQL equivalent. Both do the same job, the only difference is syntax and which platform supports them. In MSSQL you write STRING_AGG(product, ', '). If downstream processing needs to work with individual items as an array, PostgreSQL's ARRAY_AGG is a better choice. But if you just need a readable comma-separated list for a report or output, STRING_AGG is the cleanest option in MSSQL.
Q3. What happens if amount has NULL values, how does SUM handle it?
SQL ignores NULLs in SUM by default, a NULL amount is simply skipped rather than making the total NULL. However it is better practice to handle it explicitly to make intent clear and guard against data quality issues.
SELECT customer_id, SUM(COALESCE(amount, 0)) AS total_spend, STRING_AGG(product, ', ') AS products_bought FROM orders GROUP BY customer_id HAVING COUNT(*) > 1 ORDER BY customer_id;
What to say: COALESCE replaces any NULL amount with 0 before SUM runs. I would also ask the interviewer whether orders with a NULL amount should be included in the product list at all or filtered out entirely, that changes the approach.
Q4. Can you rewrite this using a subquery instead of HAVING?
Yes, (this is a common alternative interviewers ask for to test knowledge of subqueries.)
SELECT customer_id, SUM(amount) AS total_spend, STRING_AGG(product, ', ') AS products_bought FROM orders WHERE customer_id IN ( SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(*) > 1 ) GROUP BY customer_id ORDER BY customer_id;
What to say: Both give the same result but the subquery version scans the table twice, once to find qualifying customers and once to aggregate. The HAVING version does it in a single pass which is more efficient. I prefer HAVING here but it is good to know both.
Q5. If this table had hundreds of millions of rows, how would you optimise this query?
What to say: At that scale I would think about indexing, partitioning, and avoiding full table scans.
Index on customer_id to speed up GROUP BY and lookups
CREATE INDEX idx_orders_customer ON orders(customer_id); -- In MSSQL, partition the table by order_date -- so queries can skip irrelevant date ranges CREATE PARTITION FUNCTION pf_order_date (DATE) AS RANGE RIGHT FOR VALUES ( '2024-01-01', '2025-01-01', '2026-01-01' ); CREATE PARTITION SCHEME ps_order_date AS PARTITION pf_order_date ALL TO ([PRIMARY]);
What to say: Beyond indexing and partitioning, I would push any date filters as early as possible so the engine scans fewer rows before grouping. If this aggregation feeds a dashboard that runs frequently, I would consider a materialised summary table, in MSSQL that means an indexed view or a scheduled stored procedure that writes results to a summary table, rather than running the full aggregation every single time.
Second Highest Salary per Department
The Interviewer Says
We have an employee table with salaries across multiple departments. I want you to write a query to find the employee who earns the second highest salary in each department. Keep in mind there may be employees sharing the same salary. Walk me through how you would approach this.
Input Table: employees
| emp_id | name | dept | salary |
|---|---|---|---|
| E01 | Amit | Finance | 90000 |
| E02 | Sara | Finance | 75000 |
| E03 | Raj | Finance | 75000 |
| E04 | John | IT | 120000 |
| E05 | Priya | IT | 95000 |
| E06 | Tom | IT | 80000 |
| E07 | Nina | HR | 60000 |
| E08 | Mike | HR | 45000 |
| E09 | Zara | HR | 45000 |
How to Handle This in the Interview
Before writing anything, say: Let me think through this carefully because there is a subtle trap here around duplicate salaries.
Step 1: Spot the duplicate salary trap immediately
- If two people share the same salary, are they both considered the same rank? Say: I will use DENSE_RANK so that ties get the same rank and no rank number is skipped. ROW_NUMBER would arbitrarily break ties and give wrong results here.
Step 2: Plan the approach
- Rank employees within each department by salary descending, then filter for rank = 2. This requires a CTE or subquery because you cannot reference a window function alias directly in a WHERE clause.
Step 3: Choose CTE over subquery
- Say: I prefer a CTE here, it makes the logic easier to read and debug compared to a nested subquery.
Step 4: Handle edge cases
- Ask: What should we return if a department has only one distinct salary level, should we return nothing or return NULL? This is a very common trap interviewers set intentionally.
Solution
SQL Server
WITH ranked_salaries AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_salaries WHERE salary_rank = 2 ORDER BY dept;
PostgreSQL
WITH ranked_salaries AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_salaries WHERE salary_rank = 2 ORDER BY dept;
MySQL 8+
WITH ranked_salaries AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_salaries WHERE salary_rank = 2 ORDER BY dept;
The syntax is identical across all three platforms for window functions. The CTE and DENSE_RANK behaviour is standard SQL.
What to say: I partition by department so the ranking resets independently for each department. DENSE_RANK means Sara and Raj both get rank 2 in Finance since they share 75000 — both rows appear in the output which is the correct honest answer. ROW_NUMBER would have arbitrarily picked only one of them.
Expected Output
| emp_id | name | dept | salary |
|---|---|---|---|
| E02 | Sara | Finance | 75000 |
| E03 | Raj | Finance | 75000 |
| E05 | Priya | IT | 95000 |
| E08 | Mike | HR | 45000 |
| E09 | Zara | HR | 45000 |
Follow-up Questions & Answers
Q1. What is the difference between RANK, DENSE_RANK, and ROW_NUMBER? Which one is correct here and why?
All three assign a number to each row within a partition but handle ties differently.
ROW_NUMBERgives every row a unique number even if values are identical, tie-breaking is arbitrary.RANKgives tied rows the same number but skips the next rank, two rows tied at rank 1 make the next row rank 3, skipping rank 2.DENSE_RANKgives tied rows the same number and does not skip, two rows tied at rank 1 make the next row rank 2. For this problem DENSE_RANK is correct because we want tied salaries to share the same rank and we do not want any rank numbers skipped, which would cause us to miss real second-highest salaries.
Q2. Can you solve this without window functions?
Yes, using a correlated subquery, but this is much less efficient and harder to read. Good to know as an alternative if the interviewer asks.
SELECT e1.emp_id, e1.name, e1.dept, e1.salary FROM employees e1 WHERE e1.salary = ( SELECT MAX(e2.salary) FROM employees e2 WHERE e2.dept = e1.dept AND e2.salary < ( SELECT MAX(e3.salary) FROM employees e3 WHERE e3.dept = e1.dept ) ) ORDER BY e1.dept;
What to say: This correlated subquery works but scans the table multiple times for each row which is very inefficient on large datasets. The window function approach with DENSE_RANK only requires one pass, I would always prefer that in production.
Q3. What if the interviewer asks for the Nth highest salary instead of just the second?
The CTE approach generalises very cleanly, just change the filter value.
WITH ranked_salaries AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_salaries WHERE salary_rank = 3 -- change this to any N ORDER BY dept;
What to say: This is why window functions are powerful,changing from second highest to Nth highest is just changing one number in the WHERE clause. With the correlated subquery approach you would have to nest it N times which becomes unmanageable very quickly.
Q4. What if a department has only one employee? what does your query return?
It returns no row for that department which is the correct behaviour, there is no second highest salary to show. However if the business requirement says return NULL for those departments, handle it with a LEFT JOIN.
WITH all_depts AS ( SELECT DISTINCT dept FROM employees ), ranked_salaries AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT d.dept, r.emp_id, r.name, r.salary FROM all_depts d LEFT JOIN ranked_salaries r ON d.dept = r.dept AND r.salary_rank = 2 ORDER BY d.dept;
What to say: Always clarify with the interviewer whether missing departments should appear as NULL rows or be omitted entirely, both are valid but the business requirement decides which is correct.
Detecting Status Changes in an Order Pipeline
The Interviewer Says
We have a logistics pipeline that tracks the status of each order over time. The operations team wants to know exactly when an order status changed, for example when it moved from Ordered to Dispatched or from Dispatched to Shipped. I do not want to see rows where the status stayed the same as the previous day. Write a query to return only the rows where a status change happened. Walk me through your approach.
Input Table: order_tracking
| order_id | status_date | status |
|---|---|---|
| 1001 | 2024-01-01 | Ordered |
| 1001 | 2024-01-02 | Dispatched |
| 1001 | 2024-01-03 | Dispatched |
| 1001 | 2024-01-04 | Shipped |
| 1001 | 2024-01-05 | Shipped |
| 1001 | 2024-01-06 | Delivered |
| 1002 | 2024-01-01 | Ordered |
| 1002 | 2024-01-02 | Dispatched |
| 1002 | 2024-01-03 | Shipped |
How to Handle This in the Interview
Before writing anything, think like: Let me think about how to compare each row with the previous row, that is the key operation here.
Step 1: Identify the core operation
- We need to compare each row's status with the status of the previous row for the same order. This is a classic use case for the
LAGwindow function, it lets you look at the value from the previous row without a self join.
Step 2: Partition and order correctly
- We must partition by
order_idso that the LAG lookup stays within the same order and does not bleed across orders. We order bystatus_dateso the previous row is always the chronologically previous one.
Step 3: Handle the first row of each order
- The very first row of each order has no previous row, LAG returns NULL for it. A NULL previous status means this is a new order starting, so we include it as a status change. Say: I will use COALESCE or an IS NULL check to include the first row.
Step 4: Filter in a CTE
- We cannot filter on a window function result directly in WHERE in the same SELECT, wrap it in a CTE or subquery first, then filter.
Solution
SQL Server
WITH status_changes AS ( SELECT order_id, status_date, status, LAG(status) OVER (PARTITION BY order_id ORDER BY status_date) AS prev_status FROM order_tracking ) SELECT order_id, status_date, status FROM status_changes WHERE prev_status IS NULL OR status <> prev_status ORDER BY order_id, status_date;
PostgreSQL
WITH status_changes AS ( SELECT order_id, status_date, status, LAG(status) OVER (PARTITION BY order_id ORDER BY status_date) AS prev_status FROM order_tracking ) SELECT order_id, status_date, status FROM status_changes WHERE prev_status IS NULL OR status <> prev_status ORDER BY order_id, status_date;
MySQL 8+
WITH status_changes AS ( SELECT order_id, status_date, status, LAG(status) OVER (PARTITION BY order_id ORDER BY status_date) AS prev_status FROM order_tracking ) SELECT order_id, status_date, status FROM status_changes WHERE prev_status IS NULL OR status <> prev_status ORDER BY order_id, status_date;
The syntax is identical across all three platforms for LAG and CTEs.
What to say: I use LAG to look at the previous row's status within the same order. If the previous status is NULL it means this is the first row for that order so I include it. If the previous status is different from the current status it means a change happened so I include that too. Any row where the status is the same as the previous row gets filtered out.
Expected Output
| order_id | status_date | status |
|---|---|---|
| 1001 | 2024-01-01 | Ordered |
| 1001 | 2024-01-02 | Dispatched |
| 1001 | 2024-01-04 | Shipped |
| 1001 | 2024-01-06 | Delivered |
| 1002 | 2024-01-01 | Ordered |
| 1002 | 2024-01-02 | Dispatched |
| 1002 | 2024-01-03 | Shipped |
Follow-up Questions & Answers
Q1. What is the LAG function and how does it work?
LAG is a window function that lets you access the value of a column from a previous row within the same partition without writing a self join. It takes up to three arguments, the column name, the offset which defaults to 1 meaning one row back, and a default value to return when there is no previous row instead of NULL. For example LAG(status, 1, 'No Previous Status') would return the string 'No Previous Status' for the very first row of each partition instead of NULL. It is one of the most commonly used window functions in data engineering interviews because comparing a row to its neighbour is an extremely common real world problem.
Q2. What is the difference between LAG and LEAD?
LAG looks backwards, it fetches the value from a previous row. LEAD looks forwards, it fetches the value from a following row. Both take the same arguments: column, offset, and default. In this question we use LAG because we want to compare the current status with what came before it. If the requirement was instead find the date when each status will change next, we would use LEAD to look at the next row's status. A good way to remember it: LAG = look back, LEAD = look ahead.
Q3. What happens if two status updates happen on the same date for the same order?
This is an important edge case to raise. If status_date is not unique per order, the ORDER BY inside the window function becomes ambiguous, two rows on the same date could be ordered either way and LAG would give inconsistent results.
-- If there is a timestamp column available, use it instead WITH status_changes AS ( SELECT order_id, status_date, status, LAG(status) OVER ( PARTITION BY order_id ORDER BY status_date, status_timestamp -- use timestamp to break ties ) AS prev_status FROM order_tracking ) SELECT order_id, status_date, status FROM status_changes WHERE prev_status IS NULL OR status <> prev_status ORDER BY order_id, status_date;
What to say: I would always ask the interviewer whether status_date is guaranteed to be unique per order. If not I would look for a timestamp column or a sequence ID to use as a tiebreaker in the ORDER BY. Relying on ambiguous ordering in window functions leads to non-deterministic results which is a serious data quality issue in production.
Q4. Can you solve this without LAG using a self join?
Yes, before window functions existed this was the standard approach. It is less readable and less efficient but good to know.
SELECT t1.order_id, t1.status_date, t1.status FROM order_tracking t1 LEFT JOIN order_tracking t2 ON t1.order_id = t2.order_id AND t2.status_date = ( SELECT MAX(t3.status_date) FROM order_tracking t3 WHERE t3.order_id = t1.order_id AND t3.status_date < t1.status_date ) WHERE t2.status IS NULL OR t1.status <> t2.status ORDER BY t1.order_id, t1.status_date;
What to say: This self join approach works but it is significantly more expensive — for each row it runs a correlated subquery to find the previous date. On a large table with millions of status updates this would be very slow. LAG solves the same problem in a single scan which is why window functions were introduced. I would only use the self join approach if the database does not support window functions.
Q5. How would you also capture what the status changed FROM and TO in the output?
The interviewer may ask you to show both the old and new status in the same row. Since we already have prev_status in the CTE, just include it in the SELECT.
WITH status_changes AS ( SELECT order_id, status_date, status, LAG(status) OVER (PARTITION BY order_id ORDER BY status_date) AS prev_status FROM order_tracking ) SELECT order_id, status_date, COALESCE(prev_status, 'New Order') AS changed_from, status AS changed_to FROM status_changes WHERE prev_status IS NULL OR status <> prev_status ORDER BY order_id, status_date;
What to say: I use COALESCE on prev_status so that the first row of each order shows 'New Order' instead of NULL in the changed_from column — this makes the output much more readable for the business team.
Customers Who Bought Every Product in a Category
The Interviewer Says
We run a subscription box service and want to identify our most engaged customers. I am giving you a purchases table and a products table. Write a query to find customers who have purchased every single product that exists in the products table, not just some of them, all of them. This is a relational division problem, take your time to think through it.
Input Table: products
| product_id | product_name |
|---|---|
| P01 | Yoga Mat |
| P02 | Protein Bar |
| P03 | Water Bottle |
Input Table: purchases
| customer_id | product_id |
|---|---|
| C01 | P01 |
| C01 | P02 |
| C01 | P03 |
| C02 | P01 |
| C02 | P02 |
| C03 | P01 |
| C03 | P02 |
| C03 | P03 |
How to Handle This in the Interview
Before writing anything, think like: This looks like a relational division problem. I need customers whose purchased product set fully covers the entire products table.
Step 1: Recognise the pattern
- The phrase "bought every product" is the classic signal for relational division. Do not reach for a simple GROUP BY with a count comparison right away, explain why that is risky.
Step 2: Think about the naive approach and its flaw
- Say: One way is to count distinct products per customer and compare it to the total count of products. But this breaks if a customer buys a product that no longer exists in the products table, or if there is any duplicate or mismatch, counts can match by coincidence even if the actual sets differ.
Step 3: Use the NOT EXISTS double negation approach
- Say: The safest approach is to find customers where there does not exist any product in the products table that the customer has not purchased. This guarantees set coverage rather than relying on counts.
Step 4: Mention the alternative count-based approach as a simpler fallback
- Acknowledge that if the interviewer wants a simpler, faster-to-write solution and the data is clean, the count comparison is acceptable, but always state the trade-off.
Solution
SQL Server
SELECT DISTINCT p.customer_id FROM purchases p WHERE NOT EXISTS ( SELECT 1 FROM products pr WHERE NOT EXISTS ( SELECT 1 FROM purchases p2 WHERE p2.customer_id = p.customer_id AND p2.product_id = pr.product_id ) );
PostgreSQL
SELECT DISTINCT p.customer_id FROM purchases p WHERE NOT EXISTS ( SELECT 1 FROM products pr WHERE NOT EXISTS ( SELECT 1 FROM purchases p2 WHERE p2.customer_id = p.customer_id AND p2.product_id = pr.product_id ) );
MySQL 8+
SELECT DISTINCT p.customer_id FROM purchases p WHERE NOT EXISTS ( SELECT 1 FROM products pr WHERE NOT EXISTS ( SELECT 1 FROM purchases p2 WHERE p2.customer_id = p.customer_id AND p2.product_id = pr.product_id ) );
The syntax is identical across all three platforms for NOT EXISTS and correlated subqueries.
What to say: I am using a double NOT EXISTS pattern, sometimes called the "double negative" trick. The inner NOT EXISTS finds any product that this specific customer has not purchased. The outer NOT EXISTS then checks that no such missing product exists, meaning the customer has purchased everything. This is the textbook way to express relational division in SQL.
Expected Output
| customer_id |
|---|
| C01 |
| C03 |
Follow-up Questions & Answers
Q1. What is relational division and why does this problem qualify as one?
Relational division is a relational algebra operation where you find rows in one table that are associated with every single row in another table, the opposite of a normal join which finds matches, division finds complete coverage. This problem qualifies because we are not asking "which customers bought a product", we are asking "which customers bought all products," which requires checking that no product is missing from a customer's purchase history. SQL has no native DIVIDE operator, so it is always expressed using either double NOT EXISTS, or a GROUP BY with HAVING COUNT comparison, or set operations like EXCEPT.
Q2. Can you write this using GROUP BY and HAVING COUNT instead?
Yes, this is the simpler and more common approach when the data is known to be clean, meaning no duplicate purchase rows and the products table is the single source of truth.
SELECT p.customer_id FROM purchases p GROUP BY p.customer_id HAVING COUNT(DISTINCT p.product_id) = (SELECT COUNT(*) FROM products);
What to say: This counts how many distinct products each customer purchased and compares it to the total number of products available. It is shorter and usually faster to execute. The risk is if there are duplicate or orphaned product_id values in purchases that do not exist in the products table, the count could accidentally match even though the actual sets are different. I would only use this approach after confirming with the interviewer that the data is clean, or I would add a join condition to filter purchases down to valid products first.
Q3. How would you solve this using EXCEPT instead?
EXCEPT returns rows present in the first query but not in the second, it is a direct way to express "what is missing."
SELECT customer_id FROM ( SELECT DISTINCT customer_id FROM purchases ) all_customers WHERE NOT EXISTS ( SELECT product_id FROM products EXCEPT SELECT product_id FROM purchases WHERE customer_id = all_customers.customer_id );
What to say: EXCEPT subtracts the customer's purchased products from the full products list. If the result is empty, that means the customer has no missing products, so they bought everything. This is essentially the same logic as the double NOT EXISTS approach but expressed using set operations, which some people find more intuitive to read. Note that EXCEPT is supported in SQL Server and PostgreSQL but MySQL did not support it until version 8.0.31, so I would confirm the MySQL version before using this.
Q4. What if the products table has a product that was discontinued and should be excluded from this check?
This is a great edge case to bring up proactively. If products can be marked inactive, the query should only check against active products, otherwise a customer would be unfairly excluded for not buying something that is no longer sold.
SELECT DISTINCT p.customer_id FROM purchases p WHERE NOT EXISTS ( SELECT 1 FROM products pr WHERE pr.is_active = 1 AND NOT EXISTS ( SELECT 1 FROM purchases p2 WHERE p2.customer_id = p.customer_id AND p2.product_id = pr.product_id ) );
What to say: I added a filter on is_active inside the products subquery so discontinued products are excluded from the coverage check. I would always ask the interviewer whether the products table represents the current active catalog or the full historical catalog, since that changes the correctness of the result.
Q5. How would you optimise this query if both tables were very large?
What to say: The double NOT EXISTS pattern can be expensive because it is a correlated subquery running for every customer-product combination. I would focus on indexing and reducing the search space.
-- Composite index to make the correlated lookup fast CREATE INDEX idx_purchases_customer_product ON purchases(customer_id, product_id); -- Index on products if filtering by active status CREATE INDEX idx_products_active ON products(is_active);
What to say: A composite index on customer_id and product_id together lets the inner correlated subquery use an index seek instead of a table scan, which is critical here since it runs once per customer per product. I would also check the query execution plan to confirm the optimiser is not falling back to nested loop joins over the entire table. If the products table is small, like under a few thousand rows, this query usually performs fine even at large purchase volumes because the inner subquery only needs to scan a small set.
Identifying Gaps in a Sequence
The Interviewer Says
We have a table that logs daily active users for our app. Each row represents a date where at least one event was recorded. The data team suspects there might be missing dates where the pipeline silently failed to load any data. Write a query to find the missing date ranges, meaning gaps in the sequence of dates. This is a classic gaps and islands problem.
Input Table: daily_log
| log_id | log_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-02 |
| 3 | 2024-01-03 |
| 4 | 2024-01-06 |
| 5 | 2024-01-07 |
| 6 | 2024-01-10 |
| 7 | 2024-01-11 |
| 8 | 2024-01-12 |
How to Handle This in the Interview
Before writing anything, think: This is a classic gaps and islands problem. I need to find where consecutive dates break, then express the missing range between them.
Step 1: Identify what defines a gap
- A gap exists between two consecutive rows when the difference between the current date and the previous date is more than one day. If the difference is exactly one day, there is no gap.
Step 2: Get the previous date using LAG
- Use
LAG(log_date)ordered bylog_dateto bring the previous row's date alongside the current row. This lets us calculate the day difference in the same row.
Step 3: Filter where the gap is greater than one day
- Once we have the previous date next to the current date, filter for rows where
DATEDIFFbetween them is greater than 1. The missing range is then the day right after the previous date up to the day right before the current date.
Step 4: Clarify what output format the interviewer wants
- Ask: Should I return the gap as a start and end date range, or list out every individual missing date? This changes whether you need a recursive CTE or a simple SELECT.
Solution
SQL Server
WITH dated_rows AS ( SELECT log_date, LAG(log_date) OVER (ORDER BY log_date) AS prev_date FROM daily_log ) SELECT DATEADD(DAY, 1, prev_date) AS gap_start, DATEADD(DAY, -1, log_date) AS gap_end FROM dated_rows WHERE DATEDIFF(DAY, prev_date, log_date) > 1;
PostgreSQL
WITH dated_rows AS ( SELECT log_date, LAG(log_date) OVER (ORDER BY log_date) AS prev_date FROM daily_log ) SELECT prev_date + INTERVAL '1 day' AS gap_start, log_date - INTERVAL '1 day' AS gap_end FROM dated_rows WHERE log_date - prev_date > 1;
MySQL 8+
WITH dated_rows AS ( SELECT log_date, LAG(log_date) OVER (ORDER BY log_date) AS prev_date FROM daily_log ) SELECT DATE_ADD(prev_date, INTERVAL 1 DAY) AS gap_start, DATE_SUB(log_date, INTERVAL 1 DAY) AS gap_end FROM dated_rows WHERE DATEDIFF(log_date, prev_date) > 1;
What to say: I use LAG to bring the previous log date next to the current row, then check whether the gap between them is more than one day. Wherever the gap exists, I calculate the missing range as the day after the previous date through the day before the current date. The date functions differ slightly across platforms but the core logic using LAG is identical.
Expected Output
| gap_start | gap_end |
|---|---|
| 2024-01-04 | 2024-01-05 |
| 2024-01-08 | 2024-01-09 |
Follow-up Questions & Answers
Q1. What is the gaps and islands problem in general?
Gaps and islands is a well known category of SQL problems where data has a sequence, usually dates or numbers, and you need to either find the missing breaks in that sequence, called gaps, or find the contiguous runs of present values, called islands. This pattern shows up constantly in real data engineering work, finding missing dates in a pipeline, finding consecutive login streaks for a user, or finding continuous periods where a sensor was online. The general technique is the same for both gaps and islands: use LAG or ROW_NUMBER to detect where consecutive values break, then group or filter based on that break point.
Q2. How would you list every individual missing date instead of just the start and end range?
If the interviewer wants each missing date listed individually rather than a range, you need a recursive CTE or a numbers table to generate every date between the gap boundaries.
-- SQL Server using a recursive CTE WITH dated_rows AS ( SELECT log_date, LAG(log_date) OVER (ORDER BY log_date) AS prev_date FROM daily_log ), gaps AS ( SELECT DATEADD(DAY, 1, prev_date) AS gap_start, DATEADD(DAY, -1, log_date) AS gap_end FROM dated_rows WHERE DATEDIFF(DAY, prev_date, log_date) > 1 ), all_missing_dates AS ( SELECT gap_start AS missing_date, gap_end FROM gaps UNION ALL SELECT DATEADD(DAY, 1, missing_date), gap_end FROM all_missing_dates WHERE DATEADD(DAY, 1, missing_date) <= gap_end ) SELECT missing_date FROM all_missing_dates OPTION (MAXRECURSION 1000);
What to say: I expand each gap range into individual dates using a recursive CTE, starting from gap_start and adding one day at a time until I reach gap_end. I would always set a sensible MAXRECURSION limit in SQL Server to avoid an infinite loop if the data has an unexpectedly huge gap, which usually signals a bigger upstream problem worth flagging separately.
Q3. How is this different from an islands problem? Can you find the contiguous date ranges instead?
Islands is the opposite of gaps, instead of finding where the sequence breaks, you find the continuous runs where it does not break. The trick is to subtract a row number from the date, which stays constant for consecutive dates and changes whenever there is a break.
WITH numbered_rows AS ( SELECT log_date, ROW_NUMBER() OVER (ORDER BY log_date) AS rn FROM daily_log ), island_groups AS ( SELECT log_date, DATEADD(DAY, -rn, log_date) AS island_id FROM numbered_rows ) SELECT MIN(log_date) AS island_start, MAX(log_date) AS island_end FROM island_groups GROUP BY island_id ORDER BY island_start;
What to say: Subtracting the row number from the date is a clever trick, for any run of consecutive dates, this subtraction produces the same constant value, effectively giving each island a unique group ID. I then group by that ID and take the min and max date in each group to get the island boundaries. This is a very common follow-up because it tests whether you understand the technique conceptually rather than just memorising one query.
Q4. What if there could be duplicate dates in the daily_log table — does your gap query still work?
This is an important edge case. Duplicate dates do not break the gap detection logic itself since LAG still correctly identifies the previous distinct date in sequence, but duplicates can cause the gap query to evaluate the same gap multiple times if not handled.
WITH distinct_dates AS ( SELECT DISTINCT log_date FROM daily_log ), dated_rows AS ( SELECT log_date, LAG(log_date) OVER (ORDER BY log_date) AS prev_date FROM distinct_dates ) SELECT DATEADD(DAY, 1, prev_date) AS gap_start, DATEADD(DAY, -1, log_date) AS gap_end FROM dated_rows WHERE DATEDIFF(DAY, prev_date, log_date) > 1;
What to say: I add a DISTINCT step before applying LAG so that duplicate log entries on the same date do not get treated as separate rows in the sequence. This keeps the gap detection accurate regardless of how many events were logged on a given day.
Q5. How would you turn this into a monitoring query that runs daily and alerts if today's date itself is missing?
What to say: This is a practical extension, instead of just looking backward at historical gaps, the pipeline needs to also check if data simply has not arrived yet for the most recent expected date.
SELECT CASE WHEN MAX(log_date) < CAST(GETDATE() AS DATE) - 1 THEN 'ALERT: No data loaded for ' + CAST(CAST(GETDATE() AS DATE) - 1 AS VARCHAR) ELSE 'OK' END AS pipeline_status FROM daily_log;
What to say: I compare the most recent log_date against yesterday's date, since today's data may not have fully loaded yet depending on the pipeline schedule. If the max date is older than expected, I raise an alert. In a real production setup I would wrap this in a stored procedure or orchestration tool like Airflow that sends a Slack or email alert rather than just returning a status string.
Pivoting Monthly Sales into Columns
The Interviewer Says
We have a sales table with one row per product per month. The finance team wants a report where each month becomes its own column instead of separate rows, so they can scan across a single row to compare a product's performance month over month. Write a query to pivot this data. Walk me through how you would approach it.
Input Table: monthly_sales
| product_id | sale_month | revenue |
|---|---|---|
| P01 | Jan | 50000 |
| P01 | Feb | 65000 |
| P01 | Mar | 70000 |
| P02 | Jan | 30000 |
| P02 | Feb | 28000 |
| P02 | Mar | 35000 |
How to Handle This in the Interview
Before writing anything, think: Pivoting means turning row values into column headers, I have two ways to do this, a manual CASE WHEN approach or the database's built-in PIVOT syntax. Let me think about which fits better here.
Step 1: Recognise this is a pivot operation
- The shape of the data needs to change from long format, one row per product-month, to wide format, one row per product with a column per month. This is fundamentally different from a normal aggregation.
Step 2: Decide between CASE WHEN and native PIVOT
- Say: CASE WHEN works identically across every SQL platform and is easy to read. Native PIVOT syntax exists in SQL Server but is not standard across PostgreSQL or MySQL, so I would default to CASE WHEN for portability unless the interviewer specifies SQL Server only.
Step 3: Identify the aggregation needed inside each CASE
- Even though there is only one row per product per month here, always wrap the CASE WHEN inside an aggregate function like SUM or MAX. Say: I wrap it in SUM so the query still works correctly even if there were duplicate rows for the same product and month.
Step 4: Ask about dynamic vs static columns
- Ask: Is the list of months fixed, like always Jan through Mar, or does it change dynamically based on what is in the data? That changes whether I can hardcode the columns or need dynamic SQL.
Solution
SQL Server
SELECT product_id, SUM(CASE WHEN sale_month = 'Jan' THEN revenue END) AS Jan, SUM(CASE WHEN sale_month = 'Feb' THEN revenue END) AS Feb, SUM(CASE WHEN sale_month = 'Mar' THEN revenue END) AS Mar FROM monthly_sales GROUP BY product_id ORDER BY product_id;
PostgreSQL
SELECT product_id, SUM(CASE WHEN sale_month = 'Jan' THEN revenue END) AS Jan, SUM(CASE WHEN sale_month = 'Feb' THEN revenue END) AS Feb, SUM(CASE WHEN sale_month = 'Mar' THEN revenue END) AS Mar FROM monthly_sales GROUP BY product_id ORDER BY product_id;
MySQL 8+
SELECT product_id, SUM(CASE WHEN sale_month = 'Jan' THEN revenue END) AS Jan, SUM(CASE WHEN sale_month = 'Feb' THEN revenue END) AS Feb, SUM(CASE WHEN sale_month = 'Mar' THEN revenue END) AS Mar FROM monthly_sales GROUP BY product_id ORDER BY product_id;
The CASE WHEN pivot pattern is identical across all three platforms, this is the most portable way to pivot data in SQL.
What to say: For each month I create a conditional column that only picks up the revenue when sale_month matches, otherwise it is NULL. Wrapping it in SUM collapses the NULLs and keeps the real value, and since I GROUP BY product_id, each product ends up as exactly one row with its revenue spread across the month columns.
Expected Output
| product_id | Jan | Feb | Mar |
|---|---|---|---|
| P01 | 50000 | 65000 | 70000 |
| P02 | 30000 | 28000 | 35000 |
Follow-up Questions & Answers
Q1. How would you do this using SQL Server's native PIVOT syntax instead?
SQL Server has built-in PIVOT syntax which can be more concise, though it is less portable and harder to read for people unfamiliar with it.
SELECT product_id, [Jan], [Feb], [Mar] FROM monthly_sales PIVOT ( SUM(revenue) FOR sale_month IN ([Jan], [Feb], [Mar]) ) AS pivot_table;
What to say: Native PIVOT requires you to hardcode the column names inside the FOR clause using square brackets in SQL Server. It does the same job as the CASE WHEN approach but in a more compact syntax. I personally prefer CASE WHEN for interviews and for portability since PIVOT syntax differs significantly across database platforms, but it is good to show I know both.
Q2. What if the months are not fixed and could be any value, how would you handle dynamic columns?
This requires dynamic SQL, since you cannot have a variable number of columns in standard SQL, the column list must be known at parse time.
DECLARE @columns NVARCHAR(MAX) = ''; DECLARE @sql NVARCHAR(MAX) = ''; SELECT @columns += QUOTENAME(sale_month) + ',' FROM (SELECT DISTINCT sale_month FROM monthly_sales) AS months; SET @columns = LEFT(@columns, LEN(@columns) - 1); SET @sql = ' SELECT product_id, ' + @columns + ' FROM monthly_sales PIVOT ( SUM(revenue) FOR sale_month IN (' + @columns + ') ) AS pivot_table;'; EXEC sp_executesql @sql;
What to say: I first query the distinct list of months that actually exist in the data, build that into a comma separated string of column names, then construct and execute the full PIVOT query as a dynamic string. This is necessary whenever the column set is not known in advance, for example if new months keep getting added over time. I would always be cautious with dynamic SQL around SQL injection if any part of the column list comes from user input rather than trusted internal data.
Q3. How would you reverse this — unpivot wide data back into long format?
The opposite operation, unpivot, is also commonly asked. It is needed when you receive a wide report and need to normalise it back into rows for storage or further processing.
SELECT product_id, sale_month, revenue FROM monthly_sales_wide UNPIVOT ( revenue FOR sale_month IN ([Jan], [Feb], [Mar]) ) AS unpvt;
What to say: UNPIVOT takes the wide columns and stacks them back into rows, generating a sale_month column from the original column names and a revenue column from their values. This is the inverse operation and comes up often when ingesting spreadsheet-style data from business teams who naturally produce wide formats but our warehouse needs normalised long formats for efficient storage and querying.
Q4. What happens if a product has no sales in a particular month, does it show up as 0 or NULL in your pivoted output?
This is an important detail to clarify. With the CASE WHEN approach, if a product genuinely has zero rows for a given month, that month's column will show NULL, not 0, because there is no row to aggregate at all.
SELECT product_id, COALESCE(SUM(CASE WHEN sale_month = 'Jan' THEN revenue END), 0) AS Jan, COALESCE(SUM(CASE WHEN sale_month = 'Feb' THEN revenue END), 0) AS Feb, COALESCE(SUM(CASE WHEN sale_month = 'Mar' THEN revenue END), 0) AS Mar FROM monthly_sales GROUP BY product_id ORDER BY product_id;
What to say: I wrap each SUM in COALESCE to convert NULL into 0 wherever a product has no sales recorded for that month. I would ask the interviewer whether NULL or 0 is the expected business meaning here, NULL technically means no data was recorded, while 0 means sales happened but totalled zero. These carry different meanings to a finance team and the distinction genuinely matters in reporting.
Q5. How would you extend this to also show a total column across all months?
A common real world extension, finance teams almost always want a running total alongside the monthly breakdown.
SELECT product_id, SUM(CASE WHEN sale_month = 'Jan' THEN revenue END) AS Jan, SUM(CASE WHEN sale_month = 'Feb' THEN revenue END) AS Feb, SUM(CASE WHEN sale_month = 'Mar' THEN revenue END) AS Mar, SUM(revenue) AS total_revenue FROM monthly_sales GROUP BY product_id ORDER BY product_id;
What to say: Since SUM(revenue) without a CASE WHEN condition aggregates across all rows for that product regardless of month, it naturally gives the total. I add it as one extra column alongside the pivoted months, this is a simple addition but shows I am thinking about what the business actually needs from the report, not just literally answering the pivot request.
Running Total and Month-over-Month Growth
The Interviewer Says
We track monthly revenue for the company. Finance wants two things in a single report, a running cumulative total of revenue through each month, and the percentage growth compared to the previous month. Write a query that produces both. Walk me through your thinking.
Input Table: monthly_revenue
| revenue_month | revenue |
|---|---|
| 2024-01 | 100000 |
| 2024-02 | 120000 |
| 2024-03 | 90000 |
| 2024-04 | 150000 |
| 2024-05 | 165000 |
How to Handle This in the Interview
Before writing anything, think: This needs two separate window function calculations on the same ordered data, a running sum and a row-to-previous-row comparison. Let me handle them one at a time.
Step 1: Identify the running total operation
- A running total means each row shows the sum of all values up to and including that row. This is done with
SUM(revenue) OVER (ORDER BY revenue_month), without a PARTITION BY since we want one continuous running total across all months.
Step 2: Identify the month-over-month growth operation
- Growth percentage requires comparing the current row to the previous row, which means LAG. The formula is
(current - previous) / previous * 100.
Step 3: Watch out for division by zero
- Say: If the previous month's revenue was 0, dividing by it would error out or return an undefined value. I need to guard against that with a NULLIF or a CASE check.
Step 4: Confirm the default window frame
- Mention: By default, SUM OVER ORDER BY uses a frame of RANGE UNBOUNDED PRECEDING TO CURRENT ROW, which is exactly what a running total needs, I do not need to specify it manually unless I want ROWS instead of RANGE for tie-breaking behaviour.
Solution
SQL Server
SELECT revenue_month, revenue, SUM(revenue) OVER (ORDER BY revenue_month ROWS UNBOUNDED PRECEDING) AS running_total, ROUND((revenue - LAG(revenue) OVER (ORDER BY revenue_month)) * 100.0 / NULLIF(LAG(revenue) OVER (ORDER BY revenue_month), 0), 2) AS mom_growth_pct FROM monthly_revenue ORDER BY revenue_month;
PostgreSQL
SELECT revenue_month, revenue, SUM(revenue) OVER (ORDER BY revenue_month ROWS UNBOUNDED PRECEDING) AS running_total, ROUND((revenue - LAG(revenue) OVER (ORDER BY revenue_month)) * 100.0 / NULLIF(LAG(revenue) OVER (ORDER BY revenue_month), 0), 2) AS mom_growth_pct FROM monthly_revenue ORDER BY revenue_month;
MySQL 8+
SELECT revenue_month, revenue, SUM(revenue) OVER (ORDER BY revenue_month ROWS UNBOUNDED PRECEDING) AS running_total, ROUND((revenue - LAG(revenue) OVER (ORDER BY revenue_month)) * 100.0 / NULLIF(LAG(revenue) OVER (ORDER BY revenue_month), 0), 2) AS mom_growth_pct FROM monthly_revenue ORDER BY revenue_month;
The syntax is identical across all three platforms for this query.
What to say: I use SUM with ROWS UNBOUNDED PRECEDING to build the running total, each row adds itself to the sum of everything before it. For growth, I compute the difference between the current and previous month's revenue using LAG, divide by the previous month, and multiply by 100. I wrap the previous month value in NULLIF so that if it is ever 0, the division returns NULL instead of throwing a divide by zero error.
Expected Output
| revenue_month | revenue | running_total | mom_growth_pct |
|---|---|---|---|
| 2024-01 | 100000 | 100000 | NULL |
| 2024-02 | 120000 | 220000 | 20.00 |
| 2024-03 | 90000 | 310000 | -25.00 |
| 2024-04 | 150000 | 460000 | 66.67 |
| 2024-05 | 165000 | 625000 | 10.00 |
Follow-up Questions & Answers
Q1. Why did you use ROWS UNBOUNDED PRECEDING instead of just leaving the default frame?
The default frame when you write OVER (ORDER BY ...) without specifying ROWS or RANGE is RANGE UNBOUNDED PRECEDING TO CURRENT ROW. RANGE and ROWS behave identically when the ORDER BY column has unique values, but RANGE treats tied values as part of the same logical group, meaning if two rows had the exact same revenue_month, RANGE would sum them together into the same running total step instead of accumulating row by row. I explicitly use ROWS to guarantee row-by-row accumulation regardless of ties, which is safer and more predictable for a running total. I would always prefer being explicit about ROWS versus RANGE rather than relying on the default, since the behaviour difference is a common source of subtle bugs.
Q2. What does NULLIF actually do and why is it the right tool here instead of a CASE statement?
NULLIF(a, b) returns NULL if a equals b, otherwise it returns a. Here NULLIF(previous_revenue, 0) returns NULL whenever the previous month's revenue was exactly 0, which makes the division return NULL automatically instead of erroring. This is functionally equivalent to writing a CASE statement like CASE WHEN previous_revenue = 0 THEN NULL ELSE previous_revenue END, but NULLIF is shorter and more idiomatic for this specific zero-guard pattern. I would use NULLIF whenever the goal is purely "treat this specific value as NULL," and reserve CASE for situations with more than two outcomes or more complex conditional logic.
Q3. How would you modify this to show a 3-month rolling average instead of a running total since the start?
A rolling average over a fixed window, rather than an ever-growing running total, requires bounding the frame to a specific number of preceding rows instead of UNBOUNDED PRECEDING.
SELECT revenue_month, revenue, AVG(revenue) OVER (ORDER BY revenue_month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rolling_3_month_avg FROM monthly_revenue ORDER BY revenue_month;
What to say: I change SUM to AVG and bound the frame to ROWS BETWEEN 2 PRECEDING AND CURRENT ROW, which means the current row plus the two rows before it, three rows total. For the first two months in the data, the average is simply computed over however many rows are actually available since there is nothing before them, SQL does not error out, it just uses what exists. This pattern is extremely common for smoothing out noisy monthly data in real dashboards.
Q4. The running total resets every year in reality, how would you make it reset on January each year instead of continuing forever?
This is a great real world twist. A running total that needs to restart periodically requires PARTITION BY on the reset boundary, here the year extracted from revenue_month.
SELECT revenue_month, revenue, SUM(revenue) OVER (PARTITION BY LEFT(revenue_month, 4) ORDER BY revenue_month ROWS UNBOUNDED PRECEDING) AS running_total_ytd FROM monthly_revenue ORDER BY revenue_month;
What to say: I add a PARTITION BY on the year portion of revenue_month, which resets the running total calculation independently for each year while keeping the row ordering within that year intact. This pattern, partition by year, order by month, is exactly how you would compute a year-to-date running total in a real finance reporting pipeline, and it is a very natural follow-up because PARTITION BY combined with an ordered window is one of the most important concepts to internalise.
Q5. If the interviewer asks you to also flag months where growth dropped by more than 20 percent as a risk alert, how would you do that?
This tests whether you can layer simple business logic on top of a window function result, which usually means wrapping the window function query in a CTE first.
WITH growth_calc AS ( SELECT revenue_month, revenue, ROUND((revenue - LAG(revenue) OVER (ORDER BY revenue_month)) * 100.0 / NULLIF(LAG(revenue) OVER (ORDER BY revenue_month), 0), 2) AS mom_growth_pct FROM monthly_revenue ) SELECT revenue_month, revenue, mom_growth_pct, CASE WHEN mom_growth_pct < -20 THEN 'Risk Alert' ELSE 'Normal' END AS status FROM growth_calc ORDER BY revenue_month;
What to say: I cannot reference mom_growth_pct directly in a CASE statement within the same SELECT as where it is calculated using a window function, so I wrap the calculation in a CTE first, then apply the business rule in the outer query. This two-layer pattern, calculate in a CTE then apply logic outside, is something I use constantly when window function results need further conditional processing.
Customers Who Stopped Purchasing
The Interviewer Says
We want to identify churned customers. A customer is considered churned if they purchased something last year but have not purchased anything this year. I am giving you a single orders table with all transactions across both years. Write a query to find these customers. Walk me through your approach before coding.
Input Table: orders
| order_id | customer_id | order_date |
|---|---|---|
| 1001 | C01 | 2023-03-12 |
| 1002 | C02 | 2023-07-04 |
| 1003 | C03 | 2023-11-20 |
| 1004 | C01 | 2024-01-15 |
| 1005 | C04 | 2023-05-09 |
| 1006 | C02 | 2024-02-28 |
How to Handle This in the Interview
Before writing anything, think: I need to find customers present in last year's purchase set but absent from this year's purchase set. This is essentially an anti join problem.
Step 1: Split the problem into two sets
- First identify all customers who ordered in 2023. Then identify all customers who ordered in 2024. The churned customers are the ones in the first set but not in the second.
Step 2: Pick the right join type
- Say: A LEFT JOIN from last year's customers to this year's customers, filtering where the right side is NULL, gives me exactly the customers who exist in one set but not the other. This pattern is called an anti join.
Step 3: Use derived tables or CTEs to keep it readable
- Building two separate CTEs, one for each year's distinct customers, makes the final join much easier to read than trying to filter dates inline in a single complex query.
Step 4: Ask about the year boundary
- Ask: Should this be calendar year based, January to December, or should it be a rolling twelve month window from today's date? These give very different results and is a common clarification interviewers expect.
Solution
SQL Server
WITH last_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2023 ), this_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2024 ) SELECT ly.customer_id FROM last_year_customers ly LEFT JOIN this_year_customers ty ON ly.customer_id = ty.customer_id WHERE ty.customer_id IS NULL;
PostgreSQL
WITH last_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE EXTRACT(YEAR FROM order_date) = 2023 ), this_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE EXTRACT(YEAR FROM order_date) = 2024 ) SELECT ly.customer_id FROM last_year_customers ly LEFT JOIN this_year_customers ty ON ly.customer_id = ty.customer_id WHERE ty.customer_id IS NULL;
MySQL 8+
WITH last_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2023 ), this_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2024 ) SELECT ly.customer_id FROM last_year_customers ly LEFT JOIN this_year_customers ty ON ly.customer_id = ty.customer_id WHERE ty.customer_id IS NULL;
Only the date extraction function differs slightly between platforms, the join logic itself stays the same everywhere.
What to say: I build two separate CTEs, customers who ordered in 2023 and customers who ordered in 2024. Then I left join the 2023 set to the 2024 set on customer_id. Wherever the right side comes back NULL, that means the customer had no matching order in 2024, so they qualify as churned.
Expected Output
| customer_id |
|---|
| C03 |
| C04 |
Follow-up Questions & Answers
Q1. Can you write this using NOT IN instead of a LEFT JOIN? What are the risks?
Yes, this is a common alternative but it carries a hidden danger with NULL values.
SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2023 AND customer_id NOT IN ( SELECT customer_id FROM orders WHERE YEAR(order_date) = 2024 );
What to say: NOT IN works fine as long as the subquery never returns a NULL value in customer_id. But if even one row in the 2024 subquery has a NULL customer_id, the entire NOT IN comparison silently returns no rows at all, because comparing anything to NULL is unknown rather than true or false. This is a well known SQL trap. I personally avoid NOT IN with subqueries for this reason and prefer LEFT JOIN with an IS NULL check, or NOT EXISTS, both of which handle NULLs safely.
Q2. How would you write this using NOT EXISTS instead?
NOT EXISTS is generally considered the safest and often the most efficient way to express this kind of anti join logic.
SELECT DISTINCT customer_id FROM orders o1 WHERE YEAR(o1.order_date) = 2023 AND NOT EXISTS ( SELECT 1 FROM orders o2 WHERE o2.customer_id = o1.customer_id AND YEAR(o2.order_date) = 2024 );
What to say: NOT EXISTS checks row by row whether a matching record exists in 2024 for that customer, and it has no NULL related pitfalls like NOT IN does. Most database optimisers also handle NOT EXISTS very efficiently since it can stop searching the moment it finds one match. I tend to favour this pattern in production code over NOT IN for exactly this safety reason.
Q3. What if a customer ordered in 2023 and also ordered again later in 2023, would your query count them once or multiple times?
Good catch to bring up. Because the last_year_customers CTE uses SELECT DISTINCT, each customer appears exactly once in that set regardless of how many orders they placed in 2023. The same applies to this_year_customers. So the final result naturally has no duplicates, even if the underlying orders table has many rows per customer.
What to say: I deliberately use DISTINCT inside both CTEs so the join operates on unique customers per year rather than on raw order rows. Without that, a customer with five orders in 2023 could appear up to five times if I joined directly off the raw orders table, which would be incorrect for this kind of churn analysis.
Q4. How would you extend this to a rolling twelve month definition instead of calendar year?
If the business wants a rolling window rather than fixed calendar years, the date filtering changes but the anti join structure stays the same.
WITH prior_period_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE order_date >= DATEADD(MONTH, -24, GETDATE()) AND order_date < DATEADD(MONTH, -12, GETDATE()) ), recent_period_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE order_date >= DATEADD(MONTH, -12, GETDATE()) ) SELECT pp.customer_id FROM prior_period_customers pp LEFT JOIN recent_period_customers rp ON pp.customer_id = rp.customer_id WHERE rp.customer_id IS NULL;
What to say: Instead of hardcoding calendar years, I define two rolling twelve month windows relative to today's date using DATEADD. The prior period covers months thirteen through twenty four ago, and the recent period covers the last twelve months. This is closer to how churn is typically measured in real subscription or retail businesses, since calendar year boundaries are somewhat arbitrary and a customer who bought in December and skipped January should not necessarily be flagged immediately as churned under a rolling definition.
Q5. How would you also calculate how many days it has been since each churned customer's last order?
A natural follow up the business team would actually want, since churned is binary but recency tells a richer story.
WITH last_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2023 ), this_year_customers AS ( SELECT DISTINCT customer_id FROM orders WHERE YEAR(order_date) = 2024 ), churned AS ( SELECT ly.customer_id FROM last_year_customers ly LEFT JOIN this_year_customers ty ON ly.customer_id = ty.customer_id WHERE ty.customer_id IS NULL ) SELECT c.customer_id, MAX(o.order_date) AS last_order_date, DATEDIFF(DAY, MAX(o.order_date), GETDATE()) AS days_since_last_order FROM churned c JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id;
What to say: I join the churned customer list back to the full orders table, take the maximum order_date per customer to get their true last purchase, and use DATEDIFF against today's date to show how long they have been inactive. This turns a simple yes or no churn flag into something the business can actually prioritise outreach with, customers inactive for ninety days might get a different campaign than someone inactive for four hundred days.
Finding Overlapping Date Ranges
The Interviewer Says
We manage meeting room bookings. Each row represents a booking with a start and end time for a specific room. Before we let a new booking go through, we need to detect if any existing bookings for the same room overlap with each other. Write a query that returns all pairs of bookings that overlap in time. Walk me through your thinking before writing the query.
Input Table: room_bookings
| booking_id | room_id | start_time | end_time |
|---|---|---|---|
| B01 | R101 | 2024-03-01 09:00:00 | 2024-03-01 10:00:00 |
| B02 | R101 | 2024-03-01 09:30:00 | 2024-03-01 10:30:00 |
| B03 | R101 | 2024-03-01 11:00:00 | 2024-03-01 12:00:00 |
| B04 | R102 | 2024-03-01 09:00:00 | 2024-03-01 10:00:00 |
| B05 | R102 | 2024-03-01 10:00:00 | 2024-03-01 11:00:00 |
How to Handle This in the Interview
Before writing anything, think: To find overlaps I need to compare every booking against every other booking for the same room, which means a self join. The tricky part is getting the overlap condition mathematically correct.
Step 1: Recall the standard overlap formula
- Two time ranges overlap if and only if
start_time_1 < end_time_2ANDend_time_1 > start_time_2. Say this out loud, this exact formula is something interviewers want to hear stated correctly rather than guessed at.
Step 2: Self join on the same room
- Join the table to itself where room_id matches, since bookings in different rooms can never overlap with each other regardless of time.
Step 3: Avoid duplicate and self comparing pairs
- Without care, a self join would compare B01 to B01, and also produce both B01 with B02 and B02 with B01 as separate rows. Add a condition like
b1.booking_id < b2.booking_idto keep only one direction per pair and exclude comparing a row to itself.
Step 4: Clarify the boundary condition
- Ask: If one booking ends exactly when another starts, like 10:00 to 10:00, should that count as an overlap or as back to back bookings that are fine? This changes whether you use strict less than or less than or equal to in the formula.
Solution
SQL Server
SELECT b1.booking_id AS booking_1, b2.booking_id AS booking_2, b1.room_id, b1.start_time AS booking_1_start, b1.end_time AS booking_1_end, b2.start_time AS booking_2_start, b2.end_time AS booking_2_end FROM room_bookings b1 JOIN room_bookings b2 ON b1.room_id = b2.room_id AND b1.booking_id < b2.booking_id WHERE b1.start_time < b2.end_time AND b1.end_time > b2.start_time;
PostgreSQL
SELECT b1.booking_id AS booking_1, b2.booking_id AS booking_2, b1.room_id, b1.start_time AS booking_1_start, b1.end_time AS booking_1_end, b2.start_time AS booking_2_start, b2.end_time AS booking_2_end FROM room_bookings b1 JOIN room_bookings b2 ON b1.room_id = b2.room_id AND b1.booking_id < b2.booking_id WHERE b1.start_time < b2.end_time AND b1.end_time > b2.start_time;
MySQL 8+
SELECT b1.booking_id AS booking_1, b2.booking_id AS booking_2, b1.room_id, b1.start_time AS booking_1_start, b1.end_time AS booking_1_end, b2.start_time AS booking_2_start, b2.end_time AS booking_2_end FROM room_bookings b1 JOIN room_bookings b2 ON b1.room_id = b2.room_id AND b1.booking_id < b2.booking_id WHERE b1.start_time < b2.end_time AND b1.end_time > b2.start_time;
The self join overlap pattern is identical across all three platforms.
What to say: I join the table to itself on room_id so I am only comparing bookings within the same room. The booking_id less than condition stops me from comparing a row to itself and also stops me from getting the same pair twice in both directions. Then the actual overlap check is the standard formula, one booking starts before the other ends, and ends after the other starts. If both conditions are true, the two time ranges must intersect somewhere.
Expected Output
| booking_1 | booking_2 | room_id | booking_1_start | booking_1_end | booking_2_start | booking_2_end |
|---|---|---|---|---|---|---|
| B01 | B02 | R101 | 2024-03-01 09:00:00 | 2024-03-01 10:00:00 | 2024-03-01 09:30:00 | 2024-03-01 10:30:00 |
Follow-up Questions & Answers
Q1. Why does the formula start_time_1 less than end_time_2 AND end_time_1 greater than start_time_2 correctly detect overlap?
This is the standard interval intersection test from computer science, and interviewers love asking you to justify it rather than just write it. Two ranges fail to overlap only in two scenarios, either the first range ends before the second one starts, or the first range starts after the second one ends. The formula is essentially the logical negation of those two failure conditions. If neither failure condition is true, meaning the first range does not end before the second starts, and the first range does not start after the second ends, then the ranges must share at least some common time. It works regardless of which range is longer or which one starts first, because the condition is symmetric in what it is checking even though it looks directional.
Q2. The boundary case, what if one booking ends at exactly 10:00 and another starts at exactly 10:00, does your query flag that as an overlap?
With the strict less than and greater than operators used here, no, it does not flag that as an overlap, which is the more common business expectation for booking systems, since back to back meetings should be allowed.
-- If the interviewer wants touching boundaries to also count as overlap WHERE b1.start_time <= b2.end_time AND b1.end_time >= b2.start_time
What to say: With strict inequality, a booking ending at 10:00 and another starting at 10:00 are treated as non overlapping, since there is zero duration of actual time intersection between them. If the interviewer specifically wants touching endpoints to count as a conflict, I would switch to less than or equal to and greater than or equal to instead. I always ask which behaviour is wanted rather than assuming, since this single character difference completely changes the result set.
Q3. This self join could get expensive on a large bookings table, how would you optimise it?
A great performance follow up. The self join compares every booking against every other booking in the same room, which can get slow as the table grows, especially without the right index in place.
-- Composite index to support the join and filter efficiently CREATE INDEX idx_bookings_room_time ON room_bookings(room_id, start_time, end_time);
What to say: I would add a composite index on room_id, start_time, and end_time together. This lets the database quickly narrow down to bookings within the same room and then efficiently filter by the time range conditions, rather than scanning the entire table for every comparison. I would also check the query execution plan to confirm the database is actually using this index for an index seek rather than falling back to a full table scan, since self joins on inequality conditions can sometimes confuse the optimiser into choosing a less efficient plan.
Q4. How would you modify this to also include the duration of the overlap itself, not just which bookings overlap?
The interviewer may want the actual overlapping minutes calculated, which is a common real requirement for billing or conflict severity reporting.
SELECT b1.booking_id AS booking_1, b2.booking_id AS booking_2, b1.room_id, DATEDIFF( MINUTE, CASE WHEN b1.start_time > b2.start_time THEN b1.start_time ELSE b2.start_time END, CASE WHEN b1.end_time < b2.end_time THEN b1.end_time ELSE b2.end_time END ) AS overlap_minutes FROM room_bookings b1 JOIN room_bookings b2 ON b1.room_id = b2.room_id AND b1.booking_id < b2.booking_id WHERE b1.start_time < b2.end_time AND b1.end_time > b2.start_time;
What to say: The overlap window itself is the later of the two start times through the earlier of the two end times. I use CASE WHEN to pick the maximum of the two start times and the minimum of the two end times, then take the difference in minutes between them using DATEDIFF. This gives the actual overlapping duration rather than just a yes or no flag, which is genuinely more useful for understanding how severe a scheduling conflict is.
Q5. How would you check if a brand new incoming booking conflicts with any existing booking, rather than checking all existing bookings against each other?
This is the realistic production scenario, checking one new booking before confirming it, rather than auditing the whole table at once.
-- Assume the new booking is for R101, 09:45 to 10:15 SELECT booking_id, start_time, end_time FROM room_bookings WHERE room_id = 'R101' AND start_time < '2024-03-01 10:15:00' AND end_time > '2024-03-01 09:45:00';
What to say: Instead of a self join across the whole table, this is a single lookup, I filter to the same room and apply the same overlap formula but against the fixed start and end time of the new booking I am trying to insert. If this query returns any rows at all, the new booking conflicts with something already on the calendar and should be rejected. This is exactly the kind of check that would run inside an application's booking confirmation logic before the insert actually happens.
Customer Lifetime Value Ranking with Ties
The Interviewer Says
Marketing wants to segment customers into four equal sized tiers based on their total lifetime spend, so they can target the top tier with a loyalty program. Write a query that divides customers into four buckets, where bucket one represents the highest spenders. Walk me through how you would think about this.
Input Table: customer_spend
| customer_id | total_spend |
|---|---|
| C01 | 95000 |
| C02 | 87000 |
| C03 | 76000 |
| C04 | 65000 |
| C05 | 54000 |
| C06 | 43000 |
| C07 | 32000 |
| C08 | 21000 |
How to Handle This in the Interview
Before writing anything, say: Dividing customers into equal sized buckets based on a ranking is a classic use case for NTILE, I want to make sure I pick that over RANK or DENSE_RANK since the goal here is equal group sizes, not handling ties in value.
Step 1: Recognise the difference between ranking and bucketing
- Ranking functions like RANK assign a position to each row. NTILE is different, it does not rank individual values, it distributes rows into a specified number of roughly equal sized groups based on the ordering you give it.
Step 2: Decide the sort direction
- Since bucket one should represent the highest spenders, order by total_spend descending inside the NTILE window function.
Step 3: Think about what happens when row count is not evenly divisible
- Say: With eight customers and four buckets, this divides perfectly evenly, two customers per bucket. But if the row count is not divisible evenly by the bucket count, NTILE distributes the extra rows to the earlier buckets first, I should mention that the bucket sizes might differ by one row in those cases.
Step 4: Clarify what label the business wants
- Ask: Should the buckets be labelled as numbers one through four, or does marketing want named tiers like Platinum, Gold, Silver, Bronze? This affects whether a simple NTILE call is enough or whether a CASE WHEN needs to sit on top of it.
Solution
SQL Server
SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier FROM customer_spend ORDER BY spend_tier, total_spend DESC;
PostgreSQL
SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier FROM customer_spend ORDER BY spend_tier, total_spend DESC;
MySQL 8+
SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier FROM customer_spend ORDER BY spend_tier, total_spend DESC;
NTILE syntax is identical across all three platforms.
What to say: NTILE with the value 4 splits all customers into four groups based on their position once sorted by total_spend descending. Since the ordering is descending, bucket one ends up containing the customers with the highest spend, which is exactly what marketing wants for their top tier loyalty program.
Expected Output
| customer_id | total_spend | spend_tier |
|---|---|---|
| C01 | 95000 | 1 |
| C02 | 87000 | 1 |
| C03 | 76000 | 2 |
| C04 | 65000 | 2 |
| C05 | 54000 | 3 |
| C06 | 43000 | 3 |
| C07 | 32000 | 4 |
| C08 | 21000 | 4 |
Follow-up Questions & Answers
Q1. What exactly does NTILE do differently compared to RANK or DENSE_RANK?
RANK and DENSE_RANK answer the question what position does this row hold relative to others, based purely on its value, and ties get the same rank. NTILE answers a completely different question, which equal sized group does this row fall into once everything is sorted, regardless of whether values are tied or not. NTILE does not care about ties in the underlying value at all, it only cares about dividing the total row count into the number of groups you specify. This is the key reason I reached for NTILE here rather than RANK, the business requirement was explicitly about four equal sized buckets for a loyalty program, not about precisely ranking each individual customer's spend value.
Q2. With eight customers and four buckets the split was perfectly even, what happens with an uneven number, say nine customers into four buckets?
This is exactly the kind of follow up interviewers ask to see if you actually understand the mechanics rather than just having memorised the syntax. NTILE distributes the remainder rows to the earliest buckets first when the total does not divide evenly.
-- With 9 rows and NTILE(4), the distribution becomes 3, 2, 2, 2 SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier FROM customer_spend ORDER BY spend_tier, total_spend DESC;
What to say: With nine rows split into four buckets, nine divided by four leaves a remainder of one, so NTILE gives the first bucket three rows and the remaining three buckets two rows each. The extra row always goes to the earliest groups in the ordering. I would mention this explicitly to the business stakeholder, since they might assume every tier has exactly the same number of customers, and a small size difference of one row could matter if they are doing something sensitive like assigning a fixed budget per tier.
Q3. Two customers in the data have the exact same total_spend value, but NTILE puts them in different tiers, is that a bug?
This is not a bug, it is expected and important behaviour to understand. NTILE only respects the row count target for each bucket, it does not check whether tied values end up split across a bucket boundary. If two customers both have exactly 65000 and they happen to fall right at the edge between bucket two and bucket three based on row position alone, NTILE will place them in different tiers even though their underlying spend is identical.
-- To see this clearly, compare NTILE against DENSE_RANK on the same data SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier, DENSE_RANK() OVER (ORDER BY total_spend DESC) AS spend_rank FROM customer_spend ORDER BY total_spend DESC;
What to say: I would flag this clearly to whoever requested the tiering, since splitting two customers with identical spend into different loyalty tiers could look unfair or arbitrary from a customer relations standpoint. If equal spend must always land in the same tier even at the cost of unequal bucket sizes, NTILE is actually the wrong tool entirely, and I would instead compute percentile thresholds using PERCENT_RANK or manually defined spend ranges with CASE WHEN.
Q4. How would you turn the numeric tier output into named labels like Platinum, Gold, Silver, Bronze for the business team?
A very common real world ask, the raw bucket number from NTILE is rarely what gets shown directly to a non technical stakeholder.
WITH tiered AS ( SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_tier FROM customer_spend ) SELECT customer_id, total_spend, CASE spend_tier WHEN 1 THEN 'Platinum' WHEN 2 THEN 'Gold' WHEN 3 THEN 'Silver' WHEN 4 THEN 'Bronze' END AS loyalty_tier FROM tiered ORDER BY spend_tier, total_spend DESC;
What to say: I wrap the NTILE calculation in a CTE first, then apply a CASE WHEN in the outer query to translate the numeric tier into a business friendly label. Keeping the NTILE logic and the labelling logic in separate layers makes the query easier to maintain, if marketing later wants five tiers instead of four, I only need to touch the NTILE call and update the CASE mapping, the rest of the query structure stays the same.
Q5. The interviewer asks, instead of fixed tiers, can you calculate exactly what percentile each customer falls into?
This pushes beyond NTILE into a more precise statistical ranking, which uses PERCENT_RANK or CUME_DIST depending on exactly how the percentile should be defined.
SELECT customer_id, total_spend, ROUND( PERCENT_RANK() OVER (ORDER BY total_spend DESC) * 100, 1 ) AS percentile_rank FROM customer_spend ORDER BY total_spend DESC;
What to say: PERCENT_RANK calculates the relative position of each row as a value between zero and one, where zero represents the top of the ordering, I multiply by one hundred to express it as a more familiar percentile number. This gives a continuous, precise measure rather than NTILE's discrete buckets, which is more appropriate if the business eventually wants finer grained segmentation or wants to compare a single customer's standing without needing to fit them into one of only four predefined groups.
Employee with Highest Salary in Each Department
The Interviewer Says
This is a very common one but I still want to see how you write it cleanly. We have an employee table with department information. Find the employee with the highest salary in each department. If there is a tie, show all of them. Walk me through your approach.
Input Table: employees
| emp_id | name | dept | salary |
|---|---|---|---|
| E01 | Amit | Finance | 90000 |
| E02 | Sara | Finance | 90000 |
| E03 | Raj | Finance | 75000 |
| E04 | John | IT | 120000 |
| E05 | Priya | IT | 95000 |
| E06 | Tom | IT | 80000 |
| E07 | Nina | HR | 60000 |
| E08 | Mike | HR | 45000 |
How to Handle This in the Interview
Before writing anything, say: This is a per group maximum problem, I want the full row for the highest salary in each department, not just the salary value itself, so a simple GROUP BY MAX will not be enough on its own.
Step 1: Identify why plain GROUP BY does not work
SELECT dept, MAX(salary) FROM employees GROUP BY deptonly gives you the department and the max salary number, it cannot also return emp_id or name in the same query unless those columns are also part of the GROUP BY, which would break the aggregation. Say this limitation out loud, interviewers want to hear you recognise it before reaching for a fix.
Step 2: Choose the window function approach
- Use RANK or DENSE_RANK partitioned by department ordered by salary descending, then filter for rank equal to 1. This naturally returns every column on the row, not just the aggregated value, and also handles ties correctly.
Step 3: Decide RANK versus DENSE_RANK here
- Say: Since I am only filtering for rank 1, RANK and DENSE_RANK behave identically at the top position, there is no difference until you look past rank 1. I will use RANK since it is the more conventional choice when only the top value matters.
Step 4: Mention the GROUP BY plus subquery alternative
- Note that this can also be solved with a correlated subquery comparing each employee's salary to the max salary in their own department, useful to mention as a second approach if asked.
Solution
SQL Server
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_employees WHERE salary_rank = 1 ORDER BY dept;
PostgreSQL
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_employees WHERE salary_rank = 1 ORDER BY dept;
MySQL 8+
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_employees WHERE salary_rank = 1 ORDER BY dept;
The syntax is identical across all three platforms.
What to say: I partition by department so ranking restarts for each department, and order by salary descending so the highest earner gets rank 1. Filtering for rank equal to 1 then picks out the top earner in every department, and since I used RANK, if two employees tie for the highest salary, both correctly get rank 1 and both appear in the output.
Expected Output
| emp_id | name | dept | salary |
|---|---|---|---|
| E01 | Amit | Finance | 90000 |
| E02 | Sara | Finance | 90000 |
| E04 | John | IT | 120000 |
| E07 | Nina | HR | 60000 |
Follow-up Questions & Answers
Q1. Can you solve this without window functions, using only GROUP BY and a join?
Yes, this is the pre window function way of solving it and interviewers sometimes ask for it specifically to test whether you understand the older approach.
SELECT e.emp_id, e.name, e.dept, e.salary FROM employees e JOIN ( SELECT dept, MAX(salary) AS max_salary FROM employees GROUP BY dept ) dept_max ON e.dept = dept_max.dept AND e.salary = dept_max.max_salary ORDER BY e.dept;
What to say: The subquery first finds the maximum salary per department using a normal GROUP BY, then I join that result back to the original employee table matching on both department and salary, which pulls back the full row including emp_id and name. This naturally handles ties as well, since both Amit and Sara have salary 90000 in Finance, both will match the join condition.
Q2. What if the interviewer wants the second highest salary returned alongside the highest, both in the same query?
This extends naturally from the RANK approach, just widen the filter condition.
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees ) SELECT emp_id, name, dept, salary, salary_rank FROM ranked_employees WHERE salary_rank IN (1, 2) ORDER BY dept, salary_rank;
What to say: Here I switch to DENSE_RANK instead of RANK, because once I am including more than just the top position, DENSE_RANK avoids skipping rank numbers when there are ties, which keeps the filter condition rank IN one, two behaving predictably regardless of how many people tie for first place.
Q3. Why did you not just use ROW_NUMBER here instead of RANK?
This is an important distinction to articulate clearly. ROW_NUMBER would assign a unique number to every row even when salaries tie, arbitrarily picking only one employee as rank 1 in Finance even though both Amit and Sara earn exactly 90000. Since the interviewer explicitly said show all of them if there is a tie, ROW_NUMBER would silently produce an incorrect result by dropping one of the tied employees, even though the query would run without any error.
What to say: This is exactly the kind of subtle bug that does not throw an error but produces a wrong business answer, the query executes fine and returns a plausible looking result, which makes it dangerous. I always pick the ranking function based on whether ties should be preserved or arbitrarily broken, and I confirm that requirement with the interviewer or stakeholder rather than assuming.
Q4. How would you find the department with the highest paid employee overall, just one single department, not per department?
A natural extension once you already have departments ranked, now you want only the single best across the entire company.
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, RANK() OVER (ORDER BY salary DESC) AS overall_rank FROM employees ) SELECT emp_id, name, dept, salary FROM ranked_employees WHERE overall_rank = 1;
What to say: The only change here is removing PARTITION BY entirely, so the ranking runs across the whole table rather than resetting per department. This single change is a good way to show the interviewer I understand exactly what PARTITION BY controls rather than having memorised the per department version as a fixed template.
Q5. What if salary could be NULL for some employees, how does that affect this query?
A genuinely important edge case for production data. In most databases including SQL Server, PostgreSQL, and MySQL, NULL values sort last by default when ordering descending, so an employee with a NULL salary would never accidentally be ranked as the top earner. However, it is worth explicitly deciding what should happen to that employee in the output.
WITH ranked_employees AS ( SELECT emp_id, name, dept, salary, RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS salary_rank FROM employees WHERE salary IS NOT NULL ) SELECT emp_id, name, dept, salary FROM ranked_employees WHERE salary_rank = 1 ORDER BY dept;
What to say: I would filter out NULL salaries before ranking entirely, rather than relying on default NULL sort ordering, since that default behaviour can vary across database platforms and I do not want correctness depending on an implicit assumption. I would also flag to the interviewer that employees with missing salary data might need a separate data quality conversation rather than just being silently excluded from this report.
Employees Who Got a Salary Hike This Year
The Interviewer Says
HR keeps a year wise salary history table, one row per employee per year. They want to know which employees received a salary hike compared to the previous year, and by how much. Write a query that returns the employee, their previous year salary, current year salary, and the hike amount. Walk me through your thinking.
Input Table: salary_history
| emp_id | year | salary |
|---|---|---|
| E01 | 2025 | 60000 |
| E01 | 2026 | 68000 |
| E02 | 2025 | 72000 |
| E02 | 2026 | 72000 |
| E03 | 2025 | 55000 |
| E03 | 2026 | 60000 |
| E04 | 2026 | 50000 |
How to Handle This in the Interview
Before writing anything, say: I need to compare each employee's current year salary against their own previous year salary, which means I need to bring two years of data side by side for the same employee. There are two ways to do this, a self join or a window function.
Step 1: Decide between self join and LAG
- Say: Both work here. A self join on emp_id where one side is 2026 and the other side is 2025 is very explicit and easy to read for a fixed two year comparison. If this needed to generalise to many years of history, LAG ordered by year would scale better. Since the requirement is specifically this year versus last year, I will go with a self join for clarity.
Step 2: Identify the join condition
- Join salary_history to itself on emp_id, where one alias is filtered to year 2026 and the other alias is filtered to year 2025.
Step 3: Decide the join type carefully
- Think about what happens to employee E04 who only has a 2026 row and no 2025 row at all. An INNER JOIN would silently exclude them, which is actually correct here since there is no previous salary to compare against, but say this reasoning out loud rather than assuming it.
Step 4: Define what counts as a hike
- A hike means current salary is strictly greater than previous salary. Employee E02 has the same salary both years, that should not count as a hike, it should either be excluded or labelled separately depending on what HR wants.
Solution
SQL Server
SELECT curr.emp_id, prev.salary AS previous_salary, curr.salary AS current_salary, curr.salary - prev.salary AS hike_amount FROM salary_history curr JOIN salary_history prev ON curr.emp_id = prev.emp_id AND curr.year = prev.year + 1 WHERE curr.year = 2026 AND curr.salary > prev.salary ORDER BY curr.emp_id;
PostgreSQL
SELECT curr.emp_id, prev.salary AS previous_salary, curr.salary AS current_salary, curr.salary - prev.salary AS hike_amount FROM salary_history curr JOIN salary_history prev ON curr.emp_id = prev.emp_id AND curr.year = prev.year + 1 WHERE curr.year = 2026 AND curr.salary > prev.salary ORDER BY curr.emp_id;
MySQL 8+
SELECT curr.emp_id, prev.salary AS previous_salary, curr.salary AS current_salary, curr.salary - prev.salary AS hike_amount FROM salary_history curr JOIN salary_history prev ON curr.emp_id = prev.emp_id AND curr.year = prev.year + 1 WHERE curr.year = 2026 AND curr.salary > prev.salary ORDER BY curr.emp_id;
The self join logic is identical across all three platforms.
What to say: I self join the table to itself, one side representing the current year and one side representing the previous year, matched on the same employee. The join condition curr.year equals prev.year plus 1 ensures I am always comparing consecutive years rather than any arbitrary pair. I use an INNER JOIN deliberately, since an employee with no prior year record has nothing to compare against, they naturally get excluded rather than needing special handling.
Expected Output
| emp_id | previous_salary | current_salary | hike_amount |
|---|---|---|---|
| E01 | 60000 | 68000 | 8000 |
| E03 | 55000 | 60000 | 5000 |
Follow-up Questions & Answers
Q1. Why did E02 not appear in the output even though they have records in both years?
E02 has a salary of 72000 in both 2025 and 2026, meaning there was no actual increase. The WHERE clause specifically filters for curr.salary greater than prev.salary, so an unchanged salary correctly does not qualify as a hike. This is exactly the kind of detail to point out proactively, it shows the interviewer that the query is not just joining years together, it is genuinely applying the business definition of a hike.
Q2. Why did E04 not appear, and is that the correct behaviour?
E04 only has a 2026 row with no corresponding 2025 row. Since this uses an INNER JOIN, a row with no match on the other side is dropped entirely. This is correct behaviour here, since there is no previous salary to compare against, you cannot determine whether E04 received a hike or not, they may be a new employee hired sometime in 2026.
What to say: I would still mention this explicitly to HR rather than letting them assume E04 was checked and found to have no hike, there is a meaningful difference between no previous data exists and no hike occurred, and silently dropping the row could be misread as the second case.
Q3. How would you rewrite this using LAG instead of a self join, and when would that be the better choice?
LAG becomes the better choice the moment you need to handle more than two years, or an arbitrary number of years per employee, since a self join only naturally compares exactly one pair of years at a time.
WITH salary_with_prev AS ( SELECT emp_id, year, salary, LAG(salary) OVER (PARTITION BY emp_id ORDER BY year) AS previous_salary FROM salary_history ) SELECT emp_id, year, previous_salary, salary AS current_salary, salary - previous_salary AS hike_amount FROM salary_with_prev WHERE salary > previous_salary;
What to say: LAG partitioned by employee and ordered by year automatically pulls in the immediately preceding year's salary for every row, regardless of how many years of history exist. This scales naturally if salary_history grows to cover ten years per employee, whereas the self join approach would only ever compare one specific year pair unless I hardcoded additional joins for each year transition. I would choose LAG by default for anything beyond a simple two year snapshot comparison.
Q4. How would you also calculate the hike as a percentage, not just a flat amount?
A very natural extension HR almost always wants alongside the absolute hike amount.
SELECT curr.emp_id, prev.salary AS previous_salary, curr.salary AS current_salary, curr.salary - prev.salary AS hike_amount, ROUND((curr.salary - prev.salary) * 100.0 / prev.salary, 2) AS hike_percentage FROM salary_history curr JOIN salary_history prev ON curr.emp_id = prev.emp_id AND curr.year = prev.year + 1 WHERE curr.year = 2026 AND curr.salary > prev.salary ORDER BY curr.emp_id;
What to say: I calculate the percentage by dividing the hike amount by the previous salary and multiplying by one hundred, casting to a decimal using the point zero on one hundred to avoid integer division truncating the result to zero in databases that perform integer division by default. A flat hike amount alone can be misleading, an eight thousand hike means very different things for someone earning thirty thousand versus someone earning three hundred thousand, the percentage gives HR a fairer way to compare across pay bands.
Q5. What if you needed to find employees who did NOT get a hike at all, including new employees with no prior record, all in one query?
This requires a LEFT JOIN instead of an INNER JOIN, since you specifically want to keep rows even when there is no matching previous year.
SELECT curr.emp_id, prev.salary AS previous_salary, curr.salary AS current_salary, CASE WHEN prev.salary IS NULL THEN 'No prior record' WHEN curr.salary > prev.salary THEN 'Hike' WHEN curr.salary = prev.salary THEN 'No change' ELSE 'Decrease' END AS hike_status FROM salary_history curr LEFT JOIN salary_history prev ON curr.emp_id = prev.emp_id AND curr.year = prev.year + 1 WHERE curr.year = 2026 ORDER BY curr.emp_id;
What to say: Switching to a LEFT JOIN keeps every 2026 employee in the result regardless of whether a 2025 row exists, then the CASE statement classifies each one into a clear status, no prior record, hike, no change, or decrease. This single query now answers a much broader HR question rather than only the narrow case of who specifically got a raise, and is the kind of natural follow up a thoughtful interviewer often pushes toward to see if you can generalise your own solution.
Employee Manager Hierarchy
The Interviewer Says
We have an employee table that stores each employee along with their manager's employee id in the same table, a classic self referencing hierarchy. I want you to write a query that shows every employee along with their manager's name, and also the full reporting chain depth, meaning how many levels deep they are from the top of the organisation. Walk me through your approach.
Input Table: employees
| emp_id | name | manager_id |
|---|---|---|
| E01 | Karan | NULL |
| E02 | Asha | E01 |
| E03 | Ravi | E01 |
| E04 | Meena | E02 |
| E05 | Sunil | E02 |
| E06 | Divya | E04 |
How to Handle This in the Interview
Before writing anything, say: This is a self referencing hierarchy, the table refers back to itself through manager_id. I need two different things here, the immediate manager name which is a simple self join, and the depth of the reporting chain which requires recursion since the hierarchy can go arbitrarily deep.
Step 1: Separate the two requirements clearly
- Getting the manager's name for each employee is a simple self join, no recursion needed for that part alone. Getting the depth or level in the hierarchy is the part that genuinely requires a recursive CTE, since you cannot know in advance how many levels exist.
Step 2: Identify the anchor and recursive parts
- Say: Every recursive CTE needs two parts, an anchor query that defines the starting point, here that is employees with no manager, meaning the top of the hierarchy, and a recursive part that joins the CTE back to itself to walk down one level at a time.
Step 3: Plan the recursive join condition
- The recursive part joins employees to the CTE result so far, matching the employee's manager_id to the emp_id already found in the previous level, then increments the level counter by one each time.
Step 4: Think about safety against infinite loops
- Mention: In a self referencing structure, a data error could accidentally create a circular reference, like an employee being their own manager's manager several levels up. I would set a maximum recursion limit as a safety net rather than letting the query run forever if bad data ever creates a cycle.
Solution
SQL Server
WITH employee_hierarchy AS ( -- Anchor: top of the hierarchy, no manager SELECT emp_id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL -- Recursive part: walk down one level at a time SELECT e.emp_id, e.name, e.manager_id, eh.level + 1 FROM employees e JOIN employee_hierarchy eh ON e.manager_id = eh.emp_id ) SELECT eh.emp_id, eh.name, mgr.name AS manager_name, eh.level FROM employee_hierarchy eh LEFT JOIN employees mgr ON eh.manager_id = mgr.emp_id ORDER BY eh.level, eh.emp_id OPTION (MAXRECURSION 100);
PostgreSQL
WITH RECURSIVE employee_hierarchy AS ( SELECT emp_id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.name, e.manager_id, eh.level + 1 FROM employees e JOIN employee_hierarchy eh ON e.manager_id = eh.emp_id ) SELECT eh.emp_id, eh.name, mgr.name AS manager_name, eh.level FROM employee_hierarchy eh LEFT JOIN employees mgr ON eh.manager_id = mgr.emp_id ORDER BY eh.level, eh.emp_id;
MySQL 8+
WITH RECURSIVE employee_hierarchy AS ( SELECT emp_id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.name, e.manager_id, eh.level + 1 FROM employees e JOIN employee_hierarchy eh ON e.manager_id = eh.emp_id ) SELECT eh.emp_id, eh.name, mgr.name AS manager_name, eh.level FROM employee_hierarchy eh LEFT JOIN employees mgr ON eh.manager_id = mgr.emp_id ORDER BY eh.level, eh.emp_id;
PostgreSQL and MySQL require the explicit RECURSIVE keyword after WITH, SQL Server does not need it. SQL Server also typically needs a MAXRECURSION option if the hierarchy could exceed the default limit of one hundred.
What to say: The anchor part finds Karan, the only employee with no manager, and assigns them level one. The recursive part then repeatedly joins employees back to the growing result, finding anyone whose manager_id matches an emp_id already discovered, and increments the level each pass. This keeps running until no more matches are found, naturally walking down the entire hierarchy regardless of how many levels deep it goes. Finally I left join back to employees once more just to pull in the manager's actual name for display.
Expected Output
| emp_id | name | manager_name | level |
|---|---|---|---|
| E01 | Karan | NULL | 1 |
| E02 | Asha | Karan | 2 |
| E03 | Ravi | Karan | 2 |
| E04 | Meena | Asha | 3 |
| E05 | Sunil | Asha | 3 |
| E06 | Divya | Meena | 4 |
Follow-up Questions & Answers
Q1. Why is a simple self join not enough here, why do we genuinely need recursion?
A single self join can only ever fetch one level of relationship, meaning the immediate manager for each employee. It cannot tell you the manager's manager, or how many levels deep someone sits in the hierarchy, because that information is not present in a single row, it only emerges by repeatedly walking the relationship multiple times. Recursion is exactly the mechanism for repeatedly applying the same join logic an unknown number of times until you reach the natural stopping point, here that stopping point is when no more matching child rows are found at the next level down.
Q2. What would happen if the data accidentally had a circular reference, say employee A reports to B, and B reports to A?
This is a serious data integrity problem and the recursive query would keep producing new rows for A and B alternating levels forever, since the recursion has no natural termination point in a true cycle. This is exactly why setting a maximum recursion limit is not just a performance safeguard, it is a correctness safeguard.
-- SQL Server: explicitly caps recursion at 50 levels OPTION (MAXRECURSION 50); -- PostgreSQL: no built in recursion depth limit by default, -- you typically add a WHERE clause guard inside the recursive part WITH RECURSIVE employee_hierarchy AS ( SELECT emp_id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.name, e.manager_id, eh.level + 1 FROM employees e JOIN employee_hierarchy eh ON e.manager_id = eh.emp_id WHERE eh.level < 50 -- safety guard against cycles ) SELECT * FROM employee_hierarchy;
What to say: In SQL Server, the MAXRECURSION option will actually throw an error once the limit is hit, which immediately surfaces the problem rather than silently truncating the data. In PostgreSQL, since there is no automatic cap, I would add an explicit level guard inside the recursive part itself. Either way, I would treat hitting that limit as a serious signal to go investigate the source data for a genuine cycle, not just quietly raise the limit and move on.
Q3. How would you find just the total count of direct reports for each manager, without needing the full recursive hierarchy?
If the requirement is only direct reports, not the entire downstream chain, this is a much simpler GROUP BY, no recursion needed at all.
SELECT mgr.emp_id, mgr.name AS manager_name, COUNT(e.emp_id) AS direct_report_count FROM employees mgr LEFT JOIN employees e ON e.manager_id = mgr.emp_id GROUP BY mgr.emp_id, mgr.name ORDER BY direct_report_count DESC;
What to say: I deliberately reach for the simpler query here rather than the recursive one, since recursion is genuinely only needed when you must walk multiple levels deep. If the actual business question is just how many people directly report to each manager, a LEFT JOIN with COUNT is simpler, faster, and easier for anyone else to read and maintain later. I always try to match the complexity of the query to the actual complexity of the question being asked.
Q4. How would you find the total number of people under a specific manager, including everyone in their entire downstream chain, not just direct reports?
This genuinely needs the full recursive hierarchy, since it must include reports of reports at every level beneath the given manager.
WITH RECURSIVE downstream AS ( SELECT emp_id, name, manager_id FROM employees WHERE manager_id = 'E02' -- starting manager UNION ALL SELECT e.emp_id, e.name, e.manager_id FROM employees e JOIN downstream d ON e.manager_id = d.emp_id ) SELECT COUNT(*) AS total_team_size FROM downstream;
What to say: This time the anchor starts directly from the specific manager's direct reports rather than from the very top of the hierarchy, and the recursive part walks downward from there exactly as before. Counting the final result gives the entire team size under that manager, including every level beneath them. This kind of query comes up constantly in real organisational reporting, like calculating headcount or budget rollups under a given leader.
Q5. How would you identify the very top level employee, the one with no manager, if you were not told upfront which row that was?
This is straightforward once you frame it correctly, the top of the hierarchy is simply any employee whose manager_id is NULL, that is literally the definition used as the anchor in the recursive query already.
SELECT emp_id, name FROM employees WHERE manager_id IS NULL;
What to say: I would point out that this is actually already embedded as the anchor condition in the recursive CTE, finding the root or roots of the hierarchy is the very first step before any recursion can even begin. It is worth mentioning that in some real organisations there could legitimately be more than one row with a NULL manager, for example if two different departments each have their own top level head reporting externally to a board rather than to another employee in the same table, so I would not assume there is always exactly one root.
Removing Duplicate Records While Keeping One Copy
The Interviewer Says
Our customer table has duplicate rows due to a data ingestion bug, the same customer email appears multiple times with slightly different signup dates. I want you to write a query that identifies the duplicates and keeps only the earliest record for each email, deleting the rest. Walk me through how you would handle this safely.
Input Table: customers
| customer_id | signup_date | |
|---|---|---|
| 1 | raj@gmail.com | 2024-01-10 |
| 2 | priya@gmail.com | 2024-02-05 |
| 3 | raj@gmail.com | 2024-01-05 |
| 4 | amit@gmail.com | 2024-03-01 |
| 5 | raj@gmail.com | 2024-01-20 |
| 6 | priya@gmail.com | 2024-02-01 |
How to Handle This in the Interview
Before writing anything, say: Deleting data is risky, so before I write a DELETE statement I always want to first write a SELECT that shows exactly which rows would be affected, confirm that result looks correct, and only then convert it into a DELETE.
Step 1: Identify duplicates using a window function
- Use
ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date)so that within each email group, the earliest signup date gets row number 1, and every later duplicate gets row number 2, 3, and so on.
Step 2: Decide what to keep
- Row number 1 in each group is the one to keep, since ordering ascending by signup_date puts the earliest record first. Everything with row number greater than 1 is a duplicate to remove.
Step 3: Write the SELECT first, always
- Say: I will first run a SELECT wrapped around this logic to visually confirm exactly which customer_id values would be deleted, before ever running an actual DELETE statement. This is a non negotiable habit when working with destructive operations.
Step 4: Convert to DELETE using a CTE
- Once confirmed, the same CTE logic can directly back a DELETE statement, since CTEs are fully usable in DELETE in SQL Server and PostgreSQL.
Solution
SQL Server
-- Step 1: Always preview first WITH duplicate_check AS ( SELECT customer_id, email, signup_date, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) SELECT * FROM duplicate_check WHERE row_num > 1; -- Step 2: Once confirmed, delete the duplicates WITH duplicate_check AS ( SELECT customer_id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) DELETE FROM duplicate_check WHERE row_num > 1;
PostgreSQL
-- Step 1: Always preview first WITH duplicate_check AS ( SELECT customer_id, email, signup_date, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) SELECT * FROM duplicate_check WHERE row_num > 1; -- Step 2: Once confirmed, delete the duplicates DELETE FROM customers WHERE customer_id IN ( SELECT customer_id FROM ( SELECT customer_id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) ranked WHERE row_num > 1 );
MySQL 8+
-- Step 1: Always preview first WITH duplicate_check AS ( SELECT customer_id, email, signup_date, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) SELECT * FROM duplicate_check WHERE row_num > 1; -- Step 2: Once confirmed, delete the duplicates DELETE c FROM customers c JOIN ( SELECT customer_id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signup_date ASC) AS row_num FROM customers ) ranked ON c.customer_id = ranked.customer_id WHERE ranked.row_num > 1;
SQL Server allows deleting directly from a CTE. PostgreSQL and MySQL do not support deleting directly from a CTE in the same straightforward way, so the duplicate identification logic is wrapped in a subquery instead and used to filter the DELETE.
What to say: I always write the SELECT version of this query first and visually inspect the rows it identifies as duplicates before ever running a DELETE, this is a habit I never skip regardless of how simple the logic looks, because a DELETE mistake on a production table is not easily reversible. Once the SELECT output looks correct, I reuse the exact same partitioning logic to drive the actual DELETE.
Expected Output (after delete)
| customer_id | signup_date | |
|---|---|---|
| 1 | raj@gmail.com | 2024-01-10 |
| 2 | priya@gmail.com | 2024-02-05 |
| 4 | amit@gmail.com | 2024-03-01 |
Wait, this is incorrect based on the logic, let me correct it. Row number 1 per email group goes to the earliest signup_date.
Expected Output (corrected, after delete)
| customer_id | signup_date | |
|---|---|---|
| 3 | raj@gmail.com | 2024-01-05 |
| 6 | priya@gmail.com | 2024-02-01 |
| 4 | amit@gmail.com | 2024-03-01 |
Follow-up Questions & Answers
Q1. What if two duplicate rows for the same email have the exact same signup_date, how does ROW_NUMBER decide which one survives?
This is an important edge case to raise proactively. If the ORDER BY column has tied values, ROW_NUMBER still produces a strict, unique ordering, but which specific row gets number 1 versus number 2 among the tied rows is not guaranteed or deterministic unless a tiebreaker is added.
WITH duplicate_check AS ( SELECT customer_id, email, signup_date, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY signup_date ASC, customer_id ASC -- tiebreaker added ) AS row_num FROM customers ) SELECT * FROM duplicate_check WHERE row_num > 1;
What to say: I add customer_id as a secondary sort key purely as a deterministic tiebreaker, so that if I ever run this same query twice, it consistently identifies the exact same row as the survivor both times. Without an explicit tiebreaker, two different runs of the identical query could theoretically keep a different row each time, which is dangerous behaviour for something feeding a DELETE statement.
Q2. How would you do this without window functions, for a database that does not support them?
While all three platforms we use support window functions, it is worth knowing the older self join based approach as a fallback.
DELETE c1 FROM customers c1 JOIN customers c2 ON c1.email = c2.email AND c1.signup_date > c2.signup_date;
What to say: This self join compares every customer row against every other row with the same email, and deletes the one whose signup_date is later, effectively keeping only the earliest. This works correctly as long as there are no exact duplicate signup_date and email combinations, which is exactly the tiebreaker problem from the previous question, this approach would actually delete both duplicates if their dates tie exactly, since each would qualify as later than the other being false for both, meaning neither gets deleted, which is also a subtle bug worth mentioning.
Q3. Instead of deleting duplicates, how would you write a query that just flags them without removing anything, for a report?
Sometimes the actual business need is visibility, not destruction, especially before anyone has signed off on a cleanup.
WITH duplicate_check AS ( SELECT customer_id, email, signup_date, COUNT(*) OVER (PARTITION BY email) AS email_count FROM customers ) SELECT customer_id, email, signup_date, email_count FROM duplicate_check WHERE email_count > 1 ORDER BY email, signup_date;
What to say: Rather than ROW_NUMBER, I use COUNT as a window function here to attach how many total rows share that email to every row, then filter for counts greater than one. This gives a clean report showing every duplicate group together with its size, which is often what stakeholders actually want first, visibility into how big the problem is before anyone commits to deleting anything.
Q4. What if instead of keeping the earliest record, the business wants to keep the most recently updated record, and there is an updated_at column?
A very common real world variation, the rule for which row survives changes based on what the business actually values.
WITH duplicate_check AS ( SELECT customer_id, email, signup_date, ROW_NUMBER() OVER (PARTITION BY email ORDER BY updated_at DESC) AS row_num FROM customers ) SELECT * FROM duplicate_check WHERE row_num > 1;
What to say: The only change needed is the ORDER BY direction and column, ordering by updated_at descending means the most recently modified row becomes row number 1 and survives, while older versions get marked for deletion. This shows the underlying pattern, PARTITION BY to define duplicate groups and ORDER BY to define which one to keep, is flexible and just needs the right column and direction plugged in based on the actual business rule.
Q5. How would you prevent this duplicate problem from happening again in the future, at the database level?
A thoughtful interviewer often wants to see that you think beyond just cleaning up existing damage, toward preventing it going forward.
-- Add a unique constraint on email to prevent future duplicates ALTER TABLE customers ADD CONSTRAINT uq_customers_email UNIQUE (email);
What to say: Cleaning up existing duplicates only solves the symptom, the actual root cause is almost always missing a unique constraint at the database level, which allowed the ingestion bug to insert duplicates in the first place. I would propose adding a unique constraint on email once the existing duplicates are cleaned up, since the constraint cannot be added while violating rows still exist. I would also raise this with whoever owns the ingestion pipeline, since the database constraint is a safety net, but the actual bug causing duplicate inserts still needs to be fixed at the source.
Cumulative Sum per Customer
The Interviewer Says
We have a transactions table that records every payment made by each customer over time. The finance team wants to see a running cumulative total of how much each customer has spent, so that on any given transaction row you can see the total they have spent up to and including that point. Write a query for this. Walk me through your approach.
Input Table: transactions
| txn_id | customer_id | txn_date | amount |
|---|---|---|---|
| T01 | C01 | 2024-01-05 | 3000 |
| T02 | C01 | 2024-01-12 | 5000 |
| T03 | C01 | 2024-02-03 | 2000 |
| T04 | C02 | 2024-01-08 | 7000 |
| T05 | C02 | 2024-01-25 | 4000 |
| T06 | C03 | 2024-02-10 | 6000 |
How to Handle This in the Interview
Before writing anything, say: A cumulative sum per customer means the running total resets independently for each customer, so I need both a PARTITION BY and an ORDER BY inside the window function, not just an ORDER BY on its own.
Step 1: Understand the difference between a global running total and a per customer one
- Without PARTITION BY, SUM OVER ORDER BY would give one single running total across all customers mixed together, ordered by date. That is not what is needed here. PARTITION BY customer_id resets the running total for each customer independently.
Step 2: Get the ordering right
- The cumulative total must accumulate in chronological order, so ORDER BY txn_date inside the window is essential. If two transactions happen on the same date for the same customer, think about whether the order between them matters and whether a tiebreaker is needed.
Step 3: Confirm the window frame
- Say: SUM with ORDER BY inside a window function defaults to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which is exactly the frame a running total needs. I can state this explicitly to make the intent clearer, though leaving it implicit also works since the default matches.
Step 4: Ask a clarifying question
- Ask: Should the cumulative total include the current row's transaction amount, or only the transactions before it? Including the current row is the most common interpretation but it is worth confirming.
Solution
SQL Server
SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions ORDER BY customer_id, txn_date;
PostgreSQL
SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions ORDER BY customer_id, txn_date;
MySQL 8+
SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions ORDER BY customer_id, txn_date;
The syntax is identical across all three platforms.
What to say: PARTITION BY customer_id makes the running total restart from zero for each customer independently. ORDER BY txn_date inside the window ensures the transactions accumulate in chronological order. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW makes it explicit that the sum covers everything from the very first row of that customer right up to and including the current row, which is exactly what a cumulative spend figure should represent.
Expected Output
| txn_id | customer_id | txn_date | amount | cumulative_spend |
|---|---|---|---|---|
| T01 | C01 | 2024-01-05 | 3000 | 3000 |
| T02 | C01 | 2024-01-12 | 5000 | 8000 |
| T03 | C01 | 2024-02-03 | 2000 | 10000 |
| T04 | C02 | 2024-01-08 | 7000 | 7000 |
| T05 | C02 | 2024-01-25 | 4000 | 11000 |
| T06 | C03 | 2024-02-10 | 6000 | 6000 |
Follow-up Questions & Answers
Q1. What happens if you remove PARTITION BY from this query?
Removing PARTITION BY makes the window function treat all rows across all customers as a single group, so the running total accumulates across every customer combined in date order rather than resetting per customer. C01 and C02 transactions would bleed into each other based purely on txn_date ordering, which is meaningless for per customer spend tracking and a very easy mistake to make when writing window functions under time pressure.
-- This is wrong for per customer cumulative total -- but correct if you genuinely want one running total across all customers SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS global_running_total FROM transactions ORDER BY txn_date;
What to say: I always double check the PARTITION BY clause whenever I write a window function, because missing it is one of the most common and subtle bugs in window function queries. The query runs fine and returns numbers, but those numbers are silently wrong if the intent was per group accumulation. Reviewing what the PARTITION BY actually divides the data into is the first thing I verify when a window function result looks unexpected.
Q2. What is the difference between ROWS and RANGE in the window frame definition?
Both ROWS and RANGE define which rows are included in the window calculation relative to the current row, but they differ in how they handle ties in the ORDER BY column. ROWS treats each physical row as a distinct unit regardless of value, so ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW always includes exactly the rows up to and including the current physical row. RANGE treats all rows with the same ORDER BY value as a logical group, so if two transactions share the same txn_date, RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW includes both of them together in what it considers the current position, even if you are evaluating what feels like the first of the two rows. For a cumulative sum where txn_date can repeat, ROWS gives more predictable and deterministic results, which is why I explicitly write ROWS rather than relying on the default.
Q3. Two transactions for the same customer fall on the same date, how does that affect the cumulative sum?
If C01 had two transactions both on 2024-01-12, the cumulative sum result depends on the ROWS versus RANGE choice and whether there is a tiebreaker in the ORDER BY.
SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date, txn_id -- txn_id as tiebreaker ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions ORDER BY customer_id, txn_date, txn_id;
What to say: Adding txn_id as a secondary sort key in the ORDER BY gives every row a stable, deterministic position even when dates tie. This means the cumulative sum builds up one transaction at a time in a consistent order rather than potentially grouping same day transactions together as RANGE would. In practice I always add a unique column like txn_id as a tiebreaker inside any window function ORDER BY whenever the primary sort column is not guaranteed to be unique per partition.
Q4. How would you use this cumulative sum to find the transaction where each customer first crossed a spend threshold, say 8000?
A very practical extension that tests whether you can layer filtering logic on top of window function results.
WITH cumulative AS ( SELECT txn_id, customer_id, txn_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions ), first_crossing AS ( SELECT customer_id, txn_id, txn_date, amount, cumulative_spend, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY txn_date ) AS row_num FROM cumulative WHERE cumulative_spend >= 8000 ) SELECT customer_id, txn_id, txn_date, amount, cumulative_spend FROM first_crossing WHERE row_num = 1 ORDER BY customer_id;
What to say: I first compute the cumulative spend in one CTE, then in a second CTE I filter to only rows where the running total has crossed the threshold, and finally use ROW_NUMBER to pick just the first such row per customer since there could be multiple rows above the threshold. This chained CTE approach, compute then filter then pick first, is a pattern I use frequently whenever a window function result needs further row selection logic applied on top of it.
Q5. How would you calculate the cumulative sum as a percentage of that customer's total spend across all transactions, not just up to the current row?
This combines a running total with the overall total, giving a percentage of completion through each customer's full spending history.
WITH totals AS ( SELECT customer_id, SUM(amount) AS total_spend FROM transactions GROUP BY customer_id ), cumulative AS ( SELECT t.txn_id, t.customer_id, t.txn_date, t.amount, SUM(t.amount) OVER ( PARTITION BY t.customer_id ORDER BY t.txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_spend FROM transactions t ) SELECT c.txn_id, c.customer_id, c.txn_date, c.amount, c.cumulative_spend, ROUND( c.cumulative_spend * 100.0 / tot.total_spend, 1 ) AS pct_of_total FROM cumulative c JOIN totals tot ON c.customer_id = tot.customer_id ORDER BY c.customer_id, c.txn_date;
What to say: I compute each customer's grand total in one CTE using a plain GROUP BY, then compute the running total in a second CTE, and finally join them together to express the cumulative spend as a percentage of the full total. The last row for each customer will always show exactly one hundred percent since by that point the running total equals the grand total, which is a good sanity check that the query is working correctly.
Finding the First and Last Transaction per Customer
The Interviewer Says
We want to understand customer journey patterns. For each customer, I want to see their very first transaction and their most recent transaction in a single row, including the date and amount for both. This should be one row per customer in the output. Walk me through how you would approach this.
Input Table: transactions
| txn_id | customer_id | txn_date | amount |
|---|---|---|---|
| T01 | C01 | 2024-01-05 | 3000 |
| T02 | C01 | 2024-02-12 | 5000 |
| T03 | C01 | 2024-03-03 | 2000 |
| T04 | C02 | 2024-01-08 | 7000 |
| T05 | C02 | 2024-03-25 | 4000 |
| T06 | C03 | 2024-02-10 | 6000 |
How to Handle This in the Interview
Before writing anything, say: I need one row per customer with both the first and last transaction details side by side. There are two solid ways to approach this, a GROUP BY with conditional aggregation, or window functions with FIRST_VALUE and LAST_VALUE. Let me think about which is cleaner for this specific output shape.
Step 1: Consider the GROUP BY approach
- A plain GROUP BY with MIN and MAX on txn_date gives the earliest and latest dates per customer. But getting the amount that corresponds to those dates is not straightforward with just GROUP BY, since
MIN(txn_date)andMIN(amount)are completely independent aggregations that may not point to the same row.
Step 2: Understand why FIRST_VALUE and LAST_VALUE are cleaner here
- Window functions let you fetch the actual value from a specific row within an ordered partition, not just the minimum or maximum of a column independently. This means you can reliably pull both the date and the amount from the true first or last row together.
Step 3: Watch the LAST_VALUE frame trap
- Say: LAST_VALUE has a very common trap. By default the window frame only goes up to the current row, not to the end of the partition. So without explicitly extending the frame to ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, LAST_VALUE just returns the current row's own value rather than the true last row in the partition. This is one of the most common window function bugs I have seen.
Step 4: Deduplicate at the end
- Since window functions return one result per row, not one per customer, I need to deduplicate at the end using DISTINCT or ROW_NUMBER to collapse back to one row per customer.
Solution
SQL Server
WITH customer_txns AS ( SELECT customer_id, txn_date, amount, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_date, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_amount, LAST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_date, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_amount FROM transactions ) SELECT DISTINCT customer_id, first_txn_date, first_txn_amount, last_txn_date, last_txn_amount FROM customer_txns ORDER BY customer_id;
PostgreSQL
WITH customer_txns AS ( SELECT customer_id, txn_date, amount, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_date, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_amount, LAST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_date, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_amount FROM transactions ) SELECT DISTINCT customer_id, first_txn_date, first_txn_amount, last_txn_date, last_txn_amount FROM customer_txns ORDER BY customer_id;
MySQL 8+
WITH customer_txns AS ( SELECT customer_id, txn_date, amount, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_date, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_amount, LAST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_date, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_amount FROM transactions ) SELECT DISTINCT customer_id, first_txn_date, first_txn_amount, last_txn_date, last_txn_amount FROM customer_txns ORDER BY customer_id;
The syntax is identical across all three platforms.
What to say: I explicitly set the window frame to ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING for both FIRST_VALUE and LAST_VALUE. This is critical for LAST_VALUE specifically, without this the default frame only reaches up to the current row, so LAST_VALUE would just return the current row's own value every time rather than the true last row in the partition. Once the window functions compute the first and last values for every row, I use DISTINCT to collapse down to one row per customer since every row within the same customer partition carries identical first and last values.
Expected Output
| customer_id | first_txn_date | first_txn_amount | last_txn_date | last_txn_amount |
|---|---|---|---|---|
| C01 | 2024-01-05 | 3000 | 2024-03-03 | 2000 |
| C02 | 2024-01-08 | 7000 | 2024-03-25 | 4000 |
| C03 | 2024-02-10 | 6000 | 2024-02-10 | 6000 |
Follow-up Questions & Answers
Q1. Can you solve this using GROUP BY with MIN and MAX instead of window functions?
Yes, and for many interviewers this is actually the simpler and preferred approach when the requirement is just dates. The difficulty is getting the corresponding amount for each date cleanly.
WITH first_last_dates AS ( SELECT customer_id, MIN(txn_date) AS first_txn_date, MAX(txn_date) AS last_txn_date FROM transactions GROUP BY customer_id ) SELECT fld.customer_id, fld.first_txn_date, t1.amount AS first_txn_amount, fld.last_txn_date, t2.amount AS last_txn_amount FROM first_last_dates fld JOIN transactions t1 ON fld.customer_id = t1.customer_id AND fld.first_txn_date = t1.txn_date JOIN transactions t2 ON fld.customer_id = t2.customer_id AND fld.last_txn_date = t2.txn_date ORDER BY fld.customer_id;
What to say: I first compute the min and max date per customer in a CTE, then join back to the original transactions table twice, once to get the amount for the first date and once for the last date. This works cleanly when dates are unique per customer, but if two transactions share the same date and both happen to be the minimum date, the join returns multiple rows for that customer, so a further deduplication step would be needed. The FIRST_VALUE approach avoids this ambiguity entirely.
Q2. What is the LAST_VALUE default frame trap you mentioned, can you explain it more clearly?
This is one of the most misunderstood behaviours in window functions and interviewers bring it up specifically because most people get it wrong until they have been burned by it once. When you write LAST_VALUE(amount) OVER (PARTITION BY customer_id ORDER BY txn_date) without any explicit frame, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This means for every row, the window only sees from the beginning of the partition up to and including that specific row. So LAST_VALUE at each row simply returns that row's own value, since the current row is by definition the last row the window can see with that default frame. To make LAST_VALUE actually return the final row in the entire partition, you must explicitly extend the frame to UNBOUNDED FOLLOWING.
What to say: This catches almost everyone the first time they use LAST_VALUE in production. The query runs without any error and returns plausible looking numbers, but those numbers are silently wrong. I make it a rule to always write the frame clause explicitly whenever I use LAST_VALUE rather than trusting the default, since the default is almost never what you actually want with that function.
Q3. How would you also calculate the number of days between the first and last transaction for each customer?
A natural business metric alongside the first and last dates, it tells you how long each customer has been active.
WITH customer_txns AS ( SELECT customer_id, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_date, LAST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_date FROM transactions ) SELECT DISTINCT customer_id, first_txn_date, last_txn_date, DATEDIFF(DAY, first_txn_date, last_txn_date) AS active_days FROM customer_txns ORDER BY customer_id;
What to say: DATEDIFF between the last and first transaction dates gives the customer's active lifespan in days. A customer like C03 who only has one transaction would show zero active days since both dates are the same, which is accurate and also a useful signal that this customer has only ever interacted once. In a real retention analysis, customers with zero active days are single purchase customers and might be a separate segment worth targeting differently.
Q4. What if a customer has only one transaction, does this query still work correctly?
Yes, and C03 in the example data actually demonstrates this. When a customer has only one transaction, FIRST_VALUE and LAST_VALUE both point to that same single row, so first_txn_date equals last_txn_date and first_txn_amount equals last_txn_amount. The query handles single transaction customers naturally without any special case handling needed, which is one of the reasons the window function approach is cleaner than trying to handle edge cases manually.
Q5. How would you extend this to also show the transaction with the highest amount per customer, not just the first and last chronologically?
This adds a third dimension alongside first and last, the peak transaction, which is genuinely useful for understanding customer behaviour patterns.
WITH customer_txns AS ( SELECT customer_id, txn_date, amount, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_date, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_txn_amount, LAST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_date, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY txn_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_txn_amount, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY amount DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS highest_txn_amount, FIRST_VALUE(txn_date) OVER ( PARTITION BY customer_id ORDER BY amount DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS highest_txn_date FROM transactions ) SELECT DISTINCT customer_id, first_txn_date, first_txn_amount, last_txn_date, last_txn_amount, highest_txn_date, highest_txn_amount FROM customer_txns ORDER BY customer_id;
What to say: I add two more FIRST_VALUE calls but this time ordered by amount descending rather than date, so that the first row in this ordering is the one with the highest amount. This shows how flexible window functions are, the same FIRST_VALUE function gives completely different results simply by changing what the ORDER BY inside the window points to, without changing the overall query structure at all.
Finding Consecutive Login Streaks
The Interviewer Says
We have a user activity table that records the dates when each user logged into our platform. The product team wants to find the longest consecutive login streak for each user, meaning the maximum number of days in a row they logged in without any gap. Walk me through how you would think about this problem.
Input Table: user_logins
| user_id | login_date |
|---|---|
| U01 | 2024-01-01 |
| U01 | 2024-01-02 |
| U01 | 2024-01-03 |
| U01 | 2024-01-05 |
| U01 | 2024-01-06 |
| U02 | 2024-01-01 |
| U02 | 2024-01-02 |
| U02 | 2024-01-04 |
| U02 | 2024-01-05 |
| U02 | 2024-01-06 |
How to Handle This in the Interview
Before writing anything, say: This is a classic islands problem. I need to group consecutive dates together into streaks, find the length of each streak, then return the maximum streak length per user. The key trick for grouping consecutive dates is subtracting a row number from the date itself.
Step 1: Understand the consecutive date grouping trick
- If you subtract a sequential row number from a date for dates that are truly consecutive, the result stays constant across that entire run. The moment there is a gap of even one day, the subtraction produces a different constant, effectively creating a new group. This is the core insight behind solving any consecutive sequence problem.
Step 2: Handle duplicate login dates
- Say: A user could theoretically log in multiple times in one day and produce duplicate rows. I would deduplicate login dates per user before applying any streak logic, otherwise the row number calculation would break.
Step 3: Group by user and the computed streak identifier
- Once the streak group identifier is computed, GROUP BY user_id and that identifier, then count the rows in each group to get the streak length.
Step 4: Take the maximum streak per user
- Wrap the streak length calculation in another aggregation to get the single longest streak per user.
Solution
SQL Server
WITH distinct_logins AS ( SELECT DISTINCT user_id, login_date FROM user_logins ), streak_groups AS ( SELECT user_id, login_date, DATEADD( DAY, -ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date), login_date ) AS streak_id FROM distinct_logins ), streak_lengths AS ( SELECT user_id, streak_id, COUNT(*) AS streak_length FROM streak_groups GROUP BY user_id, streak_id ) SELECT user_id, MAX(streak_length) AS longest_streak FROM streak_lengths GROUP BY user_id ORDER BY user_id;
PostgreSQL
WITH distinct_logins AS ( SELECT DISTINCT user_id, login_date FROM user_logins ), streak_groups AS ( SELECT user_id, login_date, login_date - CAST( ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS INTEGER ) * INTERVAL '1 day' AS streak_id FROM distinct_logins ), streak_lengths AS ( SELECT user_id, streak_id, COUNT(*) AS streak_length FROM streak_groups GROUP BY user_id, streak_id ) SELECT user_id, MAX(streak_length) AS longest_streak FROM streak_lengths GROUP BY user_id ORDER BY user_id;
MySQL 8+
WITH distinct_logins AS ( SELECT DISTINCT user_id, login_date FROM user_logins ), streak_groups AS ( SELECT user_id, login_date, DATE_SUB( login_date, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) DAY ) AS streak_id FROM distinct_logins ), streak_lengths AS ( SELECT user_id, streak_id, COUNT(*) AS streak_length FROM streak_groups GROUP BY user_id, streak_id ) SELECT user_id, MAX(streak_length) AS longest_streak FROM streak_lengths GROUP BY user_id ORDER BY user_id;
The core logic is identical across all three platforms. Only the date arithmetic syntax differs slightly, DATEADD in SQL Server, interval arithmetic in PostgreSQL, and DATE_SUB in MySQL.
What to say: The key insight is that for truly consecutive dates, subtracting a row number from the date produces the same constant value across that entire run. The moment there is a one day gap, the subtraction shifts to a different constant, naturally creating a new group identity. I deduplicate login dates first to handle multiple logins on the same day cleanly, then use three layered CTEs to go from raw dates to streak groups to streak lengths and finally to the maximum per user.
Expected Output
| user_id | longest_streak |
|---|---|
| U01 | 3 |
| U02 | 3 |
Follow-up Questions & Answers
Q1. Can you explain the date minus row number trick in more detail with a concrete example?
Take U01 who logged in on January 1, 2, 3, then skipped January 4, then logged in again on January 5 and 6. The row numbers assigned in date order are 1, 2, 3, 4, 5. When you subtract those row numbers from the dates you get December 31, December 31, December 31 for the first three rows since January 1 minus 1, January 2 minus 2, and January 3 minus 3 all equal December 31. Then January 5 minus 4 gives January 1, and January 6 minus 5 also gives January 1. So December 31 is the streak group identifier for the first run of three days, and January 1 is the streak group identifier for the second run of two days. The grouping emerges naturally from the arithmetic without any explicit comparison between adjacent rows.
Q2. Why did you deduplicate first, what actually breaks if you do not?
Without deduplication, if a user logged in twice on January 2, that date gets two rows in the table. The row numbers assigned in date order would be 1, 2, 3 for January 1, January 2 first occurrence, January 2 second occurrence. Subtracting those from the dates gives December 31, December 31, December 30 respectively. The two January 2 rows produce different group identifiers, which means the second occurrence of January 2 gets counted as the start of a completely new streak rather than part of the existing one. The streak lengths then come out wrong since the group boundaries are in the wrong places.
Q3. How would you also show which specific date range each longest streak covers, not just the count?
The streak groups CTE already has the information needed, since each streak_id group has a minimum and maximum login date that define its boundaries.
WITH distinct_logins AS ( SELECT DISTINCT user_id, login_date FROM user_logins ), streak_groups AS ( SELECT user_id, login_date, DATEADD( DAY, -ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date), login_date ) AS streak_id FROM distinct_logins ), streak_lengths AS ( SELECT user_id, streak_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_length FROM streak_groups GROUP BY user_id, streak_id ), ranked_streaks AS ( SELECT user_id, streak_start, streak_end, streak_length, RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS streak_rank FROM streak_lengths ) SELECT user_id, streak_start, streak_end, streak_length FROM ranked_streaks WHERE streak_rank = 1 ORDER BY user_id;
What to say: I add MIN and MAX of login_date inside the streak_lengths grouping to capture the start and end of each streak naturally. Then I use RANK ordered by streak_length descending to find the longest streak per user, and filter for rank one at the end. RANK rather than ROW_NUMBER here means if a user has two streaks of the exact same maximum length, both appear in the output, which is probably the correct business behaviour rather than arbitrarily picking just one.
Q4. What if a user only logged in once across all of history, is their longest streak correctly reported as one?
Yes, and this is worth confirming out loud. A single login row produces one group with one row in it, so COUNT gives one and MAX of streak lengths across all groups for that user also gives one. There is no special case needed, the query handles single login users naturally through the same logic as everyone else.
Q5. How would you find all users who currently have an active streak, meaning their most recent login was yesterday or today?
This adds a recency filter to the streak problem, which is genuinely useful for a product team wanting to know who to target with a streak continuation notification.
WITH distinct_logins AS ( SELECT DISTINCT user_id, login_date FROM user_logins ), streak_groups AS ( SELECT user_id, login_date, DATEADD( DAY, -ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date), login_date ) AS streak_id FROM distinct_logins ), streak_lengths AS ( SELECT user_id, streak_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_length FROM streak_groups GROUP BY user_id, streak_id ) SELECT user_id, streak_start, streak_end, streak_length FROM streak_lengths WHERE streak_end >= DATEADD(DAY, -1, CAST(GETDATE() AS DATE)) ORDER BY streak_length DESC;
What to say: I filter the streak boundaries to only those where the streak end date is yesterday or more recent, meaning the streak is still alive and has not broken yet. In a real production pipeline this query would run every morning and feed directly into a notification service to prompt users to log in today and keep their streak going, which is a common retention mechanic in consumer apps. I would also confirm with the product team whether a streak ending today counts as active or whether only yesterday qualifies, since that single day boundary can matter a lot depending on what time zone the users are in relative to the server.
Unpivoting Columns into Rows
The Interviewer Says
Our data warehouse receives a quarterly sales report from the finance team in a wide format, one row per product with separate columns for each quarter. We need to transform this into a long format where each quarter becomes its own row, so it can be stored properly in our normalised reporting table and joined with other datasets. Write a query to unpivot this data. Walk me through your thinking.
Input Table: quarterly_sales
| product_id | q1_revenue | q2_revenue | q3_revenue | q4_revenue |
|---|---|---|---|---|
| P01 | 50000 | 65000 | 70000 | 80000 |
| P02 | 30000 | 28000 | 35000 | 40000 |
| P03 | 45000 | 50000 | 55000 | 60000 |
How to Handle This in the Interview
Before writing anything, say: Unpivoting means turning column headers into row values, which is the reverse of what PIVOT does. There are two ways to do this in SQL Server, the native UNPIVOT syntax and a CROSS APPLY with VALUES approach. I prefer CROSS APPLY because it is more readable and more flexible, let me explain why.
Step 1: Understand what unpivoting actually does
- Each product currently has one row with four revenue columns. After unpivoting, each product should have four rows, one per quarter, with a column that holds the quarter name and another that holds the revenue value. The total row count goes from three rows to twelve rows.
Step 2: Choose between UNPIVOT and CROSS APPLY
- Native UNPIVOT syntax requires all the columns being unpivoted to have exactly the same data type, otherwise it throws an error. CROSS APPLY with VALUES is more forgiving and also lets you control the output labels more cleanly.
Step 3: Think about NULL handling
- Say: If any quarter column has a NULL revenue value, native UNPIVOT silently drops that row entirely by default. CROSS APPLY keeps it and lets you decide how to handle it. I always prefer to be explicit about NULL behaviour rather than letting the database silently drop rows without warning.
Step 4: Confirm what the output labels should look like
- Ask: Should the quarter column say q1_revenue or just Q1 or just 1? The business team probably wants clean labels rather than the raw column names from the source table.
Solution
SQL Server
-- Approach 1: CROSS APPLY with VALUES (preferred) SELECT product_id, quarter, revenue FROM quarterly_sales CROSS APPLY ( VALUES ('Q1', q1_revenue), ('Q2', q2_revenue), ('Q3', q3_revenue), ('Q4', q4_revenue) ) AS unpivoted(quarter, revenue) ORDER BY product_id, quarter; -- Approach 2: Native UNPIVOT syntax SELECT product_id, REPLACE(quarter, '_revenue', '') AS quarter, revenue FROM quarterly_sales UNPIVOT ( revenue FOR quarter IN ( q1_revenue, q2_revenue, q3_revenue, q4_revenue ) ) AS unpvt ORDER BY product_id, quarter;
PostgreSQL
-- PostgreSQL does not have native UNPIVOT syntax -- Use a CROSS JOIN LATERAL (equivalent of CROSS APPLY in SQL Server) SELECT product_id, quarter, revenue FROM quarterly_sales CROSS JOIN LATERAL ( VALUES ('Q1', q1_revenue), ('Q2', q2_revenue), ('Q3', q3_revenue), ('Q4', q4_revenue) ) AS unpivoted(quarter, revenue) ORDER BY product_id, quarter;
MySQL 8+
-- MySQL also does not have native UNPIVOT syntax -- Use a CROSS JOIN with a derived table of VALUES SELECT qs.product_id, u.quarter, u.revenue FROM quarterly_sales qs CROSS JOIN ( SELECT 'Q1' AS quarter UNION ALL SELECT 'Q2' UNION ALL SELECT 'Q3' UNION ALL SELECT 'Q4' ) AS quarters(quarter) JOIN ( SELECT product_id, 'Q1' AS quarter, q1_revenue AS revenue FROM quarterly_sales UNION ALL SELECT product_id, 'Q2', q2_revenue FROM quarterly_sales UNION ALL SELECT product_id, 'Q3', q3_revenue FROM quarterly_sales UNION ALL SELECT product_id, 'Q4', q4_revenue FROM quarterly_sales ) AS u ON qs.product_id = u.product_id AND quarters.quarter = u.quarter ORDER BY qs.product_id, u.quarter;
SQL Server supports both CROSS APPLY with VALUES and native UNPIVOT. PostgreSQL uses CROSS JOIN LATERAL which is functionally identical to CROSS APPLY. MySQL requires a UNION ALL based approach since it supports neither UNPIVOT nor LATERAL joins in older versions, though MySQL 8.0.14 and above added LATERAL support.
What to say: I prefer the CROSS APPLY with VALUES approach in SQL Server because it gives me full control over what the quarter labels look like in the output, I do not have to post process the raw column names with REPLACE or SUBSTRING. It also handles NULL revenue values without silently dropping rows, and it reads more clearly since the column to row mapping is explicitly listed as key value pairs in the VALUES clause.
Expected Output
| product_id | quarter | revenue |
|---|---|---|
| P01 | Q1 | 50000 |
| P01 | Q2 | 65000 |
| P01 | Q3 | 70000 |
| P01 | Q4 | 80000 |
| P02 | Q1 | 30000 |
| P02 | Q2 | 28000 |
| P02 | Q3 | 35000 |
| P02 | Q4 | 40000 |
| P03 | Q1 | 45000 |
| P03 | Q2 | 50000 |
| P03 | Q3 | 55000 |
| P03 | Q4 | 60000 |
Follow-up Questions & Answers
Q1. What is the difference between CROSS APPLY and CROSS JOIN in SQL Server?
CROSS JOIN produces a cartesian product between two independent tables or derived tables, every row from the left side combined with every row from the right side, where the right side does not reference anything from the left side. CROSS APPLY is specifically designed to let the right side reference columns from the left side row by row, making it a row by row function application rather than a static cartesian product. In the unpivot use case, the VALUES clause inside CROSS APPLY references q1_revenue, q2_revenue and so on directly from the current row of quarterly_sales, which CROSS JOIN cannot do since the right side of a CROSS JOIN must be fully independent of the left side.
Q2. What happens to NULL values with native UNPIVOT versus CROSS APPLY?
This is one of the most practically important differences between the two approaches.
-- With native UNPIVOT: NULL rows are silently dropped -- If q3_revenue for P02 is NULL, that row simply disappears from results -- With CROSS APPLY: NULLs are kept by default -- If q3_revenue for P02 is NULL, the row appears with revenue = NULL SELECT product_id, quarter, revenue FROM quarterly_sales CROSS APPLY ( VALUES ('Q1', q1_revenue), ('Q2', q2_revenue), ('Q3', q3_revenue), ('Q4', q4_revenue) ) AS unpivoted(quarter, revenue) WHERE revenue IS NOT NULL -- add this only if you explicitly want to exclude NULLs ORDER BY product_id, quarter;
What to say: Silent NULL dropping by native UNPIVOT is a real production hazard. Imagine you are unpivoting twelve months of revenue data and one month genuinely had zero sales recorded as NULL, native UNPIVOT would drop that month's row entirely, making it look like you simply have no data for that month rather than explicitly zero revenue. With CROSS APPLY I keep the NULL row and can decide intentionally whether to filter it out, replace it with zero using COALESCE, or flag it for investigation.
Q3. How would you unpivot this if there were ten or twenty columns instead of just four?
For a large number of columns, writing out each one manually in the VALUES clause becomes impractical. This calls for dynamic SQL in SQL Server.
DECLARE @cols NVARCHAR(MAX) = ''; DECLARE @vals NVARCHAR(MAX) = ''; DECLARE @sql NVARCHAR(MAX) = ''; SELECT @cols += ',' + QUOTENAME(COLUMN_NAME), @vals += ',(' + QUOTENAME(COLUMN_NAME) + ',' + QUOTENAME(COLUMN_NAME) + ')' FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'quarterly_sales' AND COLUMN_NAME != 'product_id' ORDER BY ORDINAL_POSITION; SET @cols = STUFF(@cols, 1, 1, ''); SET @vals = STUFF(@vals, 1, 1, ''); SET @sql = ' SELECT product_id, quarter, revenue FROM quarterly_sales CROSS APPLY ( VALUES ' + @vals + ' ) AS unpivoted(quarter, revenue) ORDER BY product_id, quarter;'; EXEC sp_executesql @sql;
What to say: I use INFORMATION_SCHEMA.COLUMNS to dynamically discover all revenue columns in the table other than product_id, build the VALUES list programmatically, and execute the full unpivot as a dynamic SQL string. The advantage is that adding a new quarter column to the source table does not require any changes to this query, it picks up the new column automatically on the next run. I would make sure to validate the table and column names come from a trusted internal source before using them in dynamic SQL to avoid any injection risk.
Q4. Once unpivoted, how would you immediately insert this data into a normalised target table?
This is the realistic production scenario, the unpivot is usually just the first step before loading into a properly structured warehouse table.
-- Create the target table if it does not exist CREATE TABLE quarterly_sales_normalised ( product_id VARCHAR(10), quarter VARCHAR(5), revenue DECIMAL(18, 2), loaded_at DATETIME DEFAULT GETDATE() ); -- Insert the unpivoted data directly INSERT INTO quarterly_sales_normalised (product_id, quarter, revenue) SELECT product_id, quarter, revenue FROM quarterly_sales CROSS APPLY ( VALUES ('Q1', q1_revenue), ('Q2', q2_revenue), ('Q3', q3_revenue), ('Q4', q4_revenue) ) AS unpivoted(quarter, revenue) WHERE revenue IS NOT NULL;
What to say: Chaining an INSERT INTO directly with the CROSS APPLY unpivot is a clean single statement load with no intermediate staging needed. I add a loaded_at timestamp column with a default of GETDATE so every row automatically records when it was inserted, which is a basic but important audit trail in any data warehouse pipeline. I would also consider wrapping this in a transaction with explicit error handling in a stored procedure so that if any row fails to insert, the entire batch rolls back cleanly rather than leaving a partially loaded state in the target table.
Q5. How would you verify after the unpivot that no data was lost or incorrectly transformed?
Data validation after a transformation is something interviewers genuinely want to hear you mention, since it signals you think about data quality beyond just writing the transformation itself.
-- Check 1: Row count should be source rows multiplied by number of quarters SELECT COUNT(*) AS source_rows FROM quarterly_sales; SELECT COUNT(*) AS unpivoted_rows FROM quarterly_sales_normalised; -- Check 2: Total revenue should match between source and target SELECT SUM(q1_revenue + q2_revenue + q3_revenue + q4_revenue) AS source_total FROM quarterly_sales; SELECT SUM(revenue) AS target_total FROM quarterly_sales_normalised; -- Check 3: Every product should appear exactly four times in the target SELECT product_id, COUNT(*) AS quarter_count FROM quarterly_sales_normalised GROUP BY product_id HAVING COUNT(*) != 4;
What to say: I run three checks after every unpivot load. First the row count check confirms no rows were silently dropped or duplicated. Second the revenue sum reconciliation confirms no values were altered or lost during the transformation. Third the per product quarter count check catches any product that ended up with fewer or more than the expected four quarters, which would indicate a structural problem in either the source data or the unpivot logic. In a production pipeline I would automate these checks and fail the job with an alert if any of them do not pass, rather than discovering problems only when someone notices wrong numbers in a dashboard.
Comparing Source and Target Tables for Data Reconciliation
The Interviewer Says
This is a very real scenario you will face constantly as a data engineer. We have two tables, a source table and a target table, that are supposed to contain the same data after a pipeline run. Write a query that compares the two tables and tells us exactly what is different, whether a record exists only in the source, only in the target, or exists in both but with a mismatched value in one of the columns. Walk me through your approach.
Input Table: source_customers
| customer_id | name | city |
|---|---|---|
| C01 | Raj Kumar | Mumbai |
| C02 | Priya Singh | Delhi |
| C03 | Anil Sharma | Pune |
| C04 | Meena Iyer | Chennai |
Input Table: target_customers
| customer_id | name | city |
|---|---|---|
| C01 | Raj Kumar | Mumbai |
| C02 | Priya Singh | Bangalore |
| C04 | Meena Iyer | Chennai |
| C05 | Suresh Nair | Hyderabad |
How to Handle This in the Interview
Before writing anything, say: Data reconciliation between source and target is one of the most practical and frequently asked data engineering scenarios. There are three possible differences I need to detect and I want to handle all three in a single query.
Step 1: Identify the three categories of difference
- A record exists in source but not in target, meaning it was not loaded. A record exists in target but not in source, meaning it was loaded incorrectly or is a phantom record. A record exists in both but one or more column values do not match, meaning the transformation corrupted or changed the data.
Step 2: Choose FULL OUTER JOIN as the core approach
- Say: A FULL OUTER JOIN between source and target on the primary key returns all rows from both sides regardless of whether a match exists. Rows with NULL on the right side are source only. Rows with NULL on the left side are target only. Rows where both sides are present but values differ are mismatches.
Step 3: Use COALESCE on the key column
- Since either side could be NULL for unmatched rows, use COALESCE on customer_id to always display a non NULL key in the output regardless of which side the record came from.
Step 4: Think about how to scale this to many columns
- Ask: How many columns need to be compared? For two or three columns I can write explicit CASE WHEN comparisons. For a table with fifty columns this approach needs dynamic SQL or a hashing strategy, which I can explain if needed.
Solution
SQL Server
SELECT COALESCE(s.customer_id, t.customer_id) AS customer_id, s.name AS source_name, t.name AS target_name, s.city AS source_city, t.city AS target_city, CASE WHEN t.customer_id IS NULL THEN 'Missing in target' WHEN s.customer_id IS NULL THEN 'Extra in target' WHEN s.name <> t.name OR s.city <> t.city THEN 'Mismatch' END AS reconciliation_status FROM source_customers s FULL OUTER JOIN target_customers t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR s.name <> t.name OR s.city <> t.city ORDER BY customer_id;
PostgreSQL
SELECT COALESCE(s.customer_id, t.customer_id) AS customer_id, s.name AS source_name, t.name AS target_name, s.city AS source_city, t.city AS target_city, CASE WHEN t.customer_id IS NULL THEN 'Missing in target' WHEN s.customer_id IS NULL THEN 'Extra in target' WHEN s.name <> t.name OR s.city <> t.city THEN 'Mismatch' END AS reconciliation_status FROM source_customers s FULL OUTER JOIN target_customers t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR s.name <> t.name OR s.city <> t.city ORDER BY customer_id;
MySQL 8+
-- MySQL does not support FULL OUTER JOIN natively -- Simulate it using LEFT JOIN UNION ALL RIGHT JOIN SELECT COALESCE(s.customer_id, t.customer_id) AS customer_id, s.name AS source_name, t.name AS target_name, s.city AS source_city, t.city AS target_city, CASE WHEN t.customer_id IS NULL THEN 'Missing in target' WHEN s.customer_id IS NULL THEN 'Extra in target' WHEN s.name <> t.name OR s.city <> t.city THEN 'Mismatch' END AS reconciliation_status FROM source_customers s LEFT JOIN target_customers t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.name <> t.name OR s.city <> t.city UNION ALL SELECT t.customer_id, s.name, t.name, s.city, t.city, 'Extra in target' FROM target_customers t LEFT JOIN source_customers s ON t.customer_id = s.customer_id WHERE s.customer_id IS NULL ORDER BY customer_id;
SQL Server and PostgreSQL both support FULL OUTER JOIN natively making the query clean and straightforward. MySQL does not support FULL OUTER JOIN so it must be simulated using a LEFT JOIN combined with a UNION ALL of a RIGHT JOIN or another LEFT JOIN with the tables reversed.
What to say: The FULL OUTER JOIN brings all rows from both sides into one result set regardless of whether a match exists. I then filter to keep only the rows where something is different, either a missing key on one side or a value mismatch in the data columns. COALESCE on customer_id ensures the output always shows a non NULL identifier even when the row only exists on one side, which makes the result far more readable for whoever is investigating the discrepancy.
Expected Output
| customer_id | source_name | target_name | source_city | target_city | reconciliation_status |
|---|---|---|---|---|---|
| C02 | Priya Singh | Priya Singh | Delhi | Bangalore | Mismatch |
| C03 | Anil Sharma | NULL | Pune | NULL | Missing in target |
| C05 | NULL | Suresh Nair | NULL | Hyderabad | Extra in target |
Follow-up Questions & Answers
Q1. How would you handle NULL values in the comparison columns, does your WHERE clause correctly catch a mismatch where one side is NULL and the other is not?
This is an important edge case. The condition s.city <> t.city evaluates to UNKNOWN rather than TRUE when either side is NULL, which means a row where source city is NULL but target city is not NULL would silently pass through without being flagged as a mismatch. The fix is to handle NULL comparisons explicitly.
WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR s.name <> t.name OR s.city <> t.city OR (s.name IS NULL AND t.name IS NOT NULL) OR (s.name IS NOT NULL AND t.name IS NULL) OR (s.city IS NULL AND t.city IS NOT NULL) OR (s.city IS NOT NULL AND t.city IS NULL)
What to say: Or more concisely I can use a helper pattern that treats NULLs as comparable values using a combination of IS DISTINCT FROM in PostgreSQL, which handles NULL equality natively, or ISNULL in SQL Server to replace NULLs with a sentinel value before comparing.
-- SQL Server: use ISNULL to treat NULL as a comparable sentinel WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR ISNULL(s.name, '') <> ISNULL(t.name, '') OR ISNULL(s.city, '') <> ISNULL(t.city, '') -- PostgreSQL: IS DISTINCT FROM handles NULLs natively WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR s.name IS DISTINCT FROM t.name OR s.city IS DISTINCT FROM t.city
Q2. How would you approach this reconciliation if the table had fifty columns instead of just two?
Writing out fifty explicit column comparisons is impractical and fragile. A much better approach for wide tables is to hash the entire row and compare hashes.
SELECT COALESCE(s.customer_id, t.customer_id) AS customer_id, CASE WHEN t.customer_id IS NULL THEN 'Missing in target' WHEN s.customer_id IS NULL THEN 'Extra in target' ELSE 'Mismatch' END AS reconciliation_status FROM ( SELECT customer_id, HASHBYTES('SHA2_256', CONCAT(customer_id, '|', name, '|', city) ) AS row_hash FROM source_customers ) s FULL OUTER JOIN ( SELECT customer_id, HASHBYTES('SHA2_256', CONCAT(customer_id, '|', name, '|', city) ) AS row_hash FROM target_customers ) t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR s.row_hash <> t.row_hash;
What to say: HASHBYTES computes a SHA256 hash of the entire concatenated row in SQL Server. If any column value changes at all, the hash changes, so a single hash comparison catches any kind of mismatch regardless of which specific column caused it. The trade off is that hashing tells you a mismatch exists but not which specific column changed, so for investigation you still need a follow up query drilling into flagged rows. In practice I would use hashing as a fast first pass to identify which records have any discrepancy, then run a detailed column by column comparison only on those specific records rather than across the entire table.
Q3. How would you use EXCEPT to find differences instead of a FULL OUTER JOIN?
EXCEPT is a clean alternative when you want to find rows present in one set but absent from another, treating the full row as the unit of comparison.
-- Rows in source that have no exact match in target SELECT customer_id, name, city FROM source_customers EXCEPT SELECT customer_id, name, city FROM target_customers UNION ALL -- Rows in target that have no exact match in source SELECT customer_id, name, city FROM target_customers EXCEPT SELECT customer_id, name, city FROM source_customers;
What to say: EXCEPT compares full rows, so a row that exists in both tables but with one changed column will appear in both halves of this UNION ALL since it no longer exactly matches on either side. This approach is extremely concise and works well for spotting any kind of difference, though it does not directly label whether a row is missing, extra, or mismatched the way the FULL OUTER JOIN approach does. I would choose EXCEPT when I just need a quick check that any difference exists, and the FULL OUTER JOIN approach when I need a properly labelled reconciliation report that stakeholders can act on directly.
Q4. How would you productionise this reconciliation check as part of a data pipeline?
What to say: In a real pipeline I would not run this as a one off query but wrap it into an automated validation step that runs after every load and fails the pipeline if any discrepancy is found above a defined threshold.
-- Write discrepancies to an audit table for tracking over time INSERT INTO reconciliation_log ( run_date, customer_id, reconciliation_status, source_name, target_name, source_city, target_city ) SELECT GETDATE(), COALESCE(s.customer_id, t.customer_id), CASE WHEN t.customer_id IS NULL THEN 'Missing in target' WHEN s.customer_id IS NULL THEN 'Extra in target' ELSE 'Mismatch' END, s.name, t.name, s.city, t.city FROM source_customers s FULL OUTER JOIN target_customers t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR ISNULL(s.name, '') <> ISNULL(t.name, '') OR ISNULL(s.city, '') <> ISNULL(t.city, ''); -- Fail the pipeline if any discrepancies were found IF @@ROWCOUNT > 0 BEGIN RAISERROR('Reconciliation failed: discrepancies detected between source and target.', 16, 1); END
What to say: I write every discrepancy into a reconciliation_log table with a timestamp so there is a historical record of when data quality issues occurred and how often. The @@ROWCOUNT check after the INSERT tells me how many discrepancies were found in this run, and if it is greater than zero I use RAISERROR to surface an error that the orchestration layer, whether that is SQL Server Agent, Airflow, or something else, can catch and convert into an alert. Over time the log table becomes a valuable asset for trending data quality issues and identifying which upstream systems or transformation steps are most frequently causing problems.
Q5. How would you extend this to also capture which specific column caused the mismatch, not just that a mismatch exists?
When there are many columns, knowing which specific one changed saves a lot of investigation time.
WITH reconciled AS ( SELECT COALESCE(s.customer_id, t.customer_id) AS customer_id, s.name AS source_name, t.name AS target_name, s.city AS source_city, t.city AS target_city FROM source_customers s FULL OUTER JOIN target_customers t ON s.customer_id = t.customer_id WHERE t.customer_id IS NULL OR s.customer_id IS NULL OR ISNULL(s.name, '') <> ISNULL(t.name, '') OR ISNULL(s.city, '') <> ISNULL(t.city, '') ) SELECT customer_id, CASE WHEN target_name IS NULL AND target_city IS NULL THEN 'Missing in target' WHEN source_name IS NULL AND source_city IS NULL THEN 'Extra in target' ELSE CONCAT( CASE WHEN ISNULL(source_name,'') <> ISNULL(target_name,'') THEN 'name ' END, CASE WHEN ISNULL(source_city,'') <> ISNULL(target_city,'') THEN 'city ' END ) END AS mismatched_columns, source_name, target_name, source_city, target_city FROM reconciled ORDER BY customer_id;
What to say: I build a dynamic label string that concatenates the names of whichever columns are actually different for each row. So instead of just seeing Mismatch, the output says something like name city, telling the person investigating exactly where to look without having to manually scan across all columns for every flagged record. For a table with many columns I would generate this CONCAT string dynamically rather than writing each column out manually, following the same dynamic SQL approach discussed earlier.
Sessionisation of User Events
The Interviewer Says
This is a real problem every analytics engineer faces when working with clickstream or event data. We have a table of user events with timestamps. We want to group these events into sessions, where a new session starts whenever a user has been inactive for more than 30 minutes since their last event. Each session should get a unique session identifier. Walk me through how you would approach this.
Input Table: user_events
| event_id | user_id | event_time |
|---|---|---|
| E01 | U01 | 2024-03-01 09:00:00 |
| E02 | U01 | 2024-03-01 09:15:00 |
| E03 | U01 | 2024-03-01 09:45:00 |
| E04 | U01 | 2024-03-01 10:30:00 |
| E05 | U01 | 2024-03-01 10:45:00 |
| E06 | U02 | 2024-03-01 11:00:00 |
| E07 | U02 | 2024-03-01 11:20:00 |
| E08 | U02 | 2024-03-01 12:05:00 |
How to Handle This in the Interview
Before writing anything, say: Sessionisation is a classic event stream problem. The core challenge is that a session boundary is defined by a gap in time between consecutive events for the same user, not by any explicit marker in the data. I need to detect those gaps and then assign a session number that increments each time a new session starts.
Step 1: Find the time gap between consecutive events
- Use LAG to bring the previous event's timestamp alongside the current event, partitioned by user so events from different users never bleed into each other. Then calculate the gap in minutes between them.
Step 2: Flag where a new session starts
- A new session starts when the gap from the previous event exceeds 30 minutes, or when there is no previous event at all, meaning it is the user's first event. This gives a binary flag, 1 for new session start, 0 for continuation.
Step 3: Convert the flag into a cumulative session number
- A running SUM of the new session flag, partitioned by user and ordered by event time, naturally increments by one each time a new session starts and stays flat between sessions. This gives a per user session counter.
Step 4: Create a globally unique session identifier
- Concatenating user_id with the session counter gives a session identifier that is unique across all users, not just within one user's events.
Solution
SQL Server
WITH events_with_gap AS ( SELECT event_id, user_id, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time, DATEDIFF( MINUTE, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time), event_time ) AS gap_minutes FROM user_events ), session_flags AS ( SELECT event_id, user_id, event_time, gap_minutes, CASE WHEN gap_minutes IS NULL OR gap_minutes > 30 THEN 1 ELSE 0 END AS is_new_session FROM events_with_gap ), session_numbers AS ( SELECT event_id, user_id, event_time, gap_minutes, SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) AS session_number FROM session_flags ) SELECT event_id, user_id, event_time, gap_minutes, session_number, CONCAT(user_id, '_S', session_number) AS session_id FROM session_numbers ORDER BY user_id, event_time;
PostgreSQL
WITH events_with_gap AS ( SELECT event_id, user_id, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time, EXTRACT(EPOCH FROM ( event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) )) / 60 AS gap_minutes FROM user_events ), session_flags AS ( SELECT event_id, user_id, event_time, gap_minutes, CASE WHEN gap_minutes IS NULL OR gap_minutes > 30 THEN 1 ELSE 0 END AS is_new_session FROM events_with_gap ), session_numbers AS ( SELECT event_id, user_id, event_time, gap_minutes, SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) AS session_number FROM session_flags ) SELECT event_id, user_id, event_time, gap_minutes, session_number, CONCAT(user_id, '_S', session_number) AS session_id FROM session_numbers ORDER BY user_id, event_time;
MySQL 8+
WITH events_with_gap AS ( SELECT event_id, user_id, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time, TIMESTAMPDIFF( MINUTE, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time), event_time ) AS gap_minutes FROM user_events ), session_flags AS ( SELECT event_id, user_id, event_time, gap_minutes, CASE WHEN gap_minutes IS NULL OR gap_minutes > 30 THEN 1 ELSE 0 END AS is_new_session FROM events_with_gap ), session_numbers AS ( SELECT event_id, user_id, event_time, gap_minutes, SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) AS session_number FROM session_flags ) SELECT event_id, user_id, event_time, gap_minutes, session_number, CONCAT(user_id, '_S', session_number) AS session_id FROM session_numbers ORDER BY user_id, event_time;
The three CTE approach is identical across all platforms. The only differences are the time difference functions, DATEDIFF in SQL Server, EXTRACT EPOCH in PostgreSQL, and TIMESTAMPDIFF in MySQL.
What to say: I break this into three clean steps using CTEs. First I compute the gap in minutes between each event and the previous one using LAG. Second I flag each event as either starting a new session or continuing an existing one based on whether the gap exceeded thirty minutes. Third I take a running SUM of that flag partitioned by user, which naturally counts up by one each time a session boundary is crossed and stays flat within a session. The final session_id concatenates the user and session number to make it globally unique across all users.
Expected Output
| event_id | user_id | event_time | gap_minutes | session_number | session_id |
|---|---|---|---|---|---|
| E01 | U01 | 2024-03-01 09:00:00 | NULL | 1 | U01_S1 |
| E02 | U01 | 2024-03-01 09:15:00 | 15 | 1 | U01_S1 |
| E03 | U01 | 2024-03-01 09:45:00 | 30 | 1 | U01_S1 |
| E04 | U01 | 2024-03-01 10:30:00 | 45 | 2 | U01_S2 |
| E05 | U01 | 2024-03-01 10:45:00 | 15 | 2 | U01_S2 |
| E06 | U02 | 2024-03-01 11:00:00 | NULL | 1 | U02_S1 |
| E07 | U02 | 2024-03-01 11:20:00 | 20 | 1 | U02_S1 |
| E08 | U02 | 2024-03-01 12:05:00 | 45 | 2 | U02_S2 |
Follow-up Questions & Answers
Q1. E03 has a gap of exactly 30 minutes from E02. Your query kept it in session 1. What if the business rule is that 30 minutes exactly should trigger a new session?
This is a boundary condition the interviewer is likely testing deliberately. The current query uses gap_minutes greater than 30, meaning exactly 30 minutes is treated as still within the same session. Changing this to greater than or equal to 30 makes the boundary inclusive and starts a new session at exactly 30 minutes.
CASE WHEN gap_minutes IS NULL OR gap_minutes >= 30 THEN 1 ELSE 0 END AS is_new_session
What to say: I would always clarify this exact boundary with the product or analytics team before writing the query, since greater than and greater than or equal to give genuinely different session counts for events that fall right on the threshold. Getting this wrong changes the total session count in all downstream metrics, and a one character difference in the query can produce meaningfully different product insights.
Q2. How would you calculate session level summary statistics, like total duration and event count per session?
Once sessions are assigned, aggregating to the session level is a natural next step and almost always what the business actually wants to analyse.
WITH sessionised AS ( -- full sessionisation query from above SELECT event_id, user_id, event_time, CONCAT(user_id, '_S', SUM(CASE WHEN DATEDIFF(MINUTE, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time), event_time) > 30 OR LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) IS NULL THEN 1 ELSE 0 END) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) ) AS session_id FROM user_events ) SELECT user_id, session_id, MIN(event_time) AS session_start, MAX(event_time) AS session_end, DATEDIFF( MINUTE, MIN(event_time), MAX(event_time) ) AS session_duration_mins, COUNT(*) AS event_count FROM sessionised GROUP BY user_id, session_id ORDER BY user_id, session_id;
What to say: Once every event has a session_id attached to it, the session level summary is just a GROUP BY on user_id and session_id, using MIN and MAX of event_time for the boundaries and COUNT for the event volume. Session duration is the difference between the last and first event time within the session, not the gap to the next session start, which is an important distinction since there is usually dead time between one session ending and the next one beginning.
Q3. What if two events for the same user have the exact same timestamp, how does that affect the sessionisation?
Duplicate timestamps within the same user create ambiguity in the ORDER BY inside the LAG window function, similar to the tiebreaker problem discussed in earlier questions. If two events share an identical timestamp, their relative ordering is non deterministic, which means the gap calculation between them is unreliable.
-- Add event_id as a tiebreaker to make ordering deterministic LAG(event_time) OVER ( PARTITION BY user_id ORDER BY event_time, event_id )
What to say: Adding event_id as a secondary sort key inside every window function in this query guarantees a stable, deterministic row ordering even when timestamps tie. I would also raise this as a data quality question upstream, since two events from the same user at the exact same millisecond might indicate a logging bug worth investigating separately from the sessionisation logic itself.
Q4. How would you handle sessionisation if the timeout threshold could be different per user, stored in a separate user settings table?
This adds a join to make the timeout dynamic rather than hardcoded as 30 minutes for everyone.
WITH user_settings AS ( SELECT user_id, session_timeout_mins FROM user_preferences ), events_with_gap AS ( SELECT e.event_id, e.user_id, e.event_time, u.session_timeout_mins, DATEDIFF( MINUTE, LAG(e.event_time) OVER (PARTITION BY e.user_id ORDER BY e.event_time), e.event_time ) AS gap_minutes FROM user_events e JOIN user_settings u ON e.user_id = u.user_id ), session_flags AS ( SELECT event_id, user_id, event_time, gap_minutes, CASE WHEN gap_minutes IS NULL OR gap_minutes > session_timeout_mins THEN 1 ELSE 0 END AS is_new_session FROM events_with_gap ) SELECT event_id, user_id, event_time, gap_minutes, SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) AS session_number FROM session_flags ORDER BY user_id, event_time;
What to say: I join the user preferences table early in the first CTE so that each event row carries its own user specific timeout threshold alongside it. The CASE statement then compares gap_minutes against that user's own threshold rather than a hardcoded constant. The rest of the sessionisation logic stays exactly the same, only the threshold value changes per user. This pattern of joining configuration or reference data early in the CTE chain so it flows naturally through the rest of the query is something I use very often in real pipeline work.
Q5. How would this sessionisation approach scale if the user_events table had one billion rows?
What to say: At that scale the window function approach still works but performance becomes heavily dependent on how well the data is partitioned and indexed before the query runs.
-- Partition the table by user_id to co-locate each user's events -- This dramatically reduces the amount of data shuffled during -- the PARTITION BY user_id window function calculations CREATE TABLE user_events ( event_id VARCHAR(10), user_id VARCHAR(10), event_time DATETIME ) ON ps_user_partition_scheme(user_id); -- Clustered index on user_id and event_time together -- so events are physically sorted in the order the window function needs CREATE CLUSTERED INDEX idx_events_user_time ON user_events(user_id, event_time);
What to say: If events are physically sorted by user_id and event_time on disk through a clustered index, the window function can scan each user's events sequentially without sorting them first, which removes the most expensive step in the execution plan for large event tables. In a distributed system like Spark or a cloud warehouse like Snowflake or BigQuery, the equivalent would be partitioning the table by user_id in the storage layer so each worker node processes one user's complete event history without cross partition shuffles. The sessionisation logic itself stays identical, the optimisation is entirely in how the underlying data is organised before the query ever touches it.
Delete Duplicates Keeping the Latest Record
The Interviewer Says
We have a transactions table where the same transaction sometimes gets inserted more than once due to an upstream system bug. Each duplicate has a different txn_time since they arrive at slightly different moments. I want you to write a query that removes all duplicates and keeps only the row with the most recent txn_time for each account. Walk me through how you would handle this carefully.
Input Table: transactions
| txn_id | account_id | amount | txn_time |
|---|---|---|---|
| T01 | A01 | 5000 | 2024-03-01 09:00:00 |
| T02 | A01 | 5000 | 2024-03-01 09:05:00 |
| T03 | A01 | 5000 | 2024-03-01 09:10:00 |
| T04 | A02 | 8000 | 2024-03-01 10:00:00 |
| T05 | A02 | 8000 | 2024-03-01 10:03:00 |
| T06 | A03 | 3000 | 2024-03-01 11:00:00 |
How to Handle This in the Interview
Before writing anything, say: Anytime I am asked to delete data I follow a strict two step approach, write the SELECT version first to preview exactly which rows will be removed, confirm it looks right, then convert to DELETE. I never write a DELETE straight away on a deduplication problem.
Step 1: Define what makes a duplicate
- Say: I need to clarify with the interviewer what columns define a duplicate. Here account_id and amount together seem to define the logical duplicate, and among those duplicates I keep the one with the latest txn_time.
Step 2: Use ROW_NUMBER to rank within duplicate groups
- Partition by the columns that define a duplicate, here account_id and amount, and order by txn_time descending so the latest record gets row number 1. Everything with row number greater than 1 is a duplicate to remove.
Step 3: Note the difference from Question 14
- In Q14 we kept the earliest record using ascending order. Here we keep the latest using descending order. The logic is identical, only the sort direction changes, but the business reason is different. In transactions, the latest arrival is considered the most authoritative record.
Step 4: Always wrap in a transaction
- Say: For a production DELETE I would wrap this in a BEGIN TRANSACTION and ROLLBACK first to do a dry run, commit only after verifying the row count matches expectations.
Solution
SQL Server
-- Step 1: Always preview which rows will be deleted WITH ranked_txns AS ( SELECT txn_id, account_id, amount, txn_time, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) SELECT * FROM ranked_txns WHERE row_num > 1; -- Step 2: Dry run inside a transaction first BEGIN TRANSACTION; WITH ranked_txns AS ( SELECT txn_id, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) DELETE FROM ranked_txns WHERE row_num > 1; -- Check how many rows were affected SELECT @@ROWCOUNT AS rows_deleted; -- If count looks correct, commit. Otherwise rollback. -- COMMIT; ROLLBACK;
PostgreSQL
-- Step 1: Preview first WITH ranked_txns AS ( SELECT txn_id, account_id, amount, txn_time, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) SELECT * FROM ranked_txns WHERE row_num > 1; -- Step 2: Delete DELETE FROM transactions WHERE txn_id IN ( SELECT txn_id FROM ( SELECT txn_id, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) ranked WHERE row_num > 1 );
MySQL 8+
-- Step 1: Preview first WITH ranked_txns AS ( SELECT txn_id, account_id, amount, txn_time, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) SELECT * FROM ranked_txns WHERE row_num > 1; -- Step 2: Delete DELETE t FROM transactions t JOIN ( SELECT txn_id, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) ranked ON t.txn_id = ranked.txn_id WHERE ranked.row_num > 1;
SQL Server supports deleting directly from a CTE. PostgreSQL and MySQL require the ranked logic inside a subquery used to drive the DELETE.
What to say: The key difference from a standard deduplication is the ORDER BY direction inside ROW_NUMBER. Ordering by txn_time descending means the most recent record gets row number 1 and survives, while all earlier duplicates get higher row numbers and are deleted. I partition by both account_id and amount together since both columns together define what a logical duplicate looks like in this transaction context.
Expected Output — rows deleted
| txn_id | account_id | amount | txn_time |
|---|---|---|---|
| T01 | A01 | 5000 | 2024-03-01 09:00:00 |
| T02 | A01 | 5000 | 2024-03-01 09:05:00 |
| T05 | A02 | 8000 | 2024-03-01 10:03:00 |
Expected Output — rows remaining after delete
| txn_id | account_id | amount | txn_time |
|---|---|---|---|
| T03 | A01 | 5000 | 2024-03-01 09:10:00 |
| T04 | A02 | 8000 | 2024-03-01 10:00:00 |
| T06 | A03 | 3000 | 2024-03-01 11:00:00 |
Follow-up Questions & Answers
Q1. How is this different from Question 14 where we also removed duplicates?
The logic is structurally identical, both use ROW_NUMBER with PARTITION BY to define duplicate groups and filter for row_num greater than 1. The difference is purely in the ORDER BY direction and the business reason behind it. In Q14 we kept the earliest signup record because the first record for a customer is considered the source of truth. Here we keep the latest transaction record because the most recently arrived record is considered the most authoritative version in a transaction pipeline. Changing keep earliest to keep latest is a single change from ASC to DESC in the ORDER BY inside ROW_NUMBER.
Q2. What if txn_time is also identical across all duplicates, meaning truly exact copies with no timestamp difference?
If every duplicate has the exact same txn_time, the ROW_NUMBER ordering becomes non deterministic since all rows tie on the sort column. In that case any one of the duplicates can be chosen to survive since they are truly identical, but the tiebreaker needs to be deterministic to avoid inconsistent results across runs.
WITH ranked_txns AS ( SELECT txn_id, ROW_NUMBER() OVER ( PARTITION BY account_id, amount ORDER BY txn_time DESC, txn_id DESC -- txn_id breaks ties ) AS row_num FROM transactions ) DELETE FROM ranked_txns WHERE row_num > 1;
What to say: Adding txn_id as a secondary sort key makes the tiebreaker deterministic. If all duplicates are truly identical including txn_time, it does not matter which specific row survives since they carry the same data, but the query must always pick the same row consistently across runs to be reliable in a production pipeline. I would also raise the question of why exact duplicate timestamps are occurring upstream, since that might indicate the bug causing duplicates is inserting at the exact same moment, which has different implications than a retry pattern that arrives slightly later.
Q3. Before deleting, how would you first identify how bad the duplication problem is across the whole table?
Before touching any data in production I would run a quick diagnostic to understand the scope of the problem.
SELECT account_id, amount, COUNT(*) AS total_records, COUNT(*) - 1 AS duplicates_to_remove, MIN(txn_time) AS earliest_record, MAX(txn_time) AS latest_record FROM transactions GROUP BY account_id, amount HAVING COUNT(*) > 1 ORDER BY total_records DESC;
What to say: This diagnostic tells me three important things before I delete anything. First, which specific account and amount combinations have duplicates. Second, how many duplicate rows exist per group so I know the total rows that will be deleted. Third, the time spread between earliest and latest duplicate, a gap of a few seconds suggests a retry pattern, a gap of hours or days suggests something more serious like a full reload bug. I would share this analysis with the team before running the DELETE to make sure everyone agrees on what is about to be removed.
Q4. What if instead of deleting, the business wants to archive the duplicate rows into a separate table before removing them?
A very safe and common real world approach, especially in regulated industries where data cannot simply be destroyed without an audit trail.
-- Step 1: Archive duplicates first INSERT INTO transactions_duplicates_archive ( txn_id, account_id, amount, txn_time, archived_at ) SELECT txn_id, account_id, amount, txn_time, GETDATE() AS archived_at FROM ( SELECT txn_id, account_id, amount, txn_time, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) ranked WHERE row_num > 1; -- Step 2: Delete from main table only after archive is confirmed WITH ranked_txns AS ( SELECT txn_id, ROW_NUMBER() OVER (PARTITION BY account_id, amount ORDER BY txn_time DESC) AS row_num FROM transactions ) DELETE FROM ranked_txns WHERE row_num > 1;
What to say: I always advocate for archiving before deleting in production environments rather than destroying data outright. The archive table acts as a safety net in case the deduplication logic had an error, the data can be restored from the archive without needing to go back to a full backup. I add an archived_at timestamp so there is a clear record of when the cleanup happened, which is valuable for audit purposes and for understanding the timeline if questions arise later about missing records.
Q5. How would you prevent duplicate transactions from being inserted in the first place going forward?
Cleaning up existing duplicates solves today's problem but does not stop it happening again tomorrow.
-- Add a unique constraint on the columns that define a duplicate -- This prevents any future insert of a duplicate account_id and amount combination ALTER TABLE transactions ADD CONSTRAINT uq_transactions_account_amount UNIQUE (account_id, amount); -- If duplicates can exist across different days but not within the same day, -- a filtered unique index gives more granular control in SQL Server CREATE UNIQUE INDEX uix_transactions_daily ON transactions(account_id, amount, CAST(txn_time AS DATE));
What to say: A unique constraint is the database level guarantee that prevents the bug from causing harm even if it occurs again. However I would be careful about what columns to include in the constraint since a unique constraint on account_id and amount alone would prevent a legitimate customer from making two separate transactions of the same amount to the same account on different days, which is clearly wrong for a real transaction system. The right constraint definition depends entirely on what the business considers a true duplicate versus a legitimate repeated transaction, and I would confirm that definition carefully before adding any constraint to a production table.
Previous Month Sales Alongside Current Month
The Interviewer Says
We have a monthly sales table with one row per month. The business team wants a report where each row shows the current month sales alongside the previous month sales in the same row, so they can compare them side by side without pivoting or manually looking up the prior row. Write a query for this. Walk me through your approach.
Input Table: monthly_sales
| month | sales |
|---|---|
| Jan | 1000 |
| Feb | 1200 |
| Mar | 1500 |
| Apr | 1100 |
| May | 1800 |
How to Handle This in the Interview
Before writing anything, say: This is a classic use case for LAG. I need to bring the previous row's sales value alongside the current row, which is exactly what LAG does without needing any self join.
Step 1: Recognise the ordering problem immediately
- Say: Month names like Jan, Feb, Mar are stored as strings here, not dates. If I ORDER BY month alphabetically I will get Apr, Feb, Jan, Mar, May which is completely wrong. I need to either store months as proper dates or map them to a numeric order before using LAG. I would flag this to the interviewer straight away.
Step 2: Handle the string month ordering
- The cleanest fix is to add a month number mapping using CASE WHEN so the ORDER BY sorts chronologically rather than alphabetically. Alternatively convert month names to actual date values.
Step 3: Handle the first row
- The very first month has no previous month. LAG returns NULL for it. Say: I will leave it as NULL since there genuinely is no previous month data, or I can replace it with zero using COALESCE depending on what the report needs.
Step 4: Ask whether the interviewer wants the difference as well
- Say: Should I also include the month over month difference or just the previous month value? I can add both in the same query easily once the LAG is in place.
Solution
SQL Server
WITH ordered_sales AS ( SELECT month, sales, CASE month WHEN 'Jan' THEN 1 WHEN 'Feb' THEN 2 WHEN 'Mar' THEN 3 WHEN 'Apr' THEN 4 WHEN 'May' THEN 5 WHEN 'Jun' THEN 6 WHEN 'Jul' THEN 7 WHEN 'Aug' THEN 8 WHEN 'Sep' THEN 9 WHEN 'Oct' THEN 10 WHEN 'Nov' THEN 11 WHEN 'Dec' THEN 12 END AS month_num FROM monthly_sales ) SELECT month, sales AS current_sales, LAG(sales) OVER (ORDER BY month_num) AS previous_sales, sales - LAG(sales) OVER (ORDER BY month_num) AS sales_difference FROM ordered_sales ORDER BY month_num;
PostgreSQL
WITH ordered_sales AS ( SELECT month, sales, CASE month WHEN 'Jan' THEN 1 WHEN 'Feb' THEN 2 WHEN 'Mar' THEN 3 WHEN 'Apr' THEN 4 WHEN 'May' THEN 5 WHEN 'Jun' THEN 6 WHEN 'Jul' THEN 7 WHEN 'Aug' THEN 8 WHEN 'Sep' THEN 9 WHEN 'Oct' THEN 10 WHEN 'Nov' THEN 11 WHEN 'Dec' THEN 12 END AS month_num FROM monthly_sales ) SELECT month, sales AS current_sales, LAG(sales) OVER (ORDER BY month_num) AS previous_sales, sales - LAG(sales) OVER (ORDER BY month_num) AS sales_difference FROM ordered_sales ORDER BY month_num;
MySQL 8+
WITH ordered_sales AS ( SELECT month, sales, CASE month WHEN 'Jan' THEN 1 WHEN 'Feb' THEN 2 WHEN 'Mar' THEN 3 WHEN 'Apr' THEN 4 WHEN 'May' THEN 5 WHEN 'Jun' THEN 6 WHEN 'Jul' THEN 7 WHEN 'Aug' THEN 8 WHEN 'Sep' THEN 9 WHEN 'Oct' THEN 10 WHEN 'Nov' THEN 11 WHEN 'Dec' THEN 12 END AS month_num FROM monthly_sales ) SELECT month, sales AS current_sales, LAG(sales) OVER (ORDER BY month_num) AS previous_sales, sales - LAG(sales) OVER (ORDER BY month_num) AS sales_difference FROM ordered_sales ORDER BY month_num;
The syntax is identical across all three platforms. The CASE WHEN month number mapping and LAG behaviour are fully standard.
What to say: The most important thing I caught before writing the query is that month names as strings cannot be sorted chronologically using a plain ORDER BY, they would sort alphabetically. I added a CASE WHEN inside a CTE to convert month names into numbers first, then ORDER BY that number inside LAG to ensure the previous row is always the chronologically previous month rather than the alphabetically previous one. This kind of data type awareness is something interviewers specifically look for.
Expected Output
| month | current_sales | previous_sales | sales_difference |
|---|---|---|---|
| Jan | 1000 | NULL | NULL |
| Feb | 1200 | 1000 | 200 |
| Mar | 1500 | 1200 | 300 |
| Apr | 1100 | 1500 | -400 |
| May | 1800 | 1100 | 700 |
Follow-up Questions & Answers
Q1. What if the month column was stored as a proper date or as YYYY-MM format instead of a month name string, would the query be simpler?
Yes, significantly simpler. When months are stored as proper dates or sortable strings the CASE WHEN mapping is not needed at all since ORDER BY on the date column already gives the correct chronological order.
-- If month column is stored as '2024-01', '2024-02' etc. SELECT month, sales AS current_sales, LAG(sales) OVER (ORDER BY month) AS previous_sales, sales - LAG(sales) OVER (ORDER BY month) AS sales_difference FROM monthly_sales ORDER BY month; -- If month column is stored as a DATE like '2024-01-01' SELECT FORMAT(month, 'MMM') AS month_name, sales AS current_sales, LAG(sales) OVER (ORDER BY month) AS previous_sales, sales - LAG(sales) OVER (ORDER BY month) AS sales_difference FROM monthly_sales ORDER BY month;
What to say: This is exactly why I always recommend storing dates as proper date or datetime types in the database rather than as display strings. A month stored as a real date sorts correctly, filters correctly with date functions, and requires no conversion workarounds anywhere in the codebase. The string month name problem in this question is a data modelling mistake that creates extra complexity in every query that touches that column, not just this one.
Q2. What does LAG return for the first row and how would you handle it differently depending on the business need?
LAG returns NULL for the first row because there is no previous row to look at. There are three valid ways to handle this depending on what the business expects.
-- Option 1: Leave as NULL (default, most honest) LAG(sales) OVER (ORDER BY month_num) AS previous_sales -- Option 2: Replace NULL with 0 using the third argument of LAG LAG(sales, 1, 0) OVER (ORDER BY month_num) AS previous_sales -- Option 3: Replace NULL with 0 using COALESCE COALESCE(LAG(sales) OVER (ORDER BY month_num), 0) AS previous_sales
What to say: LAG accepts a third argument which is the default value to return when no previous row exists, so LAG(sales, 1, 0) returns 0 for January instead of NULL. Whether to use NULL or 0 depends on the business context. NULL honestly communicates that there is no prior data available, which is accurate. Zero implies there were zero sales the month before January, which is misleading if the data simply does not go back that far. I default to NULL and let the reporting layer decide how to display it, rather than silently substituting a potentially misleading value.
Q3. The interviewer now says show the same month sales from last year instead of the previous month. How does the approach change?
Instead of LAG looking back one row, this requires LAG looking back twelve rows to reach the same month of the previous year, assuming the data has one row per month in chronological order.
WITH ordered_sales AS ( SELECT sale_month, sales, ROW_NUMBER() OVER (ORDER BY sale_month) AS rn FROM monthly_sales ) SELECT sale_month, sales AS current_sales, LAG(sales, 12) OVER (ORDER BY sale_month) AS same_month_last_year, sales - LAG(sales, 12) OVER (ORDER BY sale_month) AS year_on_year_difference FROM ordered_sales ORDER BY sale_month;
What to say: The second argument of LAG controls how many rows back to look. LAG(sales, 1) is the default and looks one row back. LAG(sales, 12) looks twelve rows back, which for monthly data is the same month in the prior year. This only works correctly if the data has no missing months, since a gap in the sequence would shift the twelve row lookback to the wrong month. I would verify data completeness before relying on this approach and consider joining to a date dimension table instead if gaps are possible.
Q4. How would you also calculate the percentage change from previous month alongside the absolute difference?
A natural extension of the same query, the percentage change tells a much richer story than the absolute difference alone especially when comparing months with very different sales volumes.
WITH ordered_sales AS ( SELECT month, sales, CASE month WHEN 'Jan' THEN 1 WHEN 'Feb' THEN 2 WHEN 'Mar' THEN 3 WHEN 'Apr' THEN 4 WHEN 'May' THEN 5 WHEN 'Jun' THEN 6 WHEN 'Jul' THEN 7 WHEN 'Aug' THEN 8 WHEN 'Sep' THEN 9 WHEN 'Oct' THEN 10 WHEN 'Nov' THEN 11 WHEN 'Dec' THEN 12 END AS month_num FROM monthly_sales ), with_lag AS ( SELECT month, sales, month_num, LAG(sales) OVER (ORDER BY month_num) AS previous_sales FROM ordered_sales ) SELECT month, sales AS current_sales, previous_sales, sales - previous_sales AS sales_difference, ROUND((sales - previous_sales) * 100.0 / NULLIF(previous_sales, 0), 2) AS pct_change FROM with_lag ORDER BY month_num;
What to say: I compute the LAG once in an inner CTE and reference previous_sales multiple times in the outer query rather than writing the LAG expression twice, which keeps the query cleaner and avoids any risk of the two LAG calls producing different results if the data has ties. NULLIF on previous_sales guards against a divide by zero error if any month had zero sales.
Q5. What if there are multiple product lines in the same table and the previous month should be per product, not across all products combined?
This adds a PARTITION BY to the LAG, the same pattern seen in cumulative sum and other window function questions.
WITH ordered_sales AS ( SELECT product_id, month, sales, CASE month WHEN 'Jan' THEN 1 WHEN 'Feb' THEN 2 WHEN 'Mar' THEN 3 WHEN 'Apr' THEN 4 WHEN 'May' THEN 5 WHEN 'Jun' THEN 6 WHEN 'Jul' THEN 7 WHEN 'Aug' THEN 8 WHEN 'Sep' THEN 9 WHEN 'Oct' THEN 10 WHEN 'Nov' THEN 11 WHEN 'Dec' THEN 12 END AS month_num FROM monthly_sales ) SELECT product_id, month, sales AS current_sales, LAG(sales) OVER (PARTITION BY product_id ORDER BY month_num) AS previous_month_sales FROM ordered_sales ORDER BY product_id, month_num;
What to say: Adding PARTITION BY product_id inside the LAG window resets the lookback independently for each product, so January's previous sales for Product A looks back to December of Product A specifically rather than bleeding across into whatever the previous row happens to be from Product B. This is the same PARTITION BY pattern that comes up in almost every window function scenario and is always the right instinct when the calculation needs to reset per group.
Understanding JOIN Result Row Counts
The Interviewer Says
I am going to give you two tables with specific values including duplicates and non matching keys. Without writing any query, just tell me how many rows each type of join will return and explain why. This tests whether you truly understand how joins work under the hood, not just the syntax.
The Tables
Table A
| id |
|---|
| 1 |
| 1 |
| 2 |
| 3 |
Table B
| id |
|---|
| 1 |
| 1 |
| 2 |
| 5 |
How to Handle This in the Interview
Before answering, say: Let me think through each join type carefully, paying special attention to how duplicates on both sides multiply together rather than just adding.
The key mental model to state out loud:
- When a value exists M times in Table A and N times in Table B, a join on that value produces M multiplied by N rows, not M plus N. This is the single most important thing to get right when answering join count questions.
- Non matching values in an INNER JOIN produce zero rows for that value.
- Non matching values in a LEFT JOIN keep the left side rows with NULL on the right.
- Non matching values in a RIGHT JOIN keep the right side rows with NULL on the left.
- FULL OUTER JOIN keeps everything from both sides.
Working Through the Answer
Step 1: Map out what each value produces
- Value 1: appears 2 times in A, 2 times in B. On a join this produces 2 multiplied by 2 = 4 rows.
- Value 2: appears 1 time in A, 1 time in B. This produces 1 multiplied by 1 = 1 row.
- Value 3: appears 1 time in A, 0 times in B. No match on the right side.
- Value 5: appears 0 times in A, 1 time in B. No match on the left side.
Step 2: Apply each join type
INNER JOIN
- Only keeps rows where a match exists on both sides.
- Value 1 contributes 4 rows. Value 2 contributes 1 row. Value 3 has no match so contributes 0 rows. Value 5 has no match so contributes 0 rows.
- Total: 5 rows
LEFT JOIN
- Keeps all rows from Table A. Where no match exists on the right, fills with NULL.
- Value 1 contributes 4 rows (matched). Value 2 contributes 1 row (matched). Value 3 contributes 1 row (unmatched, right side NULL). Value 5 is only in B so it does not appear at all in a LEFT JOIN.
- Total: 6 rows
RIGHT JOIN
- Keeps all rows from Table B. Where no match exists on the left, fills with NULL.
- Value 1 contributes 4 rows (matched). Value 2 contributes 1 row (matched). Value 3 is only in A so it does not appear at all in a RIGHT JOIN. Value 5 contributes 1 row (unmatched, left side NULL).
- Total: 6 rows
FULL OUTER JOIN
- Keeps everything from both sides regardless of match.
- Value 1 contributes 4 rows (matched). Value 2 contributes 1 row (matched). Value 3 contributes 1 row (from A, right NULL). Value 5 contributes 1 row (from B, left NULL).
- Total: 7 rows
Summary Table
| Join Type | Row Count | Reason |
|---|---|---|
| INNER JOIN | 5 | Only matched values, duplicates multiply |
| LEFT JOIN | 6 | All of A plus matched B, value 3 kept with NULL |
| RIGHT JOIN | 6 | All of B plus matched A, value 5 kept with NULL |
| FULL OUTER JOIN | 7 | Everything from both sides |
Follow-up Questions & Answers
Q1. Now try this second set of tables from a real interview scenario
Table 1
| id |
|---|
| 1 |
| 1 |
| 1 |
| 1 |
| 2 |
| 2 |
| 2 |
| 3 |
| 3 |
Table 2
| id |
|---|
| 1 |
| 2 |
| 2 |
| 2 |
| 3 |
| 4 |
- Value 1: 4 times in T1, 1 time in T2. Produces 4 multiplied by 1 = 4 rows on a match.
- Value 2: 3 times in T1, 3 times in T2. Produces 3 multiplied by 3 = 9 rows on a match.
- Value 3: 2 times in T1, 1 time in T2. Produces 2 multiplied by 1 = 2 rows on a match.
- Value 4: 0 times in T1, 1 time in T2. No match on left side.
| Join Type | Row Count | Reasoning |
|---|---|---|
| INNER JOIN | 15 | 4 + 9 + 2 = 15, only matched values |
| LEFT JOIN | 15 | Same as INNER here since all T1 values exist in T2 |
| RIGHT JOIN | 16 | 15 matched + 1 unmatched row for value 4 |
| FULL OUTER JOIN | 16 | Same as RIGHT JOIN here since all T1 values exist in T2 |
What to say: Notice here that LEFT JOIN and INNER JOIN produce the same count because every value in T1 has at least one match in T2, there are no unmatched left side rows. Similarly RIGHT JOIN and FULL OUTER JOIN produce the same count because the only unmatched value is on the right side, value 4, which is already included in both. Understanding when two join types produce the same result is just as important as knowing when they differ.
Q2. Why does the duplicate multiplication catch so many people off guard?
Most people intuitively think of a join as matching rows one to one, the way you might look up a value in a dictionary. But a SQL join is a relational operation that produces every valid combination of matching rows. When value 1 appears twice on the left and twice on the right, there are literally four valid pairings: left row 1 with right row 1, left row 1 with right row 2, left row 2 with right row 1, and left row 2 with right row 2. SQL returns all four because all four are technically correct matches. This is called a many to many join and it is one of the most common sources of accidentally inflated row counts in real data pipelines. When a join produces far more rows than expected, the first thing to check is whether the join key has duplicates on one or both sides.
Q3. In a real pipeline, how would you detect and prevent this accidental row multiplication from a duplicate join key?
What to say: Before joining two large tables I always run a quick check on the join key to understand its cardinality on both sides.
-- Check if the join key is unique on each side before joining SELECT 'Table A' AS source, id, COUNT(*) AS key_count FROM table_a GROUP BY id HAVING COUNT(*) > 1 UNION ALL SELECT 'Table B' AS source, id, COUNT(*) AS key_count FROM table_b GROUP BY id HAVING COUNT(*) > 1;
What to say: If this query returns any rows, I know the join key has duplicates on at least one side and any join on that key will multiply rows. I then decide whether to deduplicate before joining, use a different join key, or acknowledge that the multiplication is intentional for the specific use case. Skipping this check and just running the join blindly on large tables is how pipelines accidentally produce hundreds of millions of rows from two tables that each have only a few million, which then crashes downstream processes or produces silently wrong aggregations.
Q4. What is a CROSS JOIN and when would it intentionally produce a large number of rows?
A CROSS JOIN produces every possible combination of every row from the left table with every row from the right table, with no join condition at all. If Table A has 4 rows and Table B has 4 rows, a CROSS JOIN produces 4 multiplied by 4 = 16 rows.
-- CROSS JOIN example SELECT a.id, b.id FROM table_a a CROSS JOIN table_b b;
What to say: CROSS JOIN is almost never what you want by accident, but it has genuine use cases. The most common real world use is generating all possible combinations deliberately, for example creating a calendar of all dates combined with all product IDs to ensure every date-product combination exists in a reporting table even if no sales occurred. It also appears in the MySQL UNPIVOT workaround shown in Question 18. Any time a join produces far more rows than expected and you did not write an explicit CROSS JOIN, the most likely cause is an accidental cartesian product from a missing or incorrect join condition.
Q5. What would happen to the row count if you added a WHERE clause filtering for id equals 1 after a FULL OUTER JOIN on these two tables?
Taking the original tables A and B from this question, a FULL OUTER JOIN produces 7 rows total. Adding WHERE id = 1 filters to only the rows where id is 1, which are the 4 matched rows from the value 1 multiplication.
SELECT * FROM table_a a FULL OUTER JOIN table_b b ON a.id = b.id WHERE a.id = 1;
What to say: The result would be 4 rows, the same as the INNER JOIN result for value 1 alone. But there is a subtle trap here, when filtering a FULL OUTER JOIN using WHERE on a column from one specific side, you can accidentally convert it into an INNER JOIN behaviour for that filter condition, since WHERE runs after the join and rows where the filtered column is NULL on that side get eliminated. If the intent is to filter while preserving the outer join semantics, the filter should go into the ON condition rather than the WHERE clause, which is a genuinely common and hard to spot bug in complex reporting queries.
Employees Earning More Than Their Manager
The Interviewer Says
We have a single employee table that also stores each employee's manager ID as a foreign key back to the same table. Write a query to find all employees whose salary is strictly greater than their own manager's salary. This is a very commonly asked question so I want to see how cleanly you write it and what edge cases you think about. Walk me through your approach.
Input Table: employees
| emp_id | name | salary | manager_id |
|---|---|---|---|
| E01 | Karan | 150000 | NULL |
| E02 | Asha | 90000 | E01 |
| E03 | Ravi | 120000 | E01 |
| E04 | Meena | 95000 | E02 |
| E05 | Sunil | 100000 | E02 |
| E06 | Divya | 85000 | E03 |
| E07 | Prakash | 130000 | E03 |
How to Handle This in the Interview
Before writing anything, say: This is a self join problem since both the employee and the manager live in the same table. I need to join the table to itself, one copy representing the employee and the other representing their manager, then compare salaries across the two copies.
Step 1: Identify this as a self join
- The manager_id in the employee row points to another emp_id in the same table. To compare an employee's salary with their manager's salary in the same row I need to join employees to itself.
Step 2: Choose the right join type
- An INNER JOIN is correct here. If an employee has no manager, meaning manager_id is NULL, they are the top of the hierarchy and there is no manager salary to compare against, so they should be excluded naturally. INNER JOIN handles this since NULL never matches any emp_id.
Step 3: Alias clearly
- Use meaningful aliases like emp and mgr rather than e1 and e2, since meaningful aliases make the query self documenting and much easier to follow during a whiteboard or screen share interview.
Step 4: Think about the edge cases out loud
- Ask: What if two employees share the same manager and both earn more than the manager? Both should appear in the output. What if the CEO has no manager at all? They should be excluded since there is no manager to compare against.
Solution
SQL Server
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary, emp.salary - mgr.salary AS salary_difference FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id WHERE emp.salary > mgr.salary ORDER BY salary_difference DESC;
PostgreSQL
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary, emp.salary - mgr.salary AS salary_difference FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id WHERE emp.salary > mgr.salary ORDER BY salary_difference DESC;
MySQL 8+
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary, emp.salary - mgr.salary AS salary_difference FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id WHERE emp.salary > mgr.salary ORDER BY salary_difference DESC;
The self join syntax is identical across all three platforms.
What to say: I join the employees table to itself using two aliases, emp for the employee side and mgr for the manager side. The join condition emp.manager_id equals mgr.emp_id is what links each employee to their specific manager's row. Then I simply compare salaries in the WHERE clause. The INNER JOIN naturally excludes Karan who has no manager since NULL never satisfies an equality join condition, which is exactly the correct behaviour here.
Expected Output
| emp_id | employee_name | employee_salary | manager_name | manager_salary | salary_difference |
|---|---|---|---|---|---|
| E07 | Prakash | 130000 | Ravi | 120000 | 10000 |
| E05 | Sunil | 100000 | Asha | 90000 | 10000 |
| E04 | Meena | 95000 | Asha | 90000 | 5000 |
Follow-up Questions & Answers
Q1. Why does Karan not appear in the output even though he has the highest salary in the company?
Karan has a NULL manager_id since he is the top of the hierarchy with no manager above him. In the self join, the ON condition requires emp.manager_id to equal mgr.emp_id. Since NULL does not equal anything in SQL, including other NULLs, Karan's row produces no match on the manager side and is excluded by the INNER JOIN. This is the correct business behaviour since there is genuinely no manager salary to compare Karan against.
What to say: This is worth stating explicitly in the interview because it shows I understand the NULL behaviour of INNER JOIN rather than just assuming it works. If the interviewer asked me to also include top level employees in the output with a note that they have no manager, I would switch to a LEFT JOIN and use a CASE statement to handle the NULL manager case gracefully.
Q2. How would you modify this to also show employees whose salary equals their manager's salary, not just those who earn more?
A single character change in the WHERE clause handles this.
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary, CASE WHEN emp.salary > mgr.salary THEN 'Earns more than manager' WHEN emp.salary = mgr.salary THEN 'Same as manager' ELSE 'Earns less than manager' END AS salary_comparison FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id ORDER BY emp.salary DESC;
What to say: By removing the WHERE clause entirely and adding a CASE statement instead I get a complete picture of every employee's salary relative to their manager, not just the ones earning more. This gives HR a much more useful report since they can see all three scenarios in one place rather than running separate queries for each case.
Q3. What if the hierarchy goes three levels deep, can you find employees who earn more than their manager's manager, meaning their skip level manager?
This requires joining the table three times, one for the employee, one for the direct manager, and one for the skip level manager.
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary, skip.name AS skip_manager_name, skip.salary AS skip_manager_salary FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id JOIN employees skip ON mgr.manager_id = skip.emp_id WHERE emp.salary > skip.salary ORDER BY emp.emp_id;
What to say: Each additional level of the hierarchy requires one more self join to the same table. For a fixed known depth like two levels up this works cleanly. But if the hierarchy can go arbitrarily deep and you want to compare against any ancestor not just the immediate skip level, this approach does not scale and you would need a recursive CTE instead, the same pattern covered in Question 13.
Q4. How would you find the manager who has the most employees earning more than them?
This extends the original query from finding individual employees to aggregating at the manager level.
WITH earning_more AS ( SELECT mgr.emp_id AS manager_id, mgr.name AS manager_name, COUNT(*) AS employees_earning_more FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id WHERE emp.salary > mgr.salary GROUP BY mgr.emp_id, mgr.name ) SELECT TOP 1 manager_id, manager_name, employees_earning_more FROM earning_more ORDER BY employees_earning_more DESC;
What to say: I wrap the original self join in a CTE and group by the manager side rather than returning individual employee rows. COUNT gives the number of direct reports earning more than each manager, and TOP 1 with ORDER BY picks the single manager with the most such employees. In PostgreSQL or MySQL I would use LIMIT 1 instead of TOP 1 since TOP is SQL Server specific syntax.
Q5. What if salary could be NULL for some employees, how does that affect the comparison?
In SQL, any comparison involving NULL produces UNKNOWN rather than TRUE or FALSE, so emp.salary > mgr.salary silently evaluates to UNKNOWN and the row is excluded when either side is NULL, which is probably the correct default behaviour since you cannot determine the relationship when data is missing. However it is worth being explicit about this.
SELECT emp.emp_id, emp.name AS employee_name, emp.salary AS employee_salary, mgr.name AS manager_name, mgr.salary AS manager_salary FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.emp_id WHERE emp.salary > mgr.salary AND emp.salary IS NOT NULL AND mgr.salary IS NOT NULL ORDER BY emp.emp_id;
What to say: I add explicit IS NOT NULL checks on both salary columns to make the NULL exclusion intentional and visible rather than relying on the silent UNKNOWN behaviour of the comparison operator. I would also flag to HR that any employee with a NULL salary is being excluded from this analysis entirely, which might be important if a significant number of employees have missing salary data, since those could represent a data quality issue rather than genuinely missing compensation information.
Count Total Friends per User from a Bidirectional Friendship Table
The Interviewer Says
We have a friendship table where each accepted friendship is stored as a row with the user who sent the request and the user who accepted it. Since a friendship is bidirectional, user 1 being friends with user 2 means user 2 is also friends with user 1, but there is only one row in the table representing that relationship. Write a query to find the total number of friends each user has. Walk me through your thinking.
Input Table: friendships
| user_id | accepted_id |
|---|---|
| 1 | 2 |
| 2 | 3 |
| 3 | 4 |
| 4 | 5 |
| 5 | 2 |
How to Handle This in the Interview
Before writing anything, say: The key challenge here is that each friendship appears only once in the table, but it counts for both people involved. User 1 appears in user_id but is friends with user 2 who appears in accepted_id. I need to count both directions for every user.
Step 1: Recognise the bidirectional nature of the data
- A single row like user_id 1, accepted_id 2 means both user 1 and user 2 gained a friend from that relationship. If I only count rows where a user appears in user_id, I miss all the friendships where they appear in accepted_id, and vice versa.
Step 2: Use UNION ALL to see both directions
- The cleanest approach is to create a unified view of all friendship relationships by unioning two selects, one treating user_id as the person and accepted_id as their friend, and one treating accepted_id as the person and user_id as their friend. Then count how many times each person appears in this combined list.
Step 3: Use UNION ALL not UNION
- Say: I use UNION ALL not UNION here because UNION removes duplicates. If two users somehow had a duplicate friendship row, UNION would silently hide it. UNION ALL keeps everything and gives an accurate count. In this context correctness matters more than deduplication.
Step 4: Ask about the data integrity
- Ask: Can the same pair of users appear more than once in this table, meaning can there be duplicate friendship rows? That would affect whether I need to deduplicate before counting.
Solution
SQL Server
WITH all_friendships AS ( SELECT user_id AS person, accepted_id AS friend FROM friendships UNION ALL SELECT accepted_id AS person, user_id AS friend FROM friendships ) SELECT person AS user_id, COUNT(*) AS total_friends FROM all_friendships GROUP BY person ORDER BY person;
PostgreSQL
WITH all_friendships AS ( SELECT user_id AS person, accepted_id AS friend FROM friendships UNION ALL SELECT accepted_id AS person, user_id AS friend FROM friendships ) SELECT person AS user_id, COUNT(*) AS total_friends FROM all_friendships GROUP BY person ORDER BY person;
MySQL 8+
WITH all_friendships AS ( SELECT user_id AS person, accepted_id AS friend FROM friendships UNION ALL SELECT accepted_id AS person, user_id AS friend FROM friendships ) SELECT person AS user_id, COUNT(*) AS total_friends FROM all_friendships GROUP BY person ORDER BY person;
The syntax is identical across all three platforms.
What to say: I use UNION ALL to stack two versions of the friendships table on top of each other. The first version treats user_id as the person we are counting for. The second version flips it and treats accepted_id as the person. This means every friendship row contributes a count of one to both people involved, which is exactly the bidirectional behaviour a real friendship system needs. Then a simple GROUP BY and COUNT on the combined result gives the total friend count per user.
Expected Output
| user_id | total_friends |
|---|---|
| 1 | 1 |
| 2 | 3 |
| 3 | 2 |
| 4 | 2 |
| 5 | 2 |
Follow-up Questions & Answers
Q1. Why is user 2 the most connected with 3 friends? Walk through it manually.
User 2 appears in three separate friendship relationships. First as accepted_id in the row where user 1 sent them a request. Second as user_id in the row where they sent a request to user 3. Third as accepted_id in the row where user 5 sent them a request. So user 2 is friends with user 1, user 3, and user 5, giving a total of 3. Working through a small example manually like this in the interview shows the interviewer you are verifying your logic against the data rather than just assuming the query is correct.
Q2. What is the difference between UNION and UNION ALL and why does it matter here?
UNION combines two result sets and removes duplicate rows from the combined output, similar to applying a DISTINCT across the full result. UNION ALL combines two result sets and keeps every row including duplicates. Here if the same user pair appeared in both SELECT statements after the union, UNION would collapse them into one row and undercount the friendships. Since the two SELECT statements have different column roles, user_id as person in the first and accepted_id as person in the second, they will naturally produce different rows for the same friendship, so UNION would not actually cause a problem in this specific case. However I still prefer UNION ALL as a habit because it is faster since the database does not need to sort and deduplicate the result, and it makes the intent explicit that I want every row from both sides.
Q3. What if the same friendship pair appears twice in the table, like user 1 and user 2 both having two rows? How would you handle that?
Duplicate friendship rows would inflate the count since UNION ALL keeps everything. The fix is to deduplicate within the CTE before counting.
WITH unique_friendships AS ( SELECT DISTINCT CASE WHEN user_id < accepted_id THEN user_id ELSE accepted_id END AS person_a, CASE WHEN user_id < accepted_id THEN accepted_id ELSE user_id END AS person_b FROM friendships ), all_friendships AS ( SELECT person_a AS person, person_b AS friend FROM unique_friendships UNION ALL SELECT person_b AS person, person_a AS friend FROM unique_friendships ) SELECT person AS user_id, COUNT(*) AS total_friends FROM all_friendships GROUP BY person ORDER BY person;
What to say: I canonicalise each friendship pair by always putting the smaller user_id first using a CASE WHEN before applying DISTINCT. This means user 1 with user 2 and user 2 with user 1 both collapse to the same canonical form person_a equals 1 and person_b equals 2, and DISTINCT removes the duplicate. After deduplication I apply the same UNION ALL pattern to expand both directions for counting. This two step approach, deduplicate first then expand, is more robust than trying to handle duplicates after the expansion.
Q4. How would you find users who have no friends at all, meaning users in a users table who do not appear in the friendships table on either side?
This requires knowing all users from a separate users table and finding which ones have no entries in the friendship count result.
WITH all_friendships AS ( SELECT user_id AS person, accepted_id AS friend FROM friendships UNION ALL SELECT accepted_id AS person, user_id AS friend FROM friendships ), friend_counts AS ( SELECT person AS user_id, COUNT(*) AS total_friends FROM all_friendships GROUP BY person ) SELECT u.user_id, COALESCE(fc.total_friends, 0) AS total_friends FROM users u LEFT JOIN friend_counts fc ON u.user_id = fc.user_id ORDER BY u.user_id;
What to say: I LEFT JOIN the users table to the friend count result so that users with no friendships at all still appear in the output with a count of zero rather than being silently excluded. COALESCE converts the NULL that comes from a missing match into zero, which is the correct count for someone with no friends. This pattern of starting from a complete list of entities and left joining aggregation results onto it is something I use constantly in reporting queries to ensure no entity is accidentally omitted from the output just because they have no activity to aggregate.
Q5. How would you find the user who has the most mutual friends with a specific user, say user 2?
Mutual friends means users who are friends with both the target user and some other user at the same time.
WITH all_friendships AS ( SELECT user_id AS person, accepted_id AS friend FROM friendships UNION ALL SELECT accepted_id AS person, user_id AS friend FROM friendships ), user2_friends AS ( SELECT friend AS user2_friend FROM all_friendships WHERE person = 2 ), other_users_friends AS ( SELECT af.person AS other_user, COUNT(*) AS mutual_friend_count FROM all_friendships af JOIN user2_friends u2f ON af.friend = u2f.user2_friend WHERE af.person != 2 GROUP BY af.person ) SELECT TOP 1 other_user, mutual_friend_count FROM other_users_friends ORDER BY mutual_friend_count DESC;
What to say: I first find all of user 2's friends in one CTE. Then I join every other user's friend list against user 2's friend list and count the overlapping entries, which gives the number of mutual friends between user 2 and each other user. The user with the highest mutual friend count is the strongest candidate for a friend suggestion, which is exactly the kind of people you may know recommendation that social platforms compute at massive scale using graph algorithms, though at SQL scale this set intersection approach works well for moderate data sizes.
Employees Who Did Not Get a Salary Hike in the Last One Year
The Interviewer Says
We have an employee table that follows a slowly changing dimension pattern, meaning every time an employee's details change, a new row is inserted with the effective start and end dates rather than updating the existing row. I want you to find all employees who have not received any salary hike in the last one year. Walk me through how you would think about this before writing anything.
Input Table: employees
| employee_id | emp_name | manager_id | salary | department | eff_start_date | eff_end_date |
|---|---|---|---|---|---|---|
| E01 | Karan | NULL | 80000 | Finance | 2023-01-01 | 2023-06-30 |
| E01 | Karan | NULL | 90000 | Finance | 2023-07-01 | 2024-06-30 |
| E01 | Karan | NULL | 95000 | Finance | 2024-07-01 | 9999-12-31 |
| E02 | Asha | E01 | 60000 | IT | 2023-01-01 | 2024-03-31 |
| E02 | Asha | E01 | 60000 | IT | 2024-04-01 | 9999-12-31 |
| E03 | Ravi | E01 | 75000 | IT | 2024-01-01 | 9999-12-31 |
| E04 | Meena | E01 | 55000 | Finance | 2022-06-01 | 9999-12-31 |
How to Handle This in the Interview
Before writing anything, say: This is an SCD Type 2 table, meaning history is tracked through effective date ranges rather than a single row per employee. Before I write the query I need to understand two things, how to identify the currently active record for each employee, and how to determine whether a salary change happened within the last one year.
Step 1: Understand the SCD Type 2 pattern
- Say: In SCD Type 2, the currently active record is the one where eff_end_date is set to a sentinel future date like 9999-12-31 or where eff_end_date is NULL. Historical records have a real past date as the end date. To find current salary I filter for the active row.
Step 2: Define what no salary hike means in this context
- A hike means the salary value changed between two consecutive rows for the same employee. No hike means either the employee has only one row ever, or all rows within the last year show the same salary value as before the last year started.
Step 3: Look for salary changes within the last year window
- The last one year window is from today minus 365 days through today. If a new row with a higher salary started within this window, the employee got a hike. If no such row exists, they did not.
Step 4: Think about edge cases
- Say: An employee who joined less than a year ago and has never had a salary change since joining should count as not getting a hike. An employee like Meena who has been on the same salary since 2022 with no new row at all in the last year definitely counts. Ravi joined in 2024 but has had the same salary since then, also no hike.
Solution
SQL Server
WITH current_records AS ( SELECT employee_id, emp_name, salary AS current_salary, eff_start_date AS current_since FROM employees WHERE eff_end_date = '9999-12-31' ), hike_in_last_year AS ( SELECT DISTINCT employee_id FROM employees WHERE eff_start_date >= DATEADD(YEAR, -1, CAST(GETDATE() AS DATE)) AND eff_end_date = '9999-12-31' AND salary > ( SELECT salary FROM employees prev WHERE prev.employee_id = employees.employee_id AND prev.eff_end_date = DATEADD(DAY, -1, employees.eff_start_date) ) ) SELECT cr.employee_id, cr.emp_name, cr.current_salary, cr.current_since FROM current_records cr WHERE cr.employee_id NOT IN ( SELECT employee_id FROM hike_in_last_year ) ORDER BY cr.employee_id;
PostgreSQL
WITH current_records AS ( SELECT employee_id, emp_name, salary AS current_salary, eff_start_date AS current_since FROM employees WHERE eff_end_date = '9999-12-31' ), hike_in_last_year AS ( SELECT DISTINCT employee_id FROM employees WHERE eff_start_date >= CURRENT_DATE - INTERVAL '1 year' AND eff_end_date = '9999-12-31' AND salary > ( SELECT salary FROM employees prev WHERE prev.employee_id = employees.employee_id AND prev.eff_end_date = employees.eff_start_date - INTERVAL '1 day' ) ) SELECT cr.employee_id, cr.emp_name, cr.current_salary, cr.current_since FROM current_records cr WHERE cr.employee_id NOT IN ( SELECT employee_id FROM hike_in_last_year ) ORDER BY cr.employee_id;
MySQL 8+
WITH current_records AS ( SELECT employee_id, emp_name, salary AS current_salary, eff_start_date AS current_since FROM employees WHERE eff_end_date = '9999-12-31' ), hike_in_last_year AS ( SELECT DISTINCT employee_id FROM employees WHERE eff_start_date >= DATE_SUB(CURDATE(), INTERVAL 1 YEAR) AND eff_end_date = '9999-12-31' AND salary > ( SELECT salary FROM employees prev WHERE prev.employee_id = employees.employee_id AND prev.eff_end_date = DATE_SUB(employees.eff_start_date, INTERVAL 1 DAY) ) ) SELECT cr.employee_id, cr.emp_name, cr.current_salary, cr.current_since FROM current_records cr WHERE cr.employee_id NOT IN ( SELECT employee_id FROM hike_in_last_year ) ORDER BY cr.employee_id;
The core SCD logic is identical across all three platforms. Only date arithmetic functions differ, DATEADD in SQL Server, interval arithmetic in PostgreSQL, and DATE_SUB in MySQL.
What to say: I split this into two CTEs. The first finds each employee's currently active record by filtering for the sentinel end date. The second identifies employees who received a salary hike in the last one year, meaning their current record started within the last year and their salary is higher than the immediately preceding record. I link consecutive records using the SCD date chain, the previous row's eff_end_date is exactly one day before the current row's eff_start_date. Employees not found in the hike list are returned as the final result.
Expected Output
| employee_id | emp_name | current_salary | current_since |
|---|---|---|---|
| E02 | Asha | 60000 | 2024-04-01 |
| E03 | Ravi | 75000 | 2024-01-01 |
| E04 | Meena | 55000 | 2022-06-01 |
Follow-up Questions & Answers
Q1. What is SCD Type 2 and why is it used instead of simply updating the existing row?
SCD stands for Slowly Changing Dimension, a pattern used in data warehousing to track historical changes to dimension data over time. Type 2 specifically handles changes by inserting a new row with the new values and a new effective date range, while closing off the previous row by setting its end date. The reason it is preferred over simply updating the existing row is that updating destroys history, you can no longer answer questions like what was Karan's salary in March 2023 if you have already overwritten it with the July 2023 value. SCD Type 2 preserves the complete history so any point in time query can be answered accurately by filtering for the row whose effective date range covers that specific date. It is extremely common in enterprise data warehouses built on tools like Snowflake, Azure Synapse, and Databricks where historical analysis is a core requirement.
Q2. How would you write a point in time query to find what salary each employee was earning on a specific past date, say 2024-01-15?
This is the most common SCD Type 2 query pattern and interviewers often ask for it alongside the history tracking question.
SELECT employee_id, emp_name, salary, department, eff_start_date, eff_end_date FROM employees WHERE eff_start_date <= '2024-01-15' AND eff_end_date >= '2024-01-15' ORDER BY employee_id;
What to say: The point in time filter is simply checking whether the target date falls within the effective date range of each row. Since SCD Type 2 guarantees that exactly one row per employee is active for any given date, this query returns at most one row per employee. This is one of the most powerful properties of the SCD Type 2 pattern, any historical question can be answered with a simple between style date filter rather than complex aggregation or self joins.
Q3. How would you use LAG instead of the correlated subquery to compare consecutive salary rows per employee?
The correlated subquery approach works but LAG is cleaner and more efficient since it avoids a separate lookup for each row.
WITH salary_history AS ( SELECT employee_id, emp_name, salary, eff_start_date, eff_end_date, LAG(salary) OVER (PARTITION BY employee_id ORDER BY eff_start_date) AS previous_salary FROM employees ), hike_in_last_year AS ( SELECT DISTINCT employee_id FROM salary_history WHERE eff_start_date >= DATEADD(YEAR, -1, CAST(GETDATE() AS DATE)) AND eff_end_date = '9999-12-31' AND salary > COALESCE(previous_salary, salary) ) SELECT sh.employee_id, sh.emp_name, sh.salary AS current_salary, sh.eff_start_date AS current_since FROM salary_history sh WHERE sh.eff_end_date = '9999-12-31' AND sh.employee_id NOT IN ( SELECT employee_id FROM hike_in_last_year ) ORDER BY sh.employee_id;
What to say: LAG partitioned by employee_id and ordered by eff_start_date brings the previous record's salary alongside the current row automatically without needing a correlated subquery. I use COALESCE on previous_salary to handle the very first row per employee where LAG returns NULL, treating it as equal to the current salary so it does not accidentally register as a hike. This approach is cleaner and scales much better on large SCD tables since LAG only requires a single pass over the data while a correlated subquery runs a separate lookup for every row.
Q4. What if the sentinel end date is NULL instead of 9999-12-31 in this table, how does that change your query?
Some SCD implementations use NULL as the end date for the currently active record instead of a far future date. The query logic stays identical, only the filter condition changes.
WITH current_records AS ( SELECT employee_id, emp_name, salary AS current_salary, eff_start_date AS current_since FROM employees WHERE eff_end_date IS NULL -- NULL means currently active ), hike_in_last_year AS ( SELECT DISTINCT employee_id FROM employees WHERE eff_start_date >= DATEADD(YEAR, -1, CAST(GETDATE() AS DATE)) AND eff_end_date IS NULL AND salary > ( SELECT salary FROM employees prev WHERE prev.employee_id = employees.employee_id AND prev.eff_end_date = DATEADD(DAY, -1, employees.eff_start_date) ) ) SELECT cr.employee_id, cr.emp_name, cr.current_salary, cr.current_since FROM current_records cr WHERE cr.employee_id NOT IN ( SELECT employee_id FROM hike_in_last_year ) ORDER BY cr.employee_id;
What to say: The only change is replacing eff_end_date equals 9999-12-31 with eff_end_date IS NULL throughout the query. Both approaches are equally valid conventions, though 9999-12-31 is more commonly used in enterprise data warehouses since it avoids NULL handling complexity in date range arithmetic and works more naturally with BETWEEN style filters for point in time queries. I would always check which convention the specific system uses before writing any SCD query rather than assuming.
Q5. How would you track this no hike list over time, meaning produce a daily or monthly report of which employees have gone without a hike for more than twelve months?
This turns the one off query into an ongoing monitoring report, which is a realistic production requirement for HR analytics.
WITH salary_history AS ( SELECT employee_id, emp_name, salary, eff_start_date, eff_end_date, LAG(salary) OVER (PARTITION BY employee_id ORDER BY eff_start_date) AS previous_salary FROM employees ), last_hike AS ( SELECT employee_id, MAX(eff_start_date) AS last_hike_date FROM salary_history WHERE salary > COALESCE(previous_salary, 0) GROUP BY employee_id ), current_records AS ( SELECT employee_id, emp_name, salary AS current_salary, eff_start_date AS current_since FROM employees WHERE eff_end_date = '9999-12-31' ) SELECT cr.employee_id, cr.emp_name, cr.current_salary, cr.current_since, lh.last_hike_date, DATEDIFF( MONTH, COALESCE(lh.last_hike_date, cr.current_since), GETDATE() ) AS months_without_hike FROM current_records cr LEFT JOIN last_hike lh ON cr.employee_id = lh.employee_id ORDER BY months_without_hike DESC;
What to say: I find each employee's most recent salary increase date using MAX on eff_start_date filtered to rows where salary went up. Then I join that back to the current active record and calculate how many months have passed since the last hike using DATEDIFF. Employees who have never received any hike at all, like someone whose first row is still their current row, get a NULL from the LEFT JOIN which I handle with COALESCE falling back to their current_since date, meaning the entire tenure has passed without a hike. Sorting by months_without_hike descending immediately surfaces the employees who have been waiting the longest, which is exactly the kind of prioritised list an HR team would act on.
Roundtrip Distance and Symmetric Difference
The Interviewer Says
I have two parts to this question. First, we have a flight routes table with distances between cities. Write a query to find all routes that have a return path and calculate the total roundtrip distance. Second, given two tables of product IDs, write a query to find the symmetric difference, meaning products that exist in one table but not both. Walk me through both.
Input Table: flight_routes
| route_id | origin | destination | distance_km |
|---|---|---|---|
| R01 | DEL | BOM | 1150 |
| R02 | BOM | DEL | 1150 |
| R03 | DEL | BLR | 1740 |
| R04 | BLR | HYD | 500 |
| R05 | HYD | BLR | 500 |
| R06 | BOM | GOA | 600 |
| R07 | DEL | CCU | 1300 |
Input Table: products_store_a
| product_id |
|---|
| P01 |
| P02 |
| P03 |
| P04 |
Input Table: products_store_b
| product_id |
|---|
| P02 |
| P03 |
| P05 |
| P06 |
How to Handle This in the Interview
Before writing anything, say: These are two distinct set operation problems. The roundtrip question needs a self join to find pairs where both directions exist. The symmetric difference question is a classic set operation that can be solved using EXCEPT in both directions combined with UNION ALL.
Step 1: Roundtrip — identify the self join condition
- A roundtrip exists when route A goes from city X to city Y and route B goes from city Y to city X. The self join condition is r1.origin equals r2.destination AND r1.destination equals r2.origin.
Step 2: Avoid duplicate pairs
- Without a filter, both DEL-BOM and BOM-DEL would appear as separate roundtrip pairs. Add a condition like r1.origin less than r1.destination to keep only one canonical direction per pair.
Step 3: Symmetric difference — understand what it means
- Symmetric difference returns rows that exist in exactly one of the two sets but not both. It is the opposite of an intersection. EXCEPT in both directions combined with UNION ALL gives the correct result.
Step 4: Know the alternative approach using FULL OUTER JOIN
- Symmetric difference can also be expressed as a FULL OUTER JOIN where either side is NULL, which gives more flexibility when you also want to label which set each row came from.
Solution
Part 1: Roundtrip Distance
SQL Server
SELECT r1.origin, r1.destination, r1.distance_km AS outbound_km, r2.distance_km AS return_km, r1.distance_km + r2.distance_km AS roundtrip_km FROM flight_routes r1 JOIN flight_routes r2 ON r1.origin = r2.destination AND r1.destination = r2.origin WHERE r1.origin < r1.destination ORDER BY r1.origin;
PostgreSQL
SELECT r1.origin, r1.destination, r1.distance_km AS outbound_km, r2.distance_km AS return_km, r1.distance_km + r2.distance_km AS roundtrip_km FROM flight_routes r1 JOIN flight_routes r2 ON r1.origin = r2.destination AND r1.destination = r2.origin WHERE r1.origin < r1.destination ORDER BY r1.origin;
MySQL 8+
SELECT r1.origin, r1.destination, r1.distance_km AS outbound_km, r2.distance_km AS return_km, r1.distance_km + r2.distance_km AS roundtrip_km FROM flight_routes r1 JOIN flight_routes r2 ON r1.origin = r2.destination AND r1.destination = r2.origin WHERE r1.origin < r1.destination ORDER BY r1.origin;
The self join logic is identical across all three platforms.
What to say: I join the table to itself on the reverse route condition, meaning the origin of one route matches the destination of the other and vice versa. The WHERE clause origin less than destination ensures each roundtrip pair appears exactly once since alphabetically DEL comes before BOM, keeping only the DEL-BOM row and not the BOM-DEL duplicate.
Part 2: Symmetric Difference
SQL Server
-- Rows in A but not B SELECT product_id, 'Only in Store A' AS source FROM products_store_a WHERE product_id NOT IN (SELECT product_id FROM products_store_b) UNION ALL -- Rows in B but not A SELECT product_id, 'Only in Store B' AS source FROM products_store_b WHERE product_id NOT IN (SELECT product_id FROM products_store_a) ORDER BY product_id;
PostgreSQL
-- Using EXCEPT (cleaner syntax) SELECT product_id, 'Only in Store A' AS source FROM products_store_a EXCEPT SELECT product_id, 'Only in Store A' FROM products_store_b UNION ALL SELECT product_id, 'Only in Store B' AS source FROM products_store_b EXCEPT SELECT product_id, 'Only in Store B' FROM products_store_a ORDER BY product_id;
MySQL 8+
SELECT product_id, 'Only in Store A' AS source FROM products_store_a WHERE product_id NOT IN (SELECT product_id FROM products_store_b) UNION ALL SELECT product_id, 'Only in Store B' AS source FROM products_store_b WHERE product_id NOT IN (SELECT product_id FROM products_store_a) ORDER BY product_id;
What to say: Symmetric difference is products that exist in one set but not the other. I handle it as two separate NOT IN checks unioned together, one for each direction. In PostgreSQL EXCEPT is cleaner and more readable. In SQL Server and MySQL NOT IN is the standard approach since EXCEPT does not support selecting additional literal columns alongside the set comparison as cleanly.
Expected Output — Roundtrip
| origin | destination | outbound_km | return_km | roundtrip_km |
|---|---|---|---|---|
| BLR | HYD | 500 | 500 | 1000 |
| BOM | DEL | 1150 | 1150 | 2300 |
Expected Output — Symmetric Difference
| product_id | source |
|---|---|
| P01 | Only in Store A |
| P04 | Only in Store A |
| P05 | Only in Store B |
| P06 | Only in Store B |
Follow-up Questions & Answers
Q1. Why does the roundtrip query use origin less than destination as the filter instead of something else?
Without this filter the self join produces both DEL-BOM paired with BOM-DEL and BOM-DEL paired with DEL-BOM as two separate result rows, meaning every roundtrip pair appears twice. The condition origin less than destination keeps only the canonical direction where the alphabetically smaller city name is the origin. Since string comparison is consistent, DEL comes before BOM alphabetically so only the BOM-DEL row is kept. This is a simple and reliable deduplication trick for self join pair problems where you want exactly one row per pair regardless of which row is the left or right side.
Q2. What is the difference between symmetric difference and a FULL OUTER JOIN with IS NULL filters?
A symmetric difference returns rows that exist in one set but not both, with no information about which set each row came from. A FULL OUTER JOIN approach gives you the same rows but also lets you label which set each row came from and compare column values between the two sides.
-- FULL OUTER JOIN approach with labelling SELECT COALESCE(a.product_id, b.product_id) AS product_id, CASE WHEN b.product_id IS NULL THEN 'Only in Store A' WHEN a.product_id IS NULL THEN 'Only in Store B' END AS source FROM products_store_a a FULL OUTER JOIN products_store_b b ON a.product_id = b.product_id WHERE a.product_id IS NULL OR b.product_id IS NULL ORDER BY product_id;
What to say: The FULL OUTER JOIN approach is more powerful when you need to label which set each row came from or when you need to compare additional column values between the two sides, not just identify presence or absence. The NOT IN or EXCEPT approach is simpler and more readable when you only need to know which rows are exclusive to one set without needing any additional context from the other side.
Q3. What if the distances differ between the outbound and return routes due to different flight paths? How would you flag those?
SELECT r1.origin, r1.destination, r1.distance_km AS outbound_km, r2.distance_km AS return_km, r1.distance_km + r2.distance_km AS roundtrip_km, CASE WHEN r1.distance_km != r2.distance_km THEN 'Distance mismatch' ELSE 'Consistent' END AS distance_status FROM flight_routes r1 JOIN flight_routes r2 ON r1.origin = r2.destination AND r1.destination = r2.origin WHERE r1.origin < r1.destination ORDER BY r1.origin;
What to say: Adding a CASE WHEN comparison between the two distance columns flags any route where the outbound and return distances differ, which might indicate a data entry error, a different routing path, or a legitimate asymmetric route. This is a common real world data quality check in logistics and aviation datasets where the same physical route should have the same distance in both directions.
Q4. How would you find routes that exist in only one direction, meaning routes with no return path?
This is the anti-join version of the roundtrip problem, finding routes that are one-way only.
SELECT r1.origin, r1.destination, r1.distance_km FROM flight_routes r1 WHERE NOT EXISTS ( SELECT 1 FROM flight_routes r2 WHERE r2.origin = r1.destination AND r2.destination = r1.origin ) ORDER BY r1.origin;
What to say: NOT EXISTS checks whether a return route exists for each outbound route. Routes that have no corresponding reverse route are the one-way routes. In our data, BOM-GOA and DEL-CCU would appear here since neither has a return path in the table. This is the logical complement of the roundtrip query and uses the same pattern flipped into an anti-join.
Q5. How would you extend the symmetric difference to handle three stores instead of two?
-- Products exclusive to each store when comparing three stores SELECT product_id, 'Only in A' AS source FROM products_store_a WHERE product_id NOT IN (SELECT product_id FROM products_store_b) AND product_id NOT IN (SELECT product_id FROM products_store_c) UNION ALL SELECT product_id, 'Only in B' AS source FROM products_store_b WHERE product_id NOT IN (SELECT product_id FROM products_store_a) AND product_id NOT IN (SELECT product_id FROM products_store_c) UNION ALL SELECT product_id, 'Only in C' AS source FROM products_store_c WHERE product_id NOT IN (SELECT product_id FROM products_store_a) AND product_id NOT IN (SELECT product_id FROM products_store_b) ORDER BY product_id;
What to say: For three or more sets, each UNION ALL branch checks that the product does not appear in any of the other sets. The pattern scales linearly with the number of sets but becomes verbose. For a dynamic number of stores stored in a single table with a store identifier column, I would redesign the approach using a groupBy with HAVING COUNT(DISTINCT store_id) equals one to find products appearing in exactly one store, which is much more maintainable than hardcoding each store as a separate branch.
Consecutive Value Difference and Minimum per Group
The Interviewer Says
Two parts again. First, we have a sensor readings table where each sensor logs a value every day. Write a query to show the difference between each reading and the next reading for the same sensor, so we can detect spikes. Second, we have an employee commission table where each employee earns commission in different months. Find the month where each employee earned their lowest commission. Walk me through both.
Input Table: sensor_readings
| sensor_id | reading_date | value |
|---|---|---|
| S01 | 2025-01-01 | 10 |
| S01 | 2025-01-02 | 15 |
| S01 | 2025-01-03 | 30 |
| S02 | 2025-01-01 | 10 |
| S02 | 2025-01-02 | 20 |
| S02 | 2025-01-03 | 30 |
Input Table: employee_commission
| emp_id | commission_amt | month_end_date |
|---|---|---|
| E01 | 300 | 2025-01-31 |
| E01 | 400 | 2025-02-28 |
| E01 | 200 | 2025-03-31 |
| E02 | 1000 | 2025-10-31 |
| E02 | 900 | 2025-12-31 |
How to Handle This in the Interview
Before writing anything, say: Both of these use window functions but for different purposes. The sensor spike question uses LEAD to compare a row with the next row. The minimum commission question uses RANK or a subquery to find the row with the minimum value per group.
Step 1: Sensor difference — LEAD vs LAG
- LEAD looks at the next row, LAG looks at the previous row. For difference between current and next, LEAD is the natural choice. For difference between current and previous, LAG is used.
Step 2: Handle the last row for each sensor
- The last reading for each sensor has no next value, so LEAD returns NULL. Decide whether to keep or drop that row based on the business requirement.
Step 3: Minimum commission — know two approaches
- A subquery finding MIN per emp_id and then joining back to get the full row. Or a window function using RANK ordered by commission ascending, filtering for rank equals one.
Step 4: Handle ties in minimum
- If two months have the same minimum commission for the same employee, RANK returns both. ROW_NUMBER arbitrarily picks one. Clarify with the interviewer which is correct.
Solution
Part 1: Sensor Consecutive Difference
SQL Server
SELECT sensor_id, reading_date, value AS current_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS next_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) - value AS value_difference FROM sensor_readings ORDER BY sensor_id, reading_date;
PostgreSQL
SELECT sensor_id, reading_date, value AS current_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS next_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) - value AS value_difference FROM sensor_readings ORDER BY sensor_id, reading_date;
MySQL 8+
SELECT sensor_id, reading_date, value AS current_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS next_value, LEAD(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) - value AS value_difference FROM sensor_readings ORDER BY sensor_id, reading_date;
Syntax is identical across all three platforms.
What to say: I partition by sensor_id so the LEAD function only looks at the next reading for the same sensor and never crosses into another sensor's data. Ordering by reading_date ensures the next row is chronologically the next reading. The last row for each sensor returns NULL for next_value and NULL for value_difference since there is no following row, which I would either leave as NULL or filter out depending on whether the business wants to see those terminal rows.
Part 2: Minimum Commission per Employee
SQL Server
WITH ranked_commission AS ( SELECT emp_id, commission_amt, month_end_date, RANK() OVER (PARTITION BY emp_id ORDER BY commission_amt ASC) AS comm_rank FROM employee_commission ) SELECT emp_id, commission_amt, month_end_date FROM ranked_commission WHERE comm_rank = 1 ORDER BY emp_id;
PostgreSQL
WITH ranked_commission AS ( SELECT emp_id, commission_amt, month_end_date, RANK() OVER (PARTITION BY emp_id ORDER BY commission_amt ASC) AS comm_rank FROM employee_commission ) SELECT emp_id, commission_amt, month_end_date FROM ranked_commission WHERE comm_rank = 1 ORDER BY emp_id;
MySQL 8+
WITH ranked_commission AS ( SELECT emp_id, commission_amt, month_end_date, RANK() OVER (PARTITION BY emp_id ORDER BY commission_amt ASC) AS comm_rank FROM employee_commission ) SELECT emp_id, commission_amt, month_end_date FROM ranked_commission WHERE comm_rank = 1 ORDER BY emp_id;
What to say: I use RANK ordered ascending by commission_amt so the lowest commission gets rank 1. Filtering for rank equals 1 gives the minimum commission row per employee. I use RANK rather than ROW_NUMBER because if an employee had the same lowest commission in two different months, both months should appear in the result. ROW_NUMBER would arbitrarily pick only one.
Expected Output — Sensor Difference
| sensor_id | reading_date | current_value | next_value | value_difference |
|---|---|---|---|---|
| S01 | 2025-01-01 | 10 | 15 | 5 |
| S01 | 2025-01-02 | 15 | 30 | 15 |
| S01 | 2025-01-03 | 30 | NULL | NULL |
| S02 | 2025-01-01 | 10 | 20 | 10 |
| S02 | 2025-01-02 | 20 | 30 | 10 |
| S02 | 2025-01-03 | 30 | NULL | NULL |
Expected Output — Minimum Commission
| emp_id | commission_amt | month_end_date |
|---|---|---|
| E01 | 200 | 2025-03-31 |
| E02 | 900 | 2025-12-31 |
Follow-up Questions & Answers
Q1. What is the difference between LEAD and LAG, and how would you rewrite the sensor query using LAG to show the difference from the previous reading instead?
LEAD fetches the value from a row that comes after the current row in the specified order. LAG fetches the value from a row that comes before the current row. Both accept the same arguments: column name, offset which defaults to one, and a default value for when no previous or next row exists.
-- Using LAG to show difference from previous reading SELECT sensor_id, reading_date, value AS current_value, LAG(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS previous_value, value - LAG(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS change_from_previous FROM sensor_readings ORDER BY sensor_id, reading_date;
What to say: With LAG the first row for each sensor has no previous value so it shows NULL for previous_value and NULL for change_from_previous, while the last row shows a meaningful value. With LEAD it is the last row that shows NULL. I choose between them based on whether the business question is looking backward at what changed or forward at what is coming.
Q2. Can you solve the minimum commission without using window functions, using only a subquery?
Yes, this is the pre-window-function approach and interviewers sometimes ask for it specifically.
SELECT e1.emp_id, e1.commission_amt, e1.month_end_date FROM employee_commission e1 JOIN ( SELECT emp_id, MIN(commission_amt) AS min_commission FROM employee_commission GROUP BY emp_id ) e2 ON e1.emp_id = e2.emp_id AND e1.commission_amt = e2.min_commission ORDER BY e1.emp_id;
What to say: The subquery finds the minimum commission per employee as a single aggregated value. Joining it back to the original table on both emp_id and commission_amt retrieves the full row including the month date for that minimum value. This naturally handles ties since any row matching the minimum commission amount will satisfy the join condition. The window function approach with RANK is more readable and avoids the two-pass nature of the subquery approach.
Q3. How would you detect spikes in the sensor data, meaning readings that jumped by more than 50 percent compared to the previous reading?
WITH sensor_with_prev AS ( SELECT sensor_id, reading_date, value, LAG(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS previous_value FROM sensor_readings ) SELECT sensor_id, reading_date, previous_value, value AS current_value, ROUND((value - previous_value) * 100.0 / NULLIF(previous_value, 0), 2) AS pct_change, 'Spike detected' AS alert FROM sensor_with_prev WHERE previous_value IS NOT NULL AND ABS(value - previous_value) * 100.0 / NULLIF(previous_value, 0) > 50 ORDER BY sensor_id, reading_date;
What to say: I wrap the LAG calculation in a CTE so I can reference previous_value in both the WHERE clause and the SELECT without repeating the window function expression twice. NULLIF on previous_value guards against division by zero when a sensor starts from zero. The ABS function catches both sudden spikes upward and sudden drops downward since both represent anomalous behaviour worth alerting on.
Q4. What if you need both the minimum and maximum commission for each employee in the same row?
SELECT emp_id, MIN(commission_amt) AS min_commission, MAX(commission_amt) AS max_commission, MAX(commission_amt) - MIN(commission_amt) AS commission_range, ROUND(AVG(commission_amt), 2) AS avg_commission FROM employee_commission GROUP BY emp_id ORDER BY emp_id;
What to say: When the requirement is just the aggregated values without the associated date, a simple GROUP BY with MIN and MAX is cleaner and faster than window functions. I would use the window function approach only when I also need to know which specific month each extreme value occurred in. For summary statistics alone, GROUP BY is the right tool.
Q5. How would you find sensors where the value never decreased, meaning each reading was always greater than or equal to the previous one?
WITH sensor_changes AS ( SELECT sensor_id, reading_date, value, LAG(value) OVER (PARTITION BY sensor_id ORDER BY reading_date) AS previous_value FROM sensor_readings ), sensors_with_decrease AS ( SELECT DISTINCT sensor_id FROM sensor_changes WHERE previous_value IS NOT NULL AND value < previous_value ) SELECT DISTINCT sensor_id FROM sensor_readings WHERE sensor_id NOT IN ( SELECT sensor_id FROM sensors_with_decrease ) ORDER BY sensor_id;
What to say: I find all sensors that had at least one decrease using LAG, then use NOT IN to exclude those sensors from the full list of sensors. What remains are sensors where values only stayed flat or increased throughout the entire measurement period. This is a useful pattern in quality control monitoring where a consistently rising metric might indicate healthy system growth while any decrease flags something needing investigation.
String Manipulation and Pattern-Based Column Generation
The Interviewer Says
Two string manipulation questions. First, we have a movie titles table and I want you to reverse each individual word in the title while keeping the word order the same. So The Social Network becomes ehT laicoS krowtteN. Second, we have a food rating table and I want you to display the rating as a row of stars, so a rating of four becomes four asterisks. Walk me through your approach to both.
Input Table: movie_titles
| movie_id | title |
|---|---|
| M01 | The Social Network |
| M02 | Inception |
| M03 | Dark Knight |
| M04 | Interstellar |
Input Table: food_ratings
| food_id | food_name | rating |
|---|---|---|
| F01 | Veg Biryani | 5 |
| F02 | Masala Dosa | 3 |
| F03 | Paneer Tikka | 4 |
| F04 | Chole Bhature | 4 |
| F05 | Mango Lassi | 2 |
How to Handle This in the Interview
Before writing anything, say: Both of these are pure string function questions. The word reversal tests whether you know how to split a string into parts and process each part individually in SQL, which is trickier in SQL than in Python. The star rating test whether you know the REPEAT function.
Step 1: Word reversal — understand the limitation
- Say: SQL does not have a built-in split-by-word function in most databases. The approach depends on how many words the title has. For a dynamic number of words this is very difficult in pure SQL without a recursive CTE or a custom function. I will use STRING_SPLIT in SQL Server or UNNEST with STRING_TO_ARRAY in PostgreSQL.
Step 2: Star rating — REPEAT is the key function
- REPEAT(string, count) repeats a string N times. REPEAT is available in SQL Server, PostgreSQL, and MySQL. It is one of those functions interviewers use to test whether you know your string function library.
Step 3: Mention the platform differences
- SQL Server uses STRING_SPLIT and REVERSE. PostgreSQL uses STRING_TO_ARRAY, UNNEST, and REVERSE. MySQL uses a workaround since STRING_SPLIT does not exist before MySQL 8 and even in 8 it has limitations.
Step 4: Ask about the word count constraint
- Ask: Are titles always exactly two or three words, or can they have any number of words? This determines whether a simple CHARINDEX based approach works or whether a more dynamic split-and-rejoin approach is needed.
Solution
Part 1: Reverse Each Word in Title
SQL Server
WITH split_words AS ( SELECT m.movie_id, m.title, s.value AS word, ROW_NUMBER() OVER (PARTITION BY m.movie_id ORDER BY (SELECT NULL)) AS word_position FROM movie_titles m CROSS APPLY STRING_SPLIT(m.title, ' ') s ), reversed_words AS ( SELECT movie_id, title, word_position, REVERSE(word) AS reversed_word FROM split_words ) SELECT movie_id, title AS original_title, STRING_AGG(reversed_word, ' ') WITHIN GROUP (ORDER BY word_position) AS reversed_words_title FROM reversed_words GROUP BY movie_id, title ORDER BY movie_id;
PostgreSQL
WITH split_words AS ( SELECT movie_id, title, word, ordinality AS word_position FROM movie_titles CROSS JOIN LATERAL UNNEST(STRING_TO_ARRAY(title, ' ')) WITH ORDINALITY AS t(word, ordinality) ), reversed_words AS ( SELECT movie_id, title, word_position, REVERSE(word) AS reversed_word FROM split_words ) SELECT movie_id, title AS original_title, STRING_AGG(reversed_word, ' ' ORDER BY word_position) AS reversed_words_title FROM reversed_words GROUP BY movie_id, title ORDER BY movie_id;
MySQL 8+
-- MySQL does not have a clean split-reverse-rejoin in pure SQL -- For a fixed maximum word count, use SUBSTRING_INDEX SELECT movie_id, title AS original_title, CONCAT_WS(' ', REVERSE(SUBSTRING_INDEX(SUBSTRING_INDEX(title, ' ', 1), ' ', -1)), CASE WHEN LOCATE(' ', title, LOCATE(' ', title) + 1) > 0 THEN REVERSE(SUBSTRING_INDEX(SUBSTRING_INDEX(title, ' ', 2), ' ', -1)) ELSE NULL END, CASE WHEN LENGTH(title) - LENGTH(REPLACE(title, ' ', '')) >= 2 THEN REVERSE(SUBSTRING_INDEX(title, ' ', -1)) ELSE NULL END ) AS reversed_words_title FROM movie_titles ORDER BY movie_id;
MySQL requires a workaround using SUBSTRING_INDEX for each word position since it lacks a native split function that returns rows with ordinal position. This approach only works up to a fixed maximum number of words.
What to say: I use STRING_SPLIT in SQL Server and UNNEST with ordinality in PostgreSQL to split the title into one row per word while keeping the original word position. I then REVERSE each word individually and use STRING_AGG ordered by the original word position to rejoin them. This preserves the word order while reversing the characters within each word. MySQL is the hardest platform for this since its string splitting capabilities are limited, making this a good question to mention the platform dependency upfront.
Part 2: Star Rating Display
SQL Server
SELECT food_id, food_name, rating, REPLICATE('*', rating) AS rating_stars, REPLICATE('*', rating) + REPLICATE('-', 5 - rating) AS rating_out_of_5 FROM food_ratings ORDER BY food_id;
PostgreSQL
SELECT food_id, food_name, rating, REPEAT('*', rating) AS rating_stars, REPEAT('*', rating) || REPEAT('-', 5 - rating) AS rating_out_of_5 FROM food_ratings ORDER BY food_id;
MySQL 8+
SELECT food_id, food_name, rating, REPEAT('*', rating) AS rating_stars, CONCAT(REPEAT('*', rating), REPEAT('-', 5 - rating)) AS rating_out_of_5 FROM food_ratings ORDER BY food_id;
REPLICATE is SQL Server's version, REPEAT is PostgreSQL and MySQL's version. Both take the string to repeat and the count as arguments.
What to say: REPEAT or REPLICATE takes two arguments, the string to repeat and how many times to repeat it. For a rating of 4 out of 5, REPEAT star 4 gives four stars and REPEAT dash 1 gives one dash, concatenated together showing the filled and unfilled portions of the rating bar. This is a clean one-liner that works entirely within the SQL SELECT with no subqueries needed.
Expected Output — Reversed Words
| movie_id | original_title | reversed_words_title |
|---|---|---|
| M01 | The Social Network | ehT laicoS krowtteN |
| M02 | Inception | noitpecnI |
| M03 | Dark Knight | kraD thginK |
| M04 | Interstellar | ralletsretnI |
Expected Output — Star Rating
| food_id | food_name | rating | rating_stars | rating_out_of_5 |
|---|---|---|---|---|
| F01 | Veg Biryani | 5 | ***** | ***** |
| F02 | Masala Dosa | 3 | *** | ***-- |
| F03 | Paneer Tikka | 4 | **** | ****- |
| F04 | Chole Bhature | 4 | **** | ****- |
| F05 | Mango Lassi | 2 | ** | **--- |
Follow-up Questions & Answers
Q1. What other common string functions should every SQL developer know for interviews?
-- LEN / LENGTH: number of characters SELECT LEN('Hello World'); -- SQL Server: 11 SELECT LENGTH('Hello World'); -- PostgreSQL/MySQL: 11 -- UPPER / LOWER: change case SELECT UPPER('hello'), LOWER('WORLD'); -- LTRIM / RTRIM / TRIM: remove whitespace SELECT TRIM(' hello '); -- removes both sides -- SUBSTRING / SUBSTR: extract part of string SELECT SUBSTRING('Hello World', 1, 5); -- 'Hello' -- CHARINDEX / POSITION / LOCATE: find position of substring SELECT CHARINDEX('World', 'Hello World'); -- SQL Server: 7 SELECT POSITION('World' IN 'Hello World'); -- PostgreSQL: 7 -- REPLACE: substitute characters SELECT REPLACE('Hello World', 'World', 'SQL'); -- 'Hello SQL' -- LEFT / RIGHT: extract from either end SELECT LEFT('Hello World', 5); -- 'Hello' SELECT RIGHT('Hello World', 5); -- 'World' -- CONCAT / + operator: join strings SELECT CONCAT('Hello', ' ', 'World'); -- 'Hello World' SELECT 'Hello' + ' ' + 'World'; -- SQL Server only -- FORMAT: format numbers and dates as strings SELECT FORMAT(1234567.89, 'N2'); -- SQL Server: '1,234,567.89'
What to say: The string functions I reach for most often in real work are TRIM for cleaning whitespace from ingested data, REPLACE for removing unwanted characters before casting, SUBSTRING and CHARINDEX for extracting parts of structured strings like codes embedded in longer identifiers, and CONCAT for building display labels. Knowing which function name applies to which platform, for example CHARINDEX in SQL Server versus POSITION in PostgreSQL, is a common source of interview stumbles.
Q2. How would you reverse the entire title string rather than word by word?
-- Reverse entire string (all characters, not just word by word) SELECT movie_id, title, REVERSE(title) AS fully_reversed FROM movie_titles; -- 'The Social Network' becomes 'krowtteN laicoS ehT'
What to say: REVERSE on the whole string reverses every character including the spaces, which swaps both the word order and each word's character order simultaneously. The word-by-word reversal in the main question preserves word order while only reversing characters within each word, which requires splitting and rejoining as shown. Clarifying which behaviour the interviewer wants before writing any code is important since both interpretations of reverse a title are reasonable.
Q3. How would you use CHARINDEX to extract the username from an email address?
SELECT email, -- Extract everything before the @ symbol LEFT(email, CHARINDEX('@', email) - 1) AS username, -- Extract everything after the @ symbol RIGHT(email, LEN(email) - CHARINDEX('@', email)) AS domain FROM ( VALUES ('karan@gmail.com'), ('asha.singh@company.co.in'), ('ravi123@outlook.com') ) AS emails(email);
What to say: CHARINDEX returns the position of the at symbol in the email string. Subtracting one gives the number of characters in the username portion, which LEFT then extracts. For the domain, subtracting the at symbol position from the total string length gives the number of characters after the at symbol, which RIGHT extracts. This pattern, finding a delimiter position and using LEFT and RIGHT relative to it, applies to parsing any structured string like a file path, a URL, or a product code.
Q4. A rating column has a NULL value for one food item. How does REPEAT handle that?
When the count argument to REPEAT or REPLICATE is NULL, the result is also NULL. When the string argument is NULL, the result is also NULL. This means a food item with a NULL rating would produce NULL in the rating_stars column rather than an empty string or an error.
SELECT food_id, food_name, rating, ISNULL(REPLICATE('*', rating), 'No rating') AS rating_stars FROM food_ratings ORDER BY food_id; -- Or replace null rating with 0 before repeating SELECT food_id, food_name, rating, REPLICATE('*', COALESCE(rating, 0)) AS rating_stars FROM food_ratings ORDER BY food_id;
What to say: I always check the NULL behaviour of string functions in SQL before using them on production data because different functions handle NULL arguments differently. REPEAT with a NULL count returns NULL rather than an empty string, which might be unexpected. I use COALESCE to replace the NULL rating with zero before passing it to REPLICATE, which produces an empty string for unrated items rather than NULL, making the output cleaner for report display.
Q5. How would you find all movie titles where any individual word is a palindrome?
WITH split_words AS ( SELECT m.movie_id, m.title, s.value AS word FROM movie_titles m CROSS APPLY STRING_SPLIT(m.title, ' ') s ) SELECT DISTINCT movie_id, title, word AS palindrome_word FROM split_words WHERE word = REVERSE(word) AND LEN(word) > 1 ORDER BY movie_id;
What to say: A palindrome is a word that reads the same forwards and backwards, meaning the word equals its own reverse. I split the title into individual words exactly as in the main solution, then compare each word to its REVERSE. The LEN greater than 1 condition excludes single character words since every single character is trivially a palindrome. This type of question combines string splitting with string comparison and is a good example of how SQL can perform surprisingly complex text processing when combined with window functions and CTEs.
Age Group Bucketing and Horizontal Column Sum
The Interviewer Says
Two straightforward but commonly asked questions. First, I have a customer table with age. Group customers into age buckets: Young for age under 30, Mid for age 30 to 50, and Senior for above 50. Second, I have a student marks table with separate columns for each subject. Calculate the total marks for each student by summing all subject columns horizontally. Walk me through both.
Input Table: customers
| customer_id | name | age | city |
|---|---|---|---|
| C01 | Karan | 25 | Mumbai |
| C02 | Asha | 34 | Delhi |
| C03 | Ravi | 52 | Pune |
| C04 | Meena | 29 | Chennai |
| C05 | Sunil | 41 | Bangalore |
| C06 | Divya | 67 | Hyderabad |
| C07 | Prakash | 19 | Mumbai |
Input Table: student_marks
| roll_no | name | maths | science | english | history | geography |
|---|---|---|---|---|---|---|
| 101 | Arjun | 85 | 90 | 78 | 82 | 88 |
| 102 | Priya | 92 | 88 | 95 | 79 | 91 |
| 103 | Rahul | 70 | 75 | 68 | 85 | 72 |
| 104 | Sneha | 88 | 92 | 84 | 90 | 86 |
How to Handle This in the Interview
Before writing anything, say: Both of these are fundamentally CASE WHEN and arithmetic problems. The age bucketing tests whether you know how to categorise continuous numeric values into discrete groups. The horizontal sum tests whether you know that SQL aggregates like SUM work on groups of rows, not across columns, so summing multiple columns requires simple arithmetic addition.
Step 1: Age bucketing — ordering of conditions matters
- In a CASE WHEN chain the conditions are evaluated top to bottom and the first match wins. If you write WHEN age > 30 before WHEN age > 50, a 60 year old customer would be caught by the first condition and incorrectly labelled Mid rather than Senior.
Step 2: Include the boundary values explicitly
- Ask: Is age 30 Young or Mid? Is age 50 Mid or Senior? Boundary values often reveal ambiguity in the business requirement. Always clarify before writing conditions.
Step 3: Horizontal sum — it is just addition
- Say: SUM in SQL aggregates values vertically across rows in a group. To sum values horizontally across columns in the same row, I simply add the column expressions together with the plus operator. There is no special function needed.
Step 4: Handle NULLs in subject columns
- If any subject column has a NULL value, plain addition would make the entire total NULL. Use COALESCE or ISNULL on each column before adding.
Solution
Part 1: Age Group Bucketing
SQL Server
SELECT customer_id, name, age, city, CASE WHEN age < 30 THEN 'Young' WHEN age BETWEEN 30 AND 50 THEN 'Mid' ELSE 'Senior' END AS age_group FROM customers ORDER BY age;
PostgreSQL
SELECT customer_id, name, age, city, CASE WHEN age < 30 THEN 'Young' WHEN age BETWEEN 30 AND 50 THEN 'Mid' ELSE 'Senior' END AS age_group FROM customers ORDER BY age;
MySQL 8+
SELECT customer_id, name, age, city, CASE WHEN age < 30 THEN 'Young' WHEN age BETWEEN 30 AND 50 THEN 'Mid' ELSE 'Senior' END AS age_group FROM customers ORDER BY age;
Syntax is identical across all three platforms.
What to say: I use BETWEEN for the mid range which is inclusive on both ends, meaning age 30 and age 50 both fall into the Mid bucket. I order the CASE conditions from youngest to oldest so the first matching condition captures the correct bucket. The ELSE handles anyone above 50 as Senior without needing to explicitly write WHEN age greater than 50.
Part 2: Horizontal Column Sum
SQL Server
SELECT roll_no, name, maths, science, english, history, geography, ISNULL(maths, 0) + ISNULL(science, 0) + ISNULL(english, 0) + ISNULL(history, 0) + ISNULL(geography, 0) AS total_marks, ROUND( ( ISNULL(maths, 0) + ISNULL(science, 0) + ISNULL(english, 0) + ISNULL(history, 0) + ISNULL(geography, 0) ) * 1.0 / 5, 2 ) AS average_marks, CASE WHEN (maths + science + english + history + geography) >= 450 THEN 'Distinction' WHEN (maths + science + english + history + geography) >= 350 THEN 'First Class' WHEN (maths + science + english + history + geography) >= 250 THEN 'Pass' ELSE 'Fail' END AS result_grade FROM student_marks ORDER BY total_marks DESC;
PostgreSQL
SELECT roll_no, name, maths, science, english, history, geography, COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) AS total_marks, ROUND( ( COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) )::NUMERIC / 5, 2 ) AS average_marks, CASE WHEN (maths + science + english + history + geography) >= 450 THEN 'Distinction' WHEN (maths + science + english + history + geography) >= 350 THEN 'First Class' WHEN (maths + science + english + history + geography) >= 250 THEN 'Pass' ELSE 'Fail' END AS result_grade FROM student_marks ORDER BY total_marks DESC;
MySQL 8+
SELECT roll_no, name, maths, science, english, history, geography, COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) AS total_marks, ROUND( ( COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) ) / 5.0, 2 ) AS average_marks FROM student_marks ORDER BY total_marks DESC;
What to say: Horizontal summing across columns is pure column arithmetic, not an aggregate function. I wrap each column in COALESCE or ISNULL to handle potential NULLs because any NULL in the addition chain makes the entire sum NULL. For the average I divide by the total number of subjects rather than using AVG, since AVG works on groups of rows not across columns. I also add a grade classification using CASE WHEN on the total to show a common real-world extension of this type of question.
Expected Output — Age Buckets
| customer_id | name | age | city | age_group |
|---|---|---|---|---|
| C07 | Prakash | 19 | Mumbai | Young |
| C01 | Karan | 25 | Mumbai | Young |
| C04 | Meena | 29 | Chennai | Young |
| C02 | Asha | 34 | Delhi | Mid |
| C05 | Sunil | 41 | Bangalore | Mid |
| C03 | Ravi | 52 | Pune | Senior |
| C06 | Divya | 67 | Hyderabad | Senior |
Expected Output — Student Marks
| roll_no | name | maths | science | english | history | geography | total_marks | average_marks | result_grade |
|---|---|---|---|---|---|---|---|---|---|
| 102 | Priya | 92 | 88 | 95 | 79 | 91 | 445 | 89.00 | First Class |
| 104 | Sneha | 88 | 92 | 84 | 90 | 86 | 440 | 88.00 | First Class |
| 101 | Arjun | 85 | 90 | 78 | 82 | 88 | 423 | 84.60 | First Class |
| 103 | Rahul | 70 | 75 | 68 | 85 | 72 | 370 | 74.00 | First Class |
Follow-up Questions & Answers
Q1. How would you also count how many customers fall into each age group?
SELECT CASE WHEN age < 30 THEN 'Young' WHEN age BETWEEN 30 AND 50 THEN 'Mid' ELSE 'Senior' END AS age_group, COUNT(*) AS customer_count, ROUND(AVG(age * 1.0), 1) AS avg_age_in_group, MIN(age) AS youngest, MAX(age) AS oldest FROM customers GROUP BY CASE WHEN age < 30 THEN 'Young' WHEN age BETWEEN 30 AND 50 THEN 'Mid' ELSE 'Senior' END ORDER BY MIN(age);
What to say: I repeat the CASE WHEN expression in the GROUP BY clause since SQL does not allow referencing a column alias from the SELECT in the GROUP BY in most databases. An alternative in SQL Server and PostgreSQL is to wrap the query in a CTE or subquery that computes the age_group first, then GROUP BY age_group in the outer query, which avoids repeating the CASE expression and is cleaner to read.
Q2. What if a student was absent for one subject and their mark is NULL? How does the total change?
Without COALESCE, adding any NULL makes the entire sum NULL, meaning a student absent for one subject would show NULL total marks rather than the sum of the subjects they did take.
-- Without null handling: Rahul absent for science -- maths(70) + NULL + english(68) + history(85) + geography(72) = NULL -- With COALESCE: treats absent subjects as 0 -- 70 + 0 + 68 + 85 + 72 = 295 -- Better approach: count only subjects attempted for average SELECT roll_no, name, COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) AS total_marks, ( COALESCE(maths, 0) + COALESCE(science, 0) + COALESCE(english, 0) + COALESCE(history, 0) + COALESCE(geography, 0) ) * 1.0 / NULLIF( (CASE WHEN maths IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN science IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN english IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN history IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN geography IS NOT NULL THEN 1 ELSE 0 END), 0 ) AS average_attempted FROM student_marks;
What to say: The business decision about NULL marks matters enormously. Treating absence as zero penalises the student in the total. Excluding absent subjects from the average is fairer but requires counting how many subjects were actually attempted to use as the denominator. I always clarify with the stakeholder whether NULL means absent and should count as zero, or absent and should be excluded from the average calculation, since these give very different results.
Q3. How would you unpivot the student marks table from wide format to long format with one row per student per subject?
-- SQL Server: UNPIVOT SELECT roll_no, name, subject, marks FROM student_marks UNPIVOT (marks FOR subject IN (maths, science, english, history, geography)) AS unpvt ORDER BY roll_no, subject; -- SQL Server: CROSS APPLY with VALUES (more flexible) SELECT s.roll_no, s.name, u.subject, u.marks FROM student_marks s CROSS APPLY ( VALUES ('Maths', maths), ('Science', science), ('English', english), ('History', history), ('Geography', geography) ) AS u(subject, marks) ORDER BY s.roll_no, u.subject;
What to say: Unpivoting turns the wide multi-column format into a normalised long format with one row per student per subject. This is useful when you want to apply aggregations like finding the highest or lowest scoring subject for each student, or when you need to store the marks in a normalised database table rather than a reporting spreadsheet. The CROSS APPLY with VALUES approach is more flexible than native UNPIVOT because it handles NULL marks more gracefully and allows custom labels for the subject names.
Q4. How would you rank students within each age group based on their total marks?
This combines the age bucketing with the horizontal sum and then adds ranking within groups.
WITH student_totals AS ( SELECT s.roll_no, s.name, c.age, CASE WHEN c.age < 20 THEN 'Teen' WHEN c.age BETWEEN 20 AND 25 THEN 'Young Adult' ELSE 'Adult' END AS age_group, s.maths + s.science + s.english + s.history + s.geography AS total_marks FROM student_marks s JOIN customers c ON s.roll_no = c.customer_id ) SELECT roll_no, name, age, age_group, total_marks, RANK() OVER (PARTITION BY age_group ORDER BY total_marks DESC) AS rank_in_age_group FROM student_totals ORDER BY age_group, rank_in_age_group;
What to say: This is a good example of combining multiple concepts in one query. I compute the age group and total marks in a CTE first, then apply the window function ranking in the outer query rather than trying to do everything in one SELECT. This layered CTE approach keeps each step readable and avoids nesting window functions inside other expressions which SQL does not allow directly.
Q5. How would you find the subject in which each student scored the highest marks?
WITH unpivoted AS ( SELECT roll_no, name, subject, marks FROM student_marks CROSS APPLY ( VALUES ('Maths', maths), ('Science', science), ('English', english), ('History', history), ('Geography', geography) ) AS u(subject, marks) ), ranked AS ( SELECT roll_no, name, subject, marks, RANK() OVER (PARTITION BY roll_no ORDER BY marks DESC) AS subject_rank FROM unpivoted WHERE marks IS NOT NULL ) SELECT roll_no, name, subject AS best_subject, marks AS best_marks FROM ranked WHERE subject_rank = 1 ORDER BY roll_no;
What to say: Finding the maximum value across columns requires unpivoting first since window functions and RANK operate on rows not columns. I unpivot to get one row per student per subject, rank within each student by marks descending, then filter for rank equals one to get each student's best subject. If two subjects tie for the highest mark, RANK returns both which is usually the correct business behaviour.
Range Join: Tour Eligibility by Family Size
The Interviewer Says
We have a travel company offering discounted tour packages. Each tour has a minimum and maximum group size eligibility. We have a families table with each family's size. Write a query to find which tours each family is eligible for, and then find the family that is eligible for the maximum number of tours. Walk me through your approach.
Input Table: families
| family_id | family_name | family_size |
|---|---|---|
| F01 | Sharma Family | 4 |
| F02 | Verma Family | 9 |
| F03 | Gupta Family | 2 |
| F04 | Singh Family | 6 |
| F05 | Patel Family | 3 |
Input Table: tour_packages
| tour_id | tour_name | min_size | max_size | destination |
|---|---|---|---|---|
| T01 | Hill Station | 2 | 4 | Shimla |
| T02 | Beach Resort | 4 | 8 | Goa |
| T03 | Heritage Tour | 4 | 7 | Rajasthan |
| T04 | Adventure Trek | 5 | 9 | Manali |
| T05 | City Explorer | 10 | 15 | Delhi |
| T06 | Weekend Getaway | 2 | 6 | Lonavala |
How to Handle This in the Interview
Before writing anything, say: This is a range join problem. Instead of joining on equality, I join where a value falls between two other values. The BETWEEN keyword or a combination of greater than or equal to and less than or equal to conditions in the ON clause handles this. Range joins are common in interview questions involving date ranges, price tiers, salary bands, and size eligibility.
Step 1: Understand the join condition
- A family is eligible for a tour if family_size is between the tour's min_size and max_size. This is a non-equi join, meaning the join condition is a range check rather than a direct equality match.
Step 2: Build the eligibility result first
- Start with a base query that shows every family-tour combination where the family qualifies. Then aggregate on top of that to answer the second part about who qualifies for the most tours.
Step 3: Find the maximum count
- Say: Finding the family with the maximum number of eligible tours requires a subquery or CTE to first count per family, then filter for the maximum count. Using TOP 1 with ORDER BY or a nested MAX subquery are both valid approaches.
Step 4: Handle ties in the maximum count
- If two families are eligible for the same maximum number of tours, both should appear. Use a subquery comparing to MAX rather than TOP 1 which would arbitrarily pick one.
Solution
SQL Server
-- Part 1: All eligible family-tour combinations SELECT f.family_id, f.family_name, f.family_size, t.tour_id, t.tour_name, t.destination, t.min_size, t.max_size FROM families f JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size ORDER BY f.family_id, t.tour_id; -- Part 2: Count eligible tours per family WITH eligibility AS ( SELECT f.family_id, f.family_name, f.family_size, COUNT(t.tour_id) AS eligible_tour_count FROM families f JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size GROUP BY f.family_id, f.family_name, f.family_size ) SELECT family_id, family_name, family_size, eligible_tour_count FROM eligibility WHERE eligible_tour_count = ( SELECT MAX(eligible_tour_count) FROM eligibility ) ORDER BY family_id;
PostgreSQL
WITH eligibility AS ( SELECT f.family_id, f.family_name, f.family_size, COUNT(t.tour_id) AS eligible_tour_count FROM families f JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size GROUP BY f.family_id, f.family_name, f.family_size ) SELECT family_id, family_name, family_size, eligible_tour_count FROM eligibility WHERE eligible_tour_count = ( SELECT MAX(eligible_tour_count) FROM eligibility ) ORDER BY family_id;
MySQL 8+
WITH eligibility AS ( SELECT f.family_id, f.family_name, f.family_size, COUNT(t.tour_id) AS eligible_tour_count FROM families f JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size GROUP BY f.family_id, f.family_name, f.family_size ) SELECT family_id, family_name, family_size, eligible_tour_count FROM eligibility WHERE eligible_tour_count = ( SELECT MAX(eligible_tour_count) FROM eligibility ) ORDER BY family_id;
The range join syntax BETWEEN min_size AND max_size is standard SQL and identical across all three platforms.
What to say: The key insight is that this is a non-equi join where the condition is a range check rather than equality. BETWEEN handles this cleanly in the ON clause. I build the full eligibility result first in a CTE, then in the outer query filter to only the family or families with the maximum eligible tour count using a subquery comparison to MAX rather than LIMIT 1, because LIMIT 1 would arbitrarily drop one family if there is a tie for the maximum.
Expected Output — Part 1 (Eligibility)
| family_id | family_name | family_size | tour_id | tour_name | destination |
|---|---|---|---|---|---|
| F01 | Sharma Family | 4 | T01 | Hill Station | Shimla |
| F01 | Sharma Family | 4 | T02 | Beach Resort | Goa |
| F01 | Sharma Family | 4 | T03 | Heritage Tour | Rajasthan |
| F01 | Sharma Family | 4 | T06 | Weekend Getaway | Lonavala |
| F02 | Verma Family | 9 | T02 | Beach Resort | Goa |
| F02 | Verma Family | 9 | T04 | Adventure Trek | Manali |
| F03 | Gupta Family | 2 | T01 | Hill Station | Shimla |
| F03 | Gupta Family | 2 | T06 | Weekend Getaway | Lonavala |
| F04 | Singh Family | 6 | T02 | Beach Resort | Goa |
| F04 | Singh Family | 6 | T03 | Heritage Tour | Rajasthan |
| F04 | Singh Family | 6 | T04 | Adventure Trek | Manali |
| F04 | Singh Family | 6 | T06 | Weekend Getaway | Lonavala |
| F05 | Patel Family | 3 | T01 | Hill Station | Shimla |
| F05 | Patel Family | 3 | T06 | Weekend Getaway | Lonavala |
Expected Output — Part 2 (Maximum Eligible Tours)
| family_id | family_name | family_size | eligible_tour_count |
|---|---|---|---|
| F01 | Sharma Family | 4 | 4 |
| F04 | Singh Family | 6 | 4 |
Follow-up Questions & Answers
Q1. What is a non-equi join and when would you use it in real data engineering work?
A non-equi join is a join where the condition uses a comparison operator other than equals, such as BETWEEN, greater than, less than, or not equals. In a standard equi-join like customer_id equals customer_id, each row from the left table matches at most the rows from the right table that have exactly the same key value. In a non-equi join like family_size BETWEEN min_size AND max_size, each row from the left table matches all rows from the right table where the value falls within a range, potentially producing many matches per left row. Real world uses include salary band classification where an employee's salary falls within a pay grade's minimum and maximum, date range overlaps where an event date falls within a campaign's start and end dates, and price tier assignment where a transaction amount falls within a discount bracket's range.
Q2. How would you find families that are not eligible for any tour at all?
SELECT f.family_id, f.family_name, f.family_size FROM families f WHERE NOT EXISTS ( SELECT 1 FROM tour_packages t WHERE f.family_size BETWEEN t.min_size AND t.max_size ) ORDER BY f.family_id;
What to say: NOT EXISTS checks whether a matching tour package exists for each family's size. If no tour package has a min_size and max_size range that includes the family_size, the family qualifies as ineligible for all tours and appears in the result. In our data, Verma Family with size 9 and Patel Family... wait, Verma with size 9 matches T02 and T04. Gupta with size 2 matches T01 and T06. The family with no eligible tours would be one with size 10 or above, which in our data is none, so this query would return an empty result, which is still worth knowing how to write.
Q3. How would you also show families that are eligible for no tours with a zero count in the eligible tour count result?
-- Use LEFT JOIN to keep families with no eligible tours SELECT f.family_id, f.family_name, f.family_size, COUNT(t.tour_id) AS eligible_tour_count FROM families f LEFT JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size GROUP BY f.family_id, f.family_name, f.family_size ORDER BY eligible_tour_count DESC, f.family_id;
What to say: Switching from INNER JOIN to LEFT JOIN keeps all families in the result regardless of whether they match any tour package. For families with no matches, COUNT(t.tour_id) returns zero rather than NULL because COUNT on a column only counts non-NULL values, and the tour_id is NULL for unmatched left join rows. This is an important distinction because COUNT(*) would return one even for unmatched rows since it counts rows not column values.
Q4. What if the tour package ranges overlap, meaning a family size of 4 falls in both T01 and T02's range. Does that cause any issue?
Overlapping ranges are intentional in this data model and do not cause any problem. The range join correctly produces one row for each matching tour package regardless of whether the ranges overlap. A family with size 4 getting matched to both T01 which covers 2 to 4 and T02 which covers 4 to 8 is the correct business result, since the family genuinely is eligible for both. The issue of overlapping ranges causing duplicate rows only arises when you are expecting exactly one match per left row, which is not the case here since one family can be eligible for multiple tours.
Q5. How would you extend this to also recommend the best tour for each family based on how well their family size fits the tour's midpoint?
WITH eligibility AS ( SELECT f.family_id, f.family_name, f.family_size, t.tour_id, t.tour_name, t.destination, t.min_size, t.max_size, (t.min_size + t.max_size) / 2.0 AS tour_midpoint, ABS(f.family_size - (t.min_size + t.max_size) / 2.0) AS fit_distance FROM families f JOIN tour_packages t ON f.family_size BETWEEN t.min_size AND t.max_size ), ranked AS ( SELECT family_id, family_name, family_size, tour_id, tour_name, destination, fit_distance, RANK() OVER (PARTITION BY family_id ORDER BY fit_distance ASC) AS best_fit_rank FROM eligibility ) SELECT family_id, family_name, family_size, tour_name AS recommended_tour, destination FROM ranked WHERE best_fit_rank = 1 ORDER BY family_id;
What to say: I calculate a fit distance for each family-tour pair as the absolute difference between the family size and the tour's midpoint. A family of 4 looking at a tour sized for 2 to 4 people is at the very top of the range, while the same family looking at a tour sized for 4 to 8 is at the bottom. The tour where the family size sits closest to the midpoint is the best natural fit since the family would be neither the smallest nor the largest group. RANK then picks the best-fitting tour for each family, handling ties where two tours have equally good fit.