Solving Common SQL Interview Problems
Solve common SQL interview problems involving Nth-highest salaries, duplicate records, customer retention, active cohorts, ranking, and window functions using PostgreSQL.
In this lesson, you will learn
- ✓Find the Nth-highest distinct salary using DENSE_RANK and alternative SQL approaches
- ✓Understand the differences between ROW_NUMBER, RANK, and DENSE_RANK
- ✓Identify duplicate records and safely remove duplicates while preserving one canonical row
- ✓Calculate customer retention and cohort activity using joins, conditional aggregation, and date operations
- ✓Use window functions to compare current and previous records
- ✓Explain edge cases involving ties, NULL values, date boundaries, and missing records
Introduction: How to Approach SQL Interview Problems
SQL interview problems often look complicated because they combine several concepts in one question. A reliable approach is to identify the required result first, then select the simplest SQL technique that produces it correctly.
## A four-step approach
1. **Understand the output.** Decide which columns must be returned and whether the result should contain one row, multiple rows, or an aggregate.
2. **Identify the tricky requirement.** Look for words such as "distinct," "Nth highest," "latest," "consecutive," "duplicate," "active," and "retained."
3. **Choose the SQL pattern.** Common tools include GROUP BY, HAVING, joins, subqueries, CTEs, and window functions.
4. **Test edge cases.** Ask what happens when there are ties, NULL values, no matching records, or multiple events on the same date.
This lesson uses PostgreSQL syntax. Some functions and date operations differ between database systems, so adapt the syntax when interviewing for MySQL, SQL Server, or Oracle.
We will solve five major interview patterns:
- Nth-highest distinct salary.
- Detecting and removing duplicates.
- Ranking records and selecting the latest row.
- Customer retention and active cohorts.
- Comparing current and previous activity with window functions.
Problem 1: Find the Nth-Highest Distinct Salary
This is one of the most common SQL interview questions.
**Problem:** Given an employees table, return the third-highest distinct salary.
Sample table:
| employee_id | employee_name | salary |
|---:|---|---:|
| 1 | Asha | 90000 |
| 2 | Ravi | 80000 |
| 3 | Neha | 80000 |
| 4 | Imran | 70000 |
| 5 | Priya | 60000 |
The distinct salary values are:
1. 90000
2. 80000
3. 70000
4. 60000
Therefore, the third-highest distinct salary is 70000.
Notice that Ravi and Neha have the same salary. They occupy the same distinct salary rank. The question asks for the third-highest **distinct salary**, not the third employee after sorting.
## Solution using DENSE_RANK
DENSE_RANK assigns the same rank to equal values and does not leave gaps after ties.
For descending salary:
- 90000 gets rank 1.
- Both 80000 salaries get rank 2.
- 70000 gets rank 3.
- 60000 gets rank 4.
Filter for rank 3 to get the required salary.
Nth-Highest Distinct Salary with DENSE_RANK
The query assumes the sample employees table has been created and populated. DENSE_RANK gives equal salaries the same rank, so 70000 is rank 3. Filtering NULL salaries avoids treating missing salary values as a ranked salary.
Understanding ROW_NUMBER, RANK, and DENSE_RANK
These three window functions are similar, but their treatment of ties differs.
Consider salaries sorted from highest to lowest: 90000, 80000, 80000, 70000.
| Salary | ROW_NUMBER | RANK | DENSE_RANK |
|---:|---:|---:|---:|
| 90000 | 1 | 1 | 1 |
| 80000 | 2 | 2 | 2 |
| 80000 | 3 | 2 | 2 |
| 70000 | 4 | 4 | 3 |
### ROW_NUMBER
Assigns a different sequence number to every row. If rows tie on the ordering column, the order between them is unspecified unless you add a tie-breaker.
Use it when you need exactly one row number per record, such as selecting the latest event for each customer.
### RANK
Assigns the same rank to ties, then leaves gaps. In the example, 70000 has rank 4 because two employees share rank 2.
Use it when competition-style ranking is required.
### DENSE_RANK
Assigns the same rank to ties without leaving gaps. In the example, 70000 has rank 3.
Use it for the Nth-highest **distinct** value.
## Interview rule
If the interviewer says "Nth-highest distinct salary," DENSE_RANK is usually the clearest choice.
If the interviewer says "Nth row after sorting," ROW_NUMBER may be appropriate.
If the interviewer says "rank employees and preserve competition-style positions," RANK may be appropriate.
Compare the Three Ranking Functions
ROW_NUMBER uses employee_id as a tie-breaker, so the row numbers are deterministic for this sample. RANK and DENSE_RANK order only by salary, so equal salaries share a rank.
Alternative: Nth-Highest Salary with DISTINCT and LIMIT
For PostgreSQL, you can also select the Nth-highest distinct salary by sorting the distinct values and using OFFSET and LIMIT.
The OFFSET value is N - 1 because SQL offsets start at zero:
- First-highest: OFFSET 0.
- Second-highest: OFFSET 1.
- Third-highest: OFFSET 2.
This is concise when the required rank is fixed. DENSE_RANK is often easier to extend when the question also asks for employee details or all employees sharing the Nth-highest salary.
If there are fewer than N distinct non-NULL salaries, the query returns no row.
Third-Highest Distinct Salary with LIMIT
DISTINCT removes repeated salary values before sorting. OFFSET 2 skips the first two distinct salaries, and LIMIT 1 returns the next value. The syntax is supported by PostgreSQL and several other databases, though pagination syntax varies by system.
Returning Only the Nth-Highest Salary Value
Some coding platforms expect a single output column rather than employee details.
You can use a scalar subquery to return the third-highest distinct salary. If there are fewer than three distinct non-NULL salaries, the scalar subquery evaluates to NULL.
This is useful when the problem statement requires a single value and specifies what to return when the Nth value does not exist.
Scalar Result for the Third-Highest Salary
The outer SELECT returns one row with one column. If the inner query returns no rows, the scalar subquery returns NULL. Check the interview problem's required behavior because some platforms expect no row or a specific sentinel instead.
Problem 2: Identify Duplicate Records
A duplicate is defined by the business rule, not necessarily by every column being identical.
For example, if email must be unique, two customers with the same email are duplicates for that rule, even if their names or creation timestamps differ.
Sample customers:
| customer_id | full_name | email |
|---:|---|---|
| 1 | Asha | asha@example.com |
| 2 | Ravi | ravi@example.com |
| 3 | Asha Sharma | asha@example.com |
| 4 | Neha | neha@example.com |
Email asha@example.com appears twice.
GROUP BY and HAVING are the standard way to identify duplicated values. HAVING filters groups after aggregation, unlike WHERE, which filters individual rows before grouping.
Find Duplicate Emails
The query groups customers by email and keeps only groups containing more than one row. It ignores NULL emails here because this example is specifically checking repeated, non-NULL email values.
Problem 3: Find and Remove Duplicate Rows Safely
Deleting duplicates is more dangerous than finding them. First decide which record should survive.
For this example, the rule is:
- Records with the same email are considered duplicates.
- Keep the row with the smallest customer_id.
- Delete the remaining rows.
Before deleting anything:
1. Run a SELECT that shows the rows to be deleted.
2. Confirm the survivor rule with the business owner.
3. Back up the data or use an appropriate recovery plan.
4. Perform the operation in a transaction where practical.
5. Check the affected row count before committing.
PostgreSQL provides the system column ctid, which identifies a physical row version within a table. It can be useful for a controlled cleanup when the table has no suitable unique key, but it is not a permanent identifier and should not be stored as one.
Here, customer_id is a stable primary key, so we can safely identify duplicate rows by that key.
Preview Duplicate Rows Before Deleting
ROW_NUMBER assigns a sequence within each email group. The smallest customer_id receives number 1 and is kept. Rows with duplicate_number greater than 1 are candidates for deletion under this particular survivor rule.
Deleting Duplicates in a Transaction
Once the preview is verified, PostgreSQL can delete rows identified by a CTE.
The following example is designed for the sample customer data. It keeps the smallest customer_id for each non-NULL email and deletes additional rows.
Do not run this blindly against production data. Before deletion, check whether other tables reference the duplicate customer IDs. Those foreign-key relationships may need to be migrated to the surviving customer before the duplicates can be removed.
Delete Extra Rows While Keeping One Record
The example deliberately ends with ROLLBACK so the demonstration does not permanently delete the sample duplicate. In a real cleanup, review the returned rows, reconcile dependent foreign keys, and commit only after verification. The survivor rule is based on customer_id, not which name looks more complete.
Preventing Duplicates with Constraints
A cleanup query fixes existing duplicate data, but a constraint prevents future violations of the same rule.
If email must be unique, a UNIQUE constraint is usually the correct long-term solution. Before adding it, clean up existing duplicates and define how NULL and case differences should be handled.
In PostgreSQL, a normal unique constraint permits multiple NULL values because NULL values are not treated as equal for ordinary uniqueness checks. If every customer must have an email, combine NOT NULL with UNIQUE.
Email addresses can also differ only by case or surrounding whitespace. Whether those should be considered duplicates is a business decision. Normalize input in the application or use an appropriate PostgreSQL design, such as a unique index on lower(email), when case-insensitive uniqueness is required.
Enforce Unique Email Values
Do not add both uniqueness mechanisms without considering your intended rule. A normal UNIQUE constraint and a unique index on LOWER(email) enforce different rules: the latter prevents values that differ only by case. The case-insensitive index will fail to build if existing emails collide after lowercasing.
Problem 4: Find the Latest Record for Each Customer
A common interview task is to find each customer's latest order.
MAX(order_date) returns the latest timestamp, but if you need other columns from the same order, you must identify the entire row. A window function is a convenient solution.
Use ROW_NUMBER partitioned by customer and ordered by order date descending. Add order_id as a tie-breaker so the result is deterministic when two orders share a timestamp.
The result contains at most one order per customer. Customers with no orders do not appear in this query because the ranking starts from the orders table.
Latest Order for Each Customer
ROW_NUMBER resets for each customer. The newest order gets row_num = 1. This result includes cancelled orders because it asks for the latest order of any status. Filter status before ranking only if the requirement is specifically the latest qualifying order.
Problem 5: Customer Cohorts and Retention
A cohort is a group of users who share a starting characteristic, often the month of their first purchase or registration.
Retention measures how many members of that group return or remain active in a later period.
A useful definition must specify:
- Cohort assignment: for example, the month of the customer's first order.
- Activity: for example, placing any order.
- Retention period: for example, the month after the cohort month.
- Counting rule: distinct customers, not the number of orders.
For this lesson, we define:
- Cohort month = month of a customer's first order.
- Activity month = month in which the customer placed an order.
- Month 0 = the cohort month.
- Month 1 = the next calendar month.
A customer counts at most once per cohort/activity month, even if they place several orders during that month.
This is a simplified order-activity metric. A paid-purchase retention report would need to filter orders using an explicit successful-payment definition.
Build a Monthly Customer Cohort Table
First, calculate each customer's first order timestamp. Then assign the customer to a cohort month.
Next, collect distinct customer activity by calendar month and calculate the number of months between cohort month and activity month.
PostgreSQL date operations can calculate the month offset using year and month components. The expression below works for month-level cohort analysis and does not depend on the number of days between two dates.
The query returns one row per cohort month and activity-month offset, with both the number of active customers and the original cohort size.
Monthly Cohort Retention
In the sample data, customers 1 and 2 first ordered in September, while customer 3 first ordered in October. Customer 4 has no order and therefore has no purchase cohort. The sample has no later-month activity for the September cohort, so no September month-1 row is produced. A full retention matrix often generates missing month offsets explicitly so those periods can be displayed as zero or not yet observable.
Understanding Cohort Maturity and Missing Periods
A cohort report should not automatically treat every missing future month as zero retention.
For example, if a cohort began in October and the report runs in October, November retention has not yet been observed. It is not correct to interpret this as zero retention.
A mature cohort has had enough time to reach the retention period being measured. Analysts often:
- Show only periods that have elapsed.
- Mark future periods as NULL or not yet observable.
- Use a generated calendar of month offsets to fill missing periods for mature cohorts.
Also note that cohort size can differ depending on whether the cohort is defined by registration, first order, or first successful purchase. Always document the definition alongside the report.
Problem 6: Compare Current and Previous Orders
LAG returns a value from a previous row in the ordered partition. It is useful when an interview asks you to compare a record with the previous event.
Here, we compare each customer's order timestamp with their previous order timestamp.
The first order for each customer has no previous order, so previous_order_date is NULL. The number of days between timestamps can be calculated by subtracting the previous timestamp from the current timestamp.
Compare Each Order with the Previous Order
LAG is calculated independently for each customer. The first order in each partition has no previous row. PostgreSQL timestamp subtraction returns an interval, not an integer count of days.
Problem 7: Find Customers with Consecutive Active Months
Another common interview question asks which customers were active in consecutive months.
First, reduce the data to one row per customer per month. This prevents multiple orders in one month from being counted as separate active months.
Then use LAG to compare each active month with the customer's previous active month. A difference of one month indicates consecutive calendar months.
This query finds customers who were active in two consecutive months. To find three or more consecutive months, extend the logic to count runs or use a gaps-and-islands pattern.
Find Consecutive Active Months
The sample customers are active in September or October, but none has orders in both months. DISTINCT ensures that multiple orders during one month do not create false consecutive-month activity.
Practice Problems
Try these interview-style problems before reading the solutions.
## Exercise 1: Second-highest salary
Return the second-highest distinct non-NULL salary. If no such salary exists, return NULL.
## Exercise 2: All employees sharing the highest salary
Return every employee whose salary equals the maximum non-NULL salary.
## Exercise 3: Duplicate phone numbers
Find phone numbers that appear more than once, ignoring NULL phone numbers.
## Exercise 4: Keep the newest customer record
A staging table contains repeated external_customer_id values. Select the newest record for each external customer, breaking timestamp ties with the largest staging_id.
## Exercise 5: Customers with no orders
Return all customers who have never placed an order.
## Exercise 6: Customer order count
Return every customer and their order count, including customers with zero orders.
## Exercise 7: Month-over-month order activity
Count distinct active customers per month and show the difference from the previous month.
## Exercise 8: Cohort retention
Explain why a month-1 retention rate cannot be considered zero when the cohort's first month is the latest month in the report.
## Exercise 9: Safe duplicate deletion
Before deleting duplicates, identify the survivor rule and determine whether any foreign keys reference the rows that would be removed.
## Exercise 10: Explain the ranking functions
Using a dataset with tied salaries, show how ROW_NUMBER, RANK, and DENSE_RANK produce different results.
Practice Problem Solutions
These are reference solutions. The sample employee, customer, and staging tables are illustrative; adapt column names to your actual schema.
Important details:
- Exercise 1 returns a single row with NULL if there is no second distinct salary.
- Exercise 2 returns every employee tied at the maximum salary.
- Exercise 4 uses ROW_NUMBER with a deterministic tie-breaker.
- Exercise 6 uses LEFT JOIN so customers with no orders remain in the output.
- Exercise 7 uses a monthly aggregate before applying LAG.
Reference SQL Solutions
The exact outputs depend on the tables and sample rows used. Exercise 5 returns Meera Gupta because she has no orders in the sample dataset. Exercise 7 counts distinct customers with orders in each month: two in September and three in October. LAG returns NULL for the first month because there is no preceding month in the query result.
SQL Interview Questions and Answers
## 1. What is the difference between WHERE and HAVING?
WHERE filters individual rows before grouping. HAVING filters groups after GROUP BY has calculated aggregates.
## 2. When should you use DENSE_RANK instead of ROW_NUMBER?
Use DENSE_RANK when tied values should share a rank and distinct values should have consecutive ranks. Use ROW_NUMBER when every row needs a unique sequence number.
## 3. What is the difference between RANK and DENSE_RANK?
RANK leaves gaps after ties. DENSE_RANK does not. For values 100, 90, 90, 80, their ranks are 1, 2, 2, 4 with RANK and 1, 2, 2, 3 with DENSE_RANK.
## 4. How do you find duplicate records?
Group by the columns that define a duplicate and use HAVING COUNT(*) > 1. The definition of duplicate must come from the business rule.
## 5. How do you remove duplicates safely?
Rank rows inside each duplicate group using ROW_NUMBER and a deterministic ordering. Preview the rows to delete, check dependencies, and use a transaction or recovery plan where appropriate.
## 6. Why is a unique constraint better than repeated cleanup?
A unique constraint prevents future duplicates that violate the chosen uniqueness rule. Cleanup alone only fixes the data that already exists.
## 7. How do you find the latest row per customer?
Use ROW_NUMBER partitioned by customer_id and ordered by the timestamp descending, with a deterministic tie-breaker. Keep row number 1.
## 8. What is a cohort?
A cohort is a group sharing a defined starting characteristic, such as the month of their first purchase.
## 9. What does customer retention measure?
It measures how many members of a cohort return or remain active in a later period, according to a defined activity rule.
## 10. Why use COUNT(DISTINCT customer_id) in a retention query?
A customer may perform several actions during one period. Counting distinct customer IDs prevents that customer from being counted more than once in the same cohort-period cell.
## 11. What does LAG do?
LAG retrieves a value from a previous row in the same window partition according to the specified ordering.
## 12. What is a gaps-and-islands problem?
It is a class of problems involving consecutive or continuous groups of values, such as consecutive dates or months. Window functions and date arithmetic are common ways to solve them.
## 13. Why must window ordering be deterministic for ROW_NUMBER?
If multiple rows tie on all ordering columns, their relative order is unspecified. Add a unique tie-breaker when selecting a specific row.
## 14. What happens if the Nth-highest salary does not exist?
The behavior depends on the query. A scalar subquery using OFFSET and LIMIT returns NULL when no value exists; a ranked-row query returns no row.
## 15. Why should NULL salaries often be excluded from ranking?
A missing salary is not a meaningful numeric salary. Excluding NULL makes the ranking rule explicit and avoids engine-specific surprises in NULL sort order.
## 16. Why can date-based retention queries be misleading?
Retention depends on the cohort definition, activity event, time zone, period boundaries, and whether each cohort has had enough time to mature.
## 17. What is the difference between NOT EXISTS and NOT IN?
NOT EXISTS tests whether a matching row exists. NOT IN can produce unexpected results if its subquery contains NULL, so NOT EXISTS is often easier to reason about for anti-join problems.
## 18. How would you make duplicate cleanup safe in a large production table?
Define the survivor rule, identify dependencies, back up or stage affected data, test the query, consider batching to reduce lock duration, enforce the final uniqueness rule, and monitor the operation.
Common Mistakes and Best Practices
## Mistake 1: Using ROW_NUMBER for the Nth-highest distinct salary
ROW_NUMBER counts rows, not distinct salary values. Tied salaries receive separate row numbers.
**Better:** use DENSE_RANK for the Nth-highest distinct value.
## Mistake 2: Forgetting NULL salaries
A missing salary should not normally count as a salary rank.
**Better:** add WHERE salary IS NOT NULL when that matches the problem definition.
## Mistake 3: Deleting duplicates without previewing them
The wrong survivor rule can remove a more complete or more recent record.
**Better:** preview the rows, choose a deterministic survivor, inspect foreign-key dependencies, and verify the affected row count.
## Mistake 4: Assuming all identical-looking rows are duplicates
Two transactions may have the same amount and timestamp but still represent distinct valid events.
**Better:** define duplicates using business keys or source event identifiers.
## Mistake 5: Using MAX(timestamp) when other columns from that row are required
MAX returns a value, not the complete row associated with that value.
**Better:** use ROW_NUMBER or a carefully designed join to identify the row.
## Mistake 6: Counting orders instead of customers in retention
Several orders from one customer can inflate the activity count.
**Better:** use COUNT(DISTINCT customer_id) after defining the cohort-period activity.
## Mistake 7: Ignoring ties in window ordering
If two events have the same timestamp, selecting a row with ROW_NUMBER can be nondeterministic unless the ordering includes a tie-breaker.
**Better:** order by timestamp and a unique ID.
## Mistake 8: Treating unobserved future periods as zero retention
A recent cohort may not have reached the next period.
**Better:** distinguish zero observed activity from a period that has not yet elapsed.
## Mistake 9: Assuming a query works identically in every SQL database
Window functions are widely supported, but pagination syntax, date functions, deletion syntax, and NULL ordering vary.
**Better:** confirm the target database and state the dialect during an interview.
A Reliable Pattern for Selecting the Latest Row
The event timestamp is sorted newest first, NULL timestamps are placed last, and event_id provides a tie-breaker. If the table contains a customer with only NULL timestamps, that customer's row with the greatest event_id will still be selected. Change the filter if rows with missing timestamps should be excluded entirely.
Lesson Summary
You should now be able to recognize and solve several common SQL interview patterns.
Remember these mappings:
- Nth-highest distinct value → DENSE_RANK or DISTINCT with ORDER BY, OFFSET, and LIMIT.
- Unique row number per partition → ROW_NUMBER.
- Competition ranking with gaps → RANK.
- Find duplicate values → GROUP BY and HAVING COUNT(*) > 1.
- Keep one row per duplicate group → ROW_NUMBER with a deterministic survivor rule.
- Latest row per group → ROW_NUMBER partitioned by the group key and ordered by timestamp.
- Customer cohort retention → first-activity cohort, distinct period activity, and cohort-size denominator.
- Compare with the previous row → LAG.
- Customers with no related records → NOT EXISTS or a correctly written anti-join.
The strongest interview answers explain not only how the query works, but also why it handles ties, NULL values, missing records, and duplicate events correctly.
Common pitfalls to avoid
Real debugging lessons and exact syntax contrasts to avoid runtime bugs
#1 Using ROW_NUMBER to find the Nth-highest distinct salary.
Why it happens: ROW_NUMBER assigns a separate number to each row, so employees sharing a salary occupy different positions.
Key takeaway: DENSE_RANK assigns the same rank to tied salaries and gives the next distinct salary the next consecutive rank.
#2 Deleting duplicate records without a deterministic survivor rule.
Why it happens: A cleanup query may delete records arbitrarily or preserve a less useful record when several rows share the same business key.
Key takeaway: First preview duplicate candidates and define which row to keep. The corrected example selects candidates only; deletion should follow after dependency checks and verification.
#3 Counting orders instead of distinct customers for retention.
Why it happens: One customer may place multiple orders during the same period, causing order counts to overstate the number of retained customers.
Key takeaway: COUNT(DISTINCT customer_id) counts each customer once per month. A complete cohort retention report must also group activity by cohort and calculate the appropriate denominator.
#4 Selecting a latest row without resolving timestamp ties.
Why it happens: Several rows can have the same timestamp, so ordering only by timestamp does not fully determine which row receives row number 1.
Key takeaway: Add a stable tie-breaker such as a unique event ID. This makes the row-selection rule deterministic for equal timestamps.
#5 Treating a cohort's unobserved future month as zero retention.
Why it happens: Analysts may create a retention table without distinguishing periods that have elapsed from periods that have not yet occurred.
Key takeaway: Retention is meaningful only when the cohort has had an opportunity to reach the period being measured. Missing activity and unavailable future data are different states.
Ready for hands-on challenges?
Test your skills with shuffled coding challenges in the Practice Arena.
Key takeaways
- DENSE_RANK assigns consecutive ranks to distinct values, making it useful for Nth-highest distinct salary problems.
- ROW_NUMBER assigns a unique sequence to rows, while RANK and DENSE_RANK handle ties differently.
- Identify duplicates with GROUP BY and HAVING before deciding which records should be deleted.
- Use ROW_NUMBER with a deterministic ordering to select one record from each duplicate group.
- Retention queries require a clear definition of a cohort, an activity event, and the retention period.
- Date boundaries, NULL values, ties, and duplicate events can change query results and must be handled deliberately.