SQL Practice Problems with Solutions
These 20 SQL practice problems come with worked solutions that run on real rows, and each one names the trap that fails most first attempts. They start with GROUP BY and joins, then move through window functions, gaps and islands, and sessions before ending on recursive CTEs. Every one of them opens in an editor on this page so you can change the query and watch the result change. When the worked problems feel easy, 928 more SQL problems on DataDriven run your query against hidden data.
How to use SQL practice problems with solutions
A worked solution teaches you a shape: the window function that numbers rows, the anti join that finds what's missing, the running sum that turns flags into session numbers. Reading the shape is the easy half. The half an interview measures is producing it cold, on tables you have never seen, with the edge cases handled. So read each problem's statement, write your own query before you open the solution, then open the solution's editor and run yours against the same rows. Only then compare.
Every problem on this page runs on a small table or 2 whose rows are drawn beside the solution, so you can trace the result by hand: the duplicate timestamp that breaks a tiebreaker, the NULL customer id that empties a NOT IN, the day a store was closed. A query that returns the right rows on clean data and the wrong rows on real data is the most common way to lose a SQL round, and each section ends with the case that catches it.
The problems are grouped by pattern and ordered from the ones every round asks to the ones that separate senior candidates. Aggregation and joins come first, then deduplication and ranking, then window frames and period over period change. Sessions follow gaps and islands, and pivots and hierarchies close the set. Work them in order the first time, then come back to the pattern you missed. The SQL practice problems on DataDriven continue from here with {COUNT:sql} problems whose answers are checked against hidden rows, and the SQL interview questions ask you to explain the same patterns out loud.
Know SQL practice problems with solutions the way the interviewer who asks it knows it.
Which SQL patterns to practice first
Roughly 10 patterns account for nearly every SQL problem a data engineering interview asks, and they aren't equally common. Aggregation with GROUP BY and HAVING and joins that keep the row count right appear in almost every round, usually as the warm-up. Window functions are the tier that decides the round. That means numbering rows to keep the latest record and ranking within a group, and it also means running totals and LAG for period over period change. When the first 20 minutes went well, an interviewer reaches for gaps and islands or sessionization, and sometimes a pivot.
Spend your time where the patterns stack. Deduplication, top N per group and sessionization are each 1 window function plus 1 filter, so once ROW_NUMBER with a tiebreaker is a reflex, 3 problem families fall at once. Gaps and islands are a running sum over a flag, the same move sessionization makes. Recursive CTEs stand alone and come up less often, but a candidate who can write one in 5 minutes has a visible edge in senior rounds.
A useful order: problems 1 to 4 until they take under 5 minutes each, then 5 to 12 until you can name the window function a statement needs before you start typing, then 13 to 20 at interview pace. Across the DataDriven catalogue, the concept tags on the SQL problems rank the same way. Joins and aggregation are tagged on most problems, and window functions come close behind.
What the SQL problems in the DataDriven catalogue test most
Share of the 928 SQL problems in the DataDriven catalogue tagged with each concept; a problem tagged with several concepts counts under each
Problems 1 and 2: aggregation with GROUP BY and HAVING
Problem 1: from an orders table with a status and an amount per order, return every customer with at least 2 completed orders, with how many they completed and how much they spent, biggest spender first. Cancelled orders carry a NULL amount.
| order_id | customer_id | order_date | status | amount |
|---|---|---|---|---|
| 1 | 101 | 2026-03-02 | completed | 40 |
| 2 | 101 | 2026-03-15 | completed | 25.5 |
| 3 | 102 | 2026-03-03 | completed | 310 |
| 4 | 103 | 2026-03-05 | cancelled | NULL |
| 5 | 103 | 2026-03-20 | completed | 18 |
| 6 | 103 | 2026-03-28 | completed | 22 |
| 7 | 104 | 2026-03-09 | completed | 99 |
| 8 | 104 | 2026-03-30 | cancelled | NULL |
The filter on status belongs in WHERE, because it removes rows before they are grouped; HAVING filters the groups after COUNT has run. The solution:
Customer 104 has 2 orders but 1 of them was cancelled, so it drops out at the WHERE and never reaches HAVING. Move the status test into HAVING with a conditional count and the query still works, but it has to read every row first. The trap in this family is COUNT(amount) against COUNT(*): COUNT of a column skips NULLs, so without the WHERE clause customer 103 counts 3 orders with COUNT(*) and 2 with COUNT(amount), and the 2 answers look equally plausible until someone checks a cancelled order.
Open the editor under the solution, delete the WHERE line and run again: customer 104 appears with 2 orders, 1 of them cancelled. Then swap COUNT(*) for COUNT(amount) in both places and customer 103's count falls from 3 to 2 while customer 104 drops back out.
Problem 2: return the customers whose average completed order is above the average completed order across all customers. The comparison value is a scalar subquery, and it has to apply the same status filter as the outer query or the 2 averages are taken over different rows:
The overall average is 85.75, so customer 102 at 310 and customer 104 at 99 qualify. AVG ignores NULLs, which is what you want here: a cancelled order with no amount should not pull an average down. Had the cancelled rows carried 0 instead of NULL, the WHERE clause would be doing all the work, and dropping it would change every number. 1 more thing to say out loud in an interview: on an integer column, some engines return an integer average, so cast to a decimal when the column isn't already one.
Problems 3 and 4: joins that keep the row count right
Problem 3 joins 3 tables, customers to orders to shipments, and an order can ship in several boxes. For every customer, including customers with no orders, return their total order amount and how many shipments they received. 1 order went to a guest, so its customer_id is NULL.
| customer_id | name | region |
|---|---|---|
| 1 | Ada | west |
| 2 | Bo | east |
| 3 | Cy | east |
| 4 | Di | west |
| order_id | customer_id | order_date | amount |
|---|---|---|---|
| 1001 | 1 | 2026-04-01 | 120 |
| 1002 | 1 | 2026-04-09 | 80 |
| 1003 | 2 | 2026-04-03 | 60 |
| 1004 | NULL | 2026-04-05 | 45 |
| shipment_id | order_id | shipped_date | carrier |
|---|---|---|---|
| 1 | 1001 | 2026-04-02 | UPS |
| 2 | 1001 | 2026-04-04 | UPS |
| 3 | 1002 | 2026-04-10 | FedEx |
| 4 | 1003 | 2026-04-04 | USPS |
The instinct is to join all 3 tables and SUM the amount. Order 1001 shipped in 2 boxes, so the join lists it twice and its 120 is counted twice: the total for Ada comes back as 320 instead of 200. This is join fan-out, and it is the most expensive SQL mistake in production because it inflates every metric downstream and nothing errors.


The fix is to aggregate the many side to 1 row per order before joining it, so the join is 1 to 1 again. Customers with no orders are kept by the LEFT JOIN, and COALESCE turns their NULL sums into 0:
In an interview, say the row counts as you go: 4 customers, 4 orders, 4 shipments, so the result has 4 rows, and orders joined to shipments stay at 4 rows only because the shipments were collapsed first. An interviewer who hears you count rows stops worrying about your joins.
Problem 4: return the customers who have never placed an order. 3 forms answer it: NOT EXISTS, a LEFT JOIN with an IS NULL test, and NOT IN. Only the first 2 are safe here:
The NOT IN form returns no rows at all on this data, because the guest order carries a NULL customer_id. A NOT IN against a list that contains a NULL is never true for any row: SQL cannot prove a value is absent from a list when 1 member of the list is unknown. The PostgreSQL manual's page on subquery expressions spells this out, and every engine follows the same 3 valued logic. Write NOT EXISTS by default; the planner turns it into the same anti join as the LEFT JOIN form, and it stays correct when the inner column is nullable.
Problems 5 and 6: deduplication and the latest row per key
Problem 5: a device_readings table receives temperature readings, sometimes twice. Return each device's latest reading. 2 readings for device 7 share the same timestamp, and device 8 received the same reading twice.
| reading_id | device_id | reading_ts | temperature |
|---|---|---|---|
| 1 | 7 | 2026-05-01 08:00:00 | 21.5 |
| 2 | 7 | 2026-05-01 09:00:00 | 22.1 |
| 3 | 7 | 2026-05-01 09:00:00 | 22.4 |
| 4 | 8 | 2026-05-01 08:30:00 | 19 |
| 5 | 8 | 2026-05-01 08:30:00 | 19 |
| 6 | 9 | 2026-05-01 07:45:00 | 25.2 |
Number each device's readings newest first and keep row 1. The tiebreaker on reading_id is what makes the answer stable: without it, the engine may pick either of device 7's 2 readings at 09:00, and a query that returns a different row on each run is a bug waiting for a reconciliation report.
Device 8's 2 identical rows also collapse to 1, because ROW_NUMBER numbers them 1 and 2 even though nothing distinguishes them. That's the difference from RANK, which gives both rows 1 and returns device 8 twice. The GROUP BY device_id with MAX(reading_ts) version looks shorter, but the temperature then comes from no particular row, and a join back to the table on the max timestamp returns both of device 7's rows again. Whenever a statement asks for the latest or first row per key, reach for ROW_NUMBER with a complete ORDER BY.
Problem 6: a contacts table has the same email entered more than once with different letter case. Return each duplicated email, how many copies it has and the id of the row to keep, taking the oldest.
| contact_id | created_at | |
|---|---|---|
| 1 | ada@example.com | 2026-01-03 |
| 2 | bo@example.com | 2026-01-04 |
| 3 | ADA@example.com | 2026-02-11 |
| 4 | cy@example.com | 2026-02-12 |
| 5 | bo@example.com | 2026-03-01 |
Normalise before you group: LOWER(email) treats ada@example.com and ADA@example.com as 1 email, which is what the business means by a duplicate. MIN(contact_id) stands in for the oldest row only when ids follow insertion order; when they might not, number the rows by created_at instead. The delete that follows keeps 1 row per email by excluding every id in that keep list, and NOT IN is safe there because MIN never returns NULL for a group that has rows.
The same move on a live problem: every service deploys versions over time, and the statement asks for the latest version of each. Write it here:
Latest Version Per Service
> The release dashboard has been skipping every deploy that went to staging or failed, and the on-call engineer found services stuck on builds they have since moved past. Rebuild it from `deploy_logs`: each service with the version from its most recent deploy and how many seconds that deploy ran.
Problems 7 and 8: ranking and top N per group
Problem 7: from a table of revenue per product, return the top 2 products in each category by revenue, keeping ties, so a category with 2 products tied for second returns 3 rows.
| category | product | revenue |
|---|---|---|
| audio | headphones | 900 |
| audio | speaker | 700 |
| audio | earbuds | 700 |
| audio | microphone | 300 |
| video | camera | 1200 |
| video | tripod | 150 |
| video | light | 150 |
The 3 ranking functions differ only on ties, and the statement tells you which one it wants. Keeping ties and filling the next position without a gap is DENSE_RANK:
The audio category has speaker and earbuds tied at 700. ROW_NUMBER would number them 2 and 3 and drop one of them arbitrarily, which breaks the statement. RANK gives both 2 and the microphone 4, which happens to give the same top 2 here, but ask for the top 3 and RANK returns nothing at position 3 while DENSE_RANK returns the microphone. Say the tie rule back to the interviewer before you pick a function; it's the whole question.
The 3 functions on the same rows show the whole difference in 1 result. The functions agree until the tie at 700 in audio: ROW_NUMBER splits it 2 and 3 in an order the engine chooses, RANK gives both rows 2 and jumps to 4, and DENSE_RANK gives both rows 2 and continues at 3. In the editor, wrap the query in a CTE and filter each column with <= 3 in turn to see which function returns the microphone at position 3:
Problem 8: return the second highest distinct salary in each department, and nothing for a department that has only 1 distinct salary. DENSE_RANK on the values again, with a DISTINCT in the final select because 2 employees can share the second highest salary:
| employee_id | department | salary |
|---|---|---|
| 1 | data | 150000 |
| 2 | data | 150000 |
| 3 | data | 120000 |
| 4 | data | 95000 |
| 5 | ops | 80000 |
| 6 | ops | 80000 |
| 7 | sales | 70000 |
| 8 | sales | 90000 |
| 9 | sales | 65000 |
The data department has 2 people on 150000, so ROW_NUMBER() = 2 would return 150000, which is the highest salary and not the second. Ops has 2 people on the same salary and returns no row, which is correct for this statement; if the interviewer wants a NULL for such a department, start from a list of departments and LEFT JOIN the ranked rows to it. The single table version of this problem, the second highest salary overall, is a LIMIT 1 OFFSET 1 over the distinct values, and it fails the same way when the top salary is shared.
A live problem with the same shape, ranked within time periods instead of categories: keep the top 3 items in each month.
Top Lessons Each Month
> Walking the calendar from the earliest month forward, surface the three most-completed lessons in each month, most completions first, and let the page address break the tie alphabetically whenever two lessons finished level. The content team uses this to guide investment decisions for the next quarter.
Problems 9 and 10: running totals and moving averages
Problem 9: a daily_sales table has 1 row per store per day, except that on 1 day the north store's 2 registers reported separately, and on another day it was closed. Return each store's running total by day.
| sale_date | store | amount |
|---|---|---|
| 2026-06-01 | north | 100 |
| 2026-06-02 | north | 50 |
| 2026-06-02 | north | 50 |
| 2026-06-03 | north | 80 |
| 2026-06-05 | north | 120 |
| 2026-06-01 | south | 70 |
| 2026-06-02 | south | 90 |
| 2026-06-03 | south | 60 |
| 2026-06-04 | south | 110 |
| 2026-06-05 | south | 40 |
A running total is SUM over a window ordered by date. The detail that fails first attempts is the default frame. When a window has an ORDER BY and no frame clause, the frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and in a RANGE frame every row with the same ORDER BY value is a peer of the current row, so both of north's rows on the 2nd get the same running total of 200 instead of 150 and then 200. The SQLite window function reference states that default. PostgreSQL shares it, as do Snowflake and BigQuery. Aggregate to 1 row per day first and declare a ROWS frame:
Collapsing to 1 row per day also answers the question the business asked, since a running total by day has 1 value per day. Keep the explicit ROWS frame anyway: it costs nothing, it documents the intent, and on engines where RANGE over a date needs an interval it avoids an error.
The default frame and an explicit ROWS frame on the raw rows, in 1 query: on north's 2 rows for the 2nd, default_frame reads 200 on both while rows_frame reads 150 and then 200. In the editor, add a third SUM with ROWS BETWEEN 2 PRECEDING AND CURRENT ROW to watch a 3 row window skip the closed day on the 4th:
Problem 10: return each store's 3 day moving average, counting a day the store was closed as 0 sales rather than skipping it. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW averages the 3 most recent rows, and a missing day isn't a row, so the window silently stretches to the 3 most recent days that had sales. The fix is a date spine: every store crossed with every calendar day, then the sales joined on:
| day |
|---|
| 2026-06-01 |
| 2026-06-02 |
| 2026-06-03 |
| 2026-06-04 |
| 2026-06-05 |
North was closed on the 4th. With the spine, the 5th averages 80 and 120 with a 0 for the closed day, giving 66.7; without it, the window reaches back to the 2nd and puts that day's 100 where the 0 should be, giving 100, which hides the closed day entirely. Warehouses keep a date dimension for exactly this; when you have none, generate the days with a recursive CTE or the engine's series function. The SQL window functions interview questions go further into frames, and the SQL window functions practice page drills them.
Problems 11 and 12: period over period with LAG
Problem 11: a monthly_revenue table has 1 row per region per month. Return each month's change from the previous month in absolute terms and as a percentage. 1 region has no row for March, and another recorded 0 in February.
| month | region | revenue |
|---|---|---|
| 2026-01-01 | emea | 100 |
| 2026-02-01 | emea | 120 |
| 2026-03-01 | emea | 90 |
| 2026-04-01 | emea | 135 |
| 2026-01-01 | apac | 80 |
| 2026-02-01 | apac | 0 |
| 2026-04-01 | apac | 110 |
LAG reads the previous row in the window's order, which is the previous month only when no month is missing. Carry the previous month's date along with its revenue and test whether it really is 1 month back, and guard the division with NULLIF so a 0 month returns NULL instead of an error:
For apac, April's LAG row is February, so the change of 110 is over 2 months and the row says so. Interviewers ask what the percentage should be when the previous month was 0, and there's no right number: a NULL with a note beats a fake 100%. The first row of every region has no previous month and keeps a NULL change; do not COALESCE it to 0, which would read as flat growth.
Problem 12: from an orders table, return the number of days between each order and the customer's previous order, with NULL on the first. A tiebreaker on order_id keeps 2 orders on the same day in a fixed order so the second shows 0 days:
| order_id | customer_id | order_date |
|---|---|---|
| 1 | 1 | 2026-02-01 |
| 2 | 1 | 2026-02-15 |
| 3 | 1 | 2026-03-20 |
| 4 | 2 | 2026-02-10 |
| 5 | 3 | 2026-03-01 |
| 6 | 3 | 2026-03-01 |
The date difference function is the 1 line of this query that changes by engine. Snowflake and SQL Server use DATEDIFF with the unit first. BigQuery and Trino both spell it DATE_DIFF, with different argument orders, and PostgreSQL just subtracts 2 dates. Know your target's form before the interview and say it when you write it.
A live version with a window on the gap: the retention team wants the customers who bought again within a set number of days.
Repeat Purchase Window
> An analyst building a churn model needs the customers whose buying rhythm has slipped: the ones who once let an unusually long stretch open up between one purchase and the very next, yet came back rather than going silent for good. From the transactions table, find the users who at some point had 65 to 80 days between consecutive purchases, and return each qualifying user ID once, smallest first.
Never-Ordered Products
> Which products in the catalog have never been ordered? Show product ID and product name for items with no matching transaction.
Problems 13 and 14: gaps and islands
Problem 13: a job_runs table records 1 row per run with a status. For each job, return its longest streak of consecutive days with a successful run. 1 day has 2 successful runs, and 1 day has none.
| run_id | job_name | run_date | status |
|---|---|---|---|
| 1 | nightly_load | 2026-07-01 | success |
| 2 | nightly_load | 2026-07-02 | success |
| 3 | nightly_load | 2026-07-03 | failed |
| 4 | nightly_load | 2026-07-04 | success |
| 5 | nightly_load | 2026-07-05 | success |
| 6 | nightly_load | 2026-07-06 | success |
| 7 | nightly_load | 2026-07-06 | success |
| 8 | nightly_load | 2026-07-08 | success |
| 9 | hourly_sync | 2026-07-01 | success |
| 10 | hourly_sync | 2026-07-02 | failed |
| 11 | hourly_sync | 2026-07-03 | success |
Gaps and islands is the pattern behind every streak question. Number the successful days per job in date order and subtract the row number from the date: inside an unbroken run both rise by 1 a day, so the difference stays constant, and after a gap the date jumps while the number does not. That difference is the island's anchor, and grouping by it gives 1 row per streak:
The DISTINCT in the first step is the trap. The nightly load ran twice on the 6th, and without DISTINCT those 2 rows get 2 row numbers for 1 date. The difference shifts, so the 3 day streak splits in 2. Ties are kept on purpose: the hourly sync has 2 streaks of 1 day and both come back. When runs happen on weekdays only, consecutive means consecutive rows rather than consecutive dates, and the anchor becomes the difference between 2 row numbers, 1 over all days and 1 over successful days.
Problem 14: a subscriptions table has start and end dates, and 1 customer's subscriptions overlap or touch, with one sitting inside another. Merge them into continuous periods of coverage. This is gaps and islands over ranges rather than points: a new period starts when a row begins after the latest end date seen so far, and a running sum of those starts numbers the periods:
| sub_id | customer_id | start_date | end_date |
|---|---|---|---|
| 1 | 1 | 2026-01-01 | 2026-01-31 |
| 2 | 1 | 2026-01-20 | 2026-02-28 |
| 3 | 1 | 2026-03-01 | 2026-03-31 |
| 4 | 1 | 2026-06-01 | 2026-06-30 |
| 5 | 2 | 2026-02-01 | 2026-02-10 |
| 6 | 2 | 2026-02-05 | 2026-02-07 |
| 7 | 2 | 2026-02-09 | 2026-02-12 |
The running MAX of the end date is what makes it correct when a short subscription sits inside a longer one. Customer 2's third subscription starts on the 9th, after the inner subscription ended on the 7th but while the first still runs to the 10th; compared with only the previous row's end it would open a new period, and compared with the running MAX it merges. Customer 1's third subscription starts the day after the second ends, and the test treats touching ranges as 1 period; change the DATE_ADD to a plain comparison if the business counts a 1 day gap as a break.
In the editor, replace the running MAX with LAG(end_date) over the same partition and order, then run again: customer 2 splits into 2 periods, because its third subscription is compared with only the row before it. Change the DATE_ADD day count from 1 to 0 and touching ranges stop merging, so customer 1 gains a third period.
Problems 15 and 16: sessionization with a 30 minute rule
Problem 15: an events table holds page views with a user and a timestamp. Assign a session number to every event, where a new session starts after 30 minutes without activity. User 1's events are 12 minutes apart, then 46, then 7.
| user_id | event_ts | page |
|---|---|---|
| 1 | 2026-08-01 08:00:00 | home |
| 1 | 2026-08-01 08:12:00 | pricing |
| 1 | 2026-08-01 08:58:00 | home |
| 1 | 2026-08-01 09:05:00 | docs |
| 2 | 2026-08-01 08:30:00 | home |
| 2 | 2026-08-01 10:30:00 | home |
The pattern is 2 window functions in sequence. LAG gives each event the minutes since the previous one; a CASE turns a gap over the limit, or no previous event, into a 1; a running SUM of those 1s is the session number, because it increments exactly where a session starts:


A gap of exactly 30 minutes stays in the session because the test is a strict greater than; say which side of the boundary you chose, since the statement rarely does. Late events need the same care: the window's ORDER BY sorts them, so an event that arrives late but carries an early timestamp lands in the right session, which a procedural loop over arrival order would get wrong.
In the editor, change the 30 in the CASE to 50 and user 1's 46 minute gap no longer starts a session, so all 4 of their events share session 1. Change it to 10 and the 12 minute gap splits the first session too.
Problem 16: from the same events, return each session's start, its number of events and its length in minutes, plus the share of sessions that contained a single event. Group the numbered events by user and session, then let a window SUM over the whole result compute the share without a second query:
User 2's 2 visits are 2 hours apart, so each is a session of 1 event with a length of 0, and 2 of the 4 sessions bounced. A session's length as last event minus first understates the time the last page was open, which page views can't tell you; say so if the interviewer asks what the number means. The data engineer practice problems page works the same sessionization problem in Python, which is the other round that asks for it.
Problems 17 and 18: conditional aggregation and pivots
Problem 17: a tickets table records the hours each ticket took to close, along with its team and priority. Return 1 row per team with the ticket counts per priority level, high through low, in 3 columns, and the share of tickets that were high priority.
| ticket_id | team | priority | hours_to_close |
|---|---|---|---|
| 1 | platform | high | 4 |
| 2 | platform | low | 30 |
| 3 | platform | medium | 12 |
| 4 | platform | high | 6 |
| 5 | ingest | low | 48 |
| 6 | ingest | low | 20 |
| 7 | ingest | medium | 8 |
| 8 | warehouse | high | 2 |
A pivot in portable SQL is a CASE inside an aggregate, 1 per output column. The share is an AVG over a CASE that returns 1 or 0, and the ELSE 0 is the whole problem:
Leave the ELSE out and the CASE returns NULL for every ticket that is not high priority. AVG ignores NULLs, so the platform team's share reads 100% instead of 50%, and the number looks fine until someone compares it with the counts beside it. The same omission in a SUM is harmless, which is why people carry the habit from SUM to AVG. Snowflake has a PIVOT keyword that does the same work with fixed column names, and so do BigQuery and SQL Server; the CASE form is what you can write in any interview without checking the docs.
Problem 18: return each team's total hours to close and that total as a percentage of all hours. A window SUM with an empty OVER clause reads the grand total on every row, so the percentage needs no self join:
Multiply by 100.0 before you divide. On an integer column in PostgreSQL or SQL Server, 52 / 130 is 0, which makes every percentage in the result 0 too. The decimal literal promotes the arithmetic. BigQuery and Snowflake divide integers into decimals and hide the problem until the query is ported, so write the literal in every engine.
Problems 19 and 20: hierarchies with recursive CTEs
Problem 19: an employees table has a manager_id that points at another employee, and 1 employee reports to nobody. Return every employee with their level in the hierarchy and the chain of names from the top.
| employee_id | name | manager_id |
|---|---|---|
| 1 | Ada | NULL |
| 2 | Bo | 1 |
| 3 | Cy | 1 |
| 4 | Di | 2 |
| 5 | Ed | 2 |
| 6 | Flo | 3 |
| 7 | Gus | 4 |
A recursive CTE has an anchor query, the rows with no manager, and a recursive query that joins the CTE's previous result to the table to find the next level, repeated until a level adds no rows. Each level appends its name to the path and adds 1 to the level:
A cycle in the data, 2 managers who report to each other through a data entry mistake, makes the recursion run forever on engines that don't detect it. The WHERE c.level < 10 guard caps the depth, so an outage becomes a wrong row you can find. String concatenation is the other line that changes by engine: PostgreSQL and SQLite use ||, as does Snowflake, while BigQuery and MySQL use CONCAT.
In the editor, change the depth guard from 10 to 2 and the 2 deepest levels disappear. Replace the anchor's IS NULL test with employee_id = 2 and the chain starts at Bo, which is how you answer the follow-up on 1 manager's whole team.
Problem 20: for every employee, return how many people report to them directly and how many in total, including reports of reports. Build the transitive closure, every ancestor paired with every descendant and the depth between them, then count it per ancestor. A LEFT JOIN from the employees keeps the ones with no reports at 0:
Ada has 2 direct reports and 6 in total, because the closure pairs her with every employee under Bo and Cy as well. The closure grows with depth times headcount, which is fine for an org chart and a problem for a graph of millions of edges; in an interview, say when you would materialise it once instead of recomputing it per query. Recursive CTEs come up less often than window functions, and that is why they're worth 1 hour of practice: few candidates write one fluently, and the ones who do are remembered.
How to work a SQL practice problem before you read its solution
Set a timer for 20 minutes and write the query without running it until you believe it is right. A SQL round rarely gives you 10 runs to converge on an answer, and the discipline of checking your own logic first is most of what the round measures. Say the plan before you type: which table is the grain of the result, which rows get filtered before grouping, which window function the statement asks for.
Then run it against the rows once, and compare with the printed result row by row rather than glancing at the shape. A result with the right columns and 1 wrong number is how fan-out hides, and missing ELSE clauses and default frames hide the same way. If the numbers differ, find the row that differs and trace it by hand through the query before you touch the code; the fix is usually 1 token, and guessing at it teaches nothing.
Read the solution only after a working answer or the full 20 minutes, and compare the approach rather than the syntax: a correlated subquery and a window function can both be right. Come back to a problem you missed after 3 or 4 days and write it from a blank editor. A pattern you can rebuild from nothing is the one you will recognise in a problem you have never seen.
The 5 edge cases that break most first attempts
Run 5 cases through any query in your head before you submit it, in practice as much as in a round. An empty input: does a GROUP BY return 0 rows when the business expects a row of zeros? A NULL in a join or filter column: a NOT IN goes empty, a COUNT of the column undercounts, an equality test drops the row. A tie on the ORDER BY column: a ROW_NUMBER without a tiebreaker returns a different row per run, and a RANK returns 2 rows where 1 was asked for.
A duplicate row: 2 shipments for 1 order fan a SUM out and 2 runs on 1 day break a streak, and the cure is DISTINCT or an aggregation before the join. A group with a single row: a LAG returns NULL, a department with 1 salary has no second highest, a session of 1 event has a length of 0. Nearly every wrong answer on this page falls to 1 of those 5, and so do most wrong answers in interviews, because clean sample data never contains them and real data always does.
The habit transfers directly to the job. The queries that go wrong in production are the ones that were right on the sample: a dashboard that doubled revenue after a split shipment feature launched, a retention number that excluded every customer with a NULL region. Practice the 5 cases until you ask about them unprompted.
Running these solutions on PostgreSQL, Snowflake, BigQuery and SQLite
The editors on this page run in your browser on SQLite, and the solutions are written in the portable form the editor accepts and most warehouses share. Window frames and CTEs, recursive ones included, behave the same in PostgreSQL and Snowflake, and so do ROW_NUMBER, DENSE_RANK and LAG. BigQuery and Redshift match them, and so does Trino. What changes is a short list. Date functions differ in name and argument order, and string concatenation and integer division vary by engine. Some engines also need an interval for a RANGE frame over a date.
Practice in the dialect of the company you are interviewing with when you know it, and in PostgreSQL when you do not: it is the strictest of the common engines about types and the one most often used as an interview reference. Whatever the engine, write every window with an explicit ORDER BY and, where the result depends on it, an explicit frame; those 2 habits remove most of the differences. When a function differs, say so as you write it. An interviewer cares that you know a difference exists far more than which spelling you used.
To run a solution in your own warehouse, create each table as its sheet shows it and insert the rows; the column types are the plain ones, INTEGER, REAL and TEXT, and only a type name might need a change, such as TIMESTAMP for the text columns that hold timestamps here. The SQL round guide covers how the round itself is run and what the interviewer is listening for while you type.
How to tell you are ready for the SQL round
Track patterns rather than problem counts. You're ready when you can read a statement you have never seen, name its pattern inside 2 minutes and finish inside 20 with the edge cases handled, across the 10 families on this page. Most people need 3 to 5 problems per family to get there, more than the 2 per family worked here.
The test that tells you most is a problem whose answer you can't see. The SQL practice problems on DataDriven run your query against hidden rows built with the same ties and NULLs, and the same duplicates, then tell you which rows differ; the SQL interview questions ask you to explain the same patterns in words, which is the other half of a round. A SQL mock interview puts both together under time, with the follow-up questions an interviewer asks.
SQL practice problems with solutions FAQ
Where can I find SQL practice problems with solutions for free?+
Should I read the solution before trying the problem?+
How many SQL practice problems should I solve before a data engineering interview?+
Which SQL dialect should I practice in?+
Do data engineering interviews really ask for window functions?+
What is the difference between RANK, DENSE_RANK and ROW_NUMBER?+
How do I check a SQL solution when there is no answer key?+
Are these SQL problems the ones asked in data engineering interviews?+
The candidate who gets the offer
- 01
Reading a solution is not the same as writing one
Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
5 problem shapes cover 80% of data engineer loops
Dedup, sessionization, top-N-per-group, slowly-changing dimensions, partition tricks. Writing the shapes by hand turns the unfamiliar into pattern recognition
Related guides
SQL practice problems
SQL problems for data engineers, answered against hidden rows
SQL interview questions
The SQL questions data engineering interviews ask, with answers
The SQL round
How the SQL round of a data engineering loop is run and what it listens for
SQL window functions interview questions
How interviewers ask about ROW_NUMBER and LAG, and about frames
SQL window functions practice
More problems to work on ranking and LAG, plus running totals
Data engineer practice problems
1 worked problem from each round of the loop, from SQL to Spark
The weekly data challenge
Dirty, production-shaped data, scored blind each week.