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.

Last updated: Proudly published by: Jeff Wahl25 min read

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.

Prepare for the interview
01 / Open invite
02min.

Know SQL practice problems with solutions the way the interviewer who asks it knows it.

a SQL practice problems with solutions query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1SELECT user_id,
2 COUNT(*) AS sessions
3FROM events
4WHERE ts >= NOW() - INTERVAL '7 day'
5
Execute your solution0.4s avg.

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

Aggregation (GROUP BY, HAVING)
598 problems
Subqueries and EXISTS
209 problems
CTEs and query structure
198 problems
JOINs and join cardinality
191 problems
Deduplication and DISTINCT
170 problems
NULL semantics (COALESCE, NULLIF)
165 problems
Date and time handling
148 problems
Conditional aggregation (CASE WHEN)
133 problems

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.

orders
order_idcustomer_idorder_datestatusamount
11012026-03-02completed40
21012026-03-15completed25.5
31022026-03-03completed310
41032026-03-05cancelledNULL
51032026-03-20completed18
61032026-03-28completed22
71042026-03-09completed99
81042026-03-30cancelledNULL

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:

SELECT
customer_id,
COUNT(*) AS completed_orders,
SUM(amount) AS total_spend
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
HAVING COUNT(*) >= 2
ORDER BY total_spend DESC
Result
customer_idcompleted_orderstotal_spend
101265.5
103240
2 rows

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:

SELECT
customer_id,
ROUND(AVG(amount), 2) AS avg_order
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
HAVING AVG(amount) > (
SELECT
AVG(amount)
FROM orders
WHERE status = 'completed'
)
ORDER BY avg_order DESC
Result
customer_idavg_order
102310
10499
2 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.

customers
customer_idnameregion
1Adawest
2Boeast
3Cyeast
4Diwest
orders
order_idcustomer_idorder_dateamount
100112026-04-01120
100212026-04-0980
100322026-04-0360
1004NULL2026-04-0545
shipments
shipment_idorder_idshipped_datecarrier
110012026-04-02UPS
210012026-04-04UPS
310022026-04-10FedEx
410032026-04-04USPS

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.

Order 1001 for 120 joined to its 2 shipment rows gives 2 joined rows that each carry 120, so the sum reads 240 against the 120 that was orderedOrder 1001 for 120 joined to its 2 shipment rows gives 2 joined rows that each carry 120, so the sum reads 240 against the 120 that was ordered

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:

WITH shipments_per_order AS (
SELECT
order_id,
COUNT(*) AS shipment_count
FROM shipments
GROUP BY order_id
)
SELECT
c.customer_id,
c.name,
COALESCE(SUM(o.amount), 0) AS total_amount,
COALESCE(SUM(s.shipment_count), 0) AS shipments
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
LEFT JOIN shipments_per_order AS s
ON s.order_id = o.order_id
GROUP BY c.customer_id, c.name
ORDER BY c.customer_id
Result
customer_idnametotal_amountshipments
1Ada2003
2Bo601
3Cy00
4Di00
4 rows

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:

SELECT
c.customer_id,
c.name
FROM customers AS c
WHERE NOT EXISTS (
SELECT
1
FROM orders AS o
WHERE o.customer_id = c.customer_id
)
ORDER BY c.customer_id
Result
customer_idname
3Cy
4Di
2 rows

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.

device_readings
reading_iddevice_idreading_tstemperature
172026-05-01 08:00:0021.5
272026-05-01 09:00:0022.1
372026-05-01 09:00:0022.4
482026-05-01 08:30:0019
582026-05-01 08:30:0019
692026-05-01 07:45:0025.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.

WITH ranked AS (
SELECT
device_id,
reading_ts,
temperature,
ROW_NUMBER() OVER (
PARTITION BY device_id
ORDER BY reading_ts DESC, reading_id DESC
) AS rn
FROM device_readings
)
SELECT
device_id,
reading_ts,
temperature
FROM ranked
WHERE rn = 1
ORDER BY device_id
Result
device_idreading_tstemperature
72026-05-01 09:00:0022.4
82026-05-01 08:30:0019
92026-05-01 07:45:0025.2
3 rows

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.

contacts
contact_idemailcreated_at
1ada@example.com2026-01-03
2bo@example.com2026-01-04
3ADA@example.com2026-02-11
4cy@example.com2026-02-12
5bo@example.com2026-03-01
SELECT
LOWER(email) AS email_key,
COUNT(*) AS copies,
MIN(contact_id) AS keep_contact_id
FROM contacts
GROUP BY LOWER(email)
HAVING COUNT(*) > 1
ORDER BY email_key
Result
email_keycopieskeep_contact_id
ada@example.com21
bo@example.com22
2 rows

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.

product_revenue
categoryproductrevenue
audioheadphones900
audiospeaker700
audioearbuds700
audiomicrophone300
videocamera1200
videotripod150
videolight150

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:

WITH ranked AS (
SELECT
category,
product,
revenue,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS rnk
FROM product_revenue
)
SELECT
category,
product,
revenue,
rnk
FROM ranked
WHERE rnk <= 2
ORDER BY category, rnk, product
Result
categoryproductrevenuernk
audioheadphones9001
audioearbuds7002
audiospeaker7002
videocamera12001
videolight1502
videotripod1502
6 rows

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:

SELECT
category,
product,
revenue,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS row_num,
RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS rnk,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS dense
FROM product_revenue
ORDER BY category, revenue DESC, product
Result
categoryproductrevenuerow_numrnkdense
audioheadphones900111
audioearbuds700322
audiospeaker700222
audiomicrophone300443
videocamera1200111
videolight150322
videotripod150222
7 rows

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:

salaries
employee_iddepartmentsalary
1data150000
2data150000
3data120000
4data95000
5ops80000
6ops80000
7sales70000
8sales90000
9sales65000
WITH ranked AS (
SELECT
department,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS rnk
FROM salaries
)
SELECT DISTINCT
department,
salary AS second_highest
FROM ranked
WHERE rnk = 2
ORDER BY department
Result
departmentsecond_highest
data120000
sales70000
2 rows

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.

daily_sales
sale_datestoreamount
2026-06-01north100
2026-06-02north50
2026-06-02north50
2026-06-03north80
2026-06-05north120
2026-06-01south70
2026-06-02south90
2026-06-03south60
2026-06-04south110
2026-06-05south40

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:

WITH per_day AS (
SELECT
store,
sale_date,
SUM(amount) AS day_amount
FROM daily_sales
GROUP BY store, sale_date
)
SELECT
store,
sale_date,
day_amount,
SUM(day_amount) OVER (
PARTITION BY store
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM per_day
ORDER BY store, sale_date
Result
storesale_dateday_amountrunning_total
north2026-06-01100100
north2026-06-02100200
north2026-06-0380280
north2026-06-05120400
south2026-06-017070
south2026-06-0290160
south2026-06-0360220
south2026-06-04110330
south2026-06-0540370
9 rows

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:

SELECT
store,
sale_date,
amount,
SUM(amount) OVER (
PARTITION BY store
ORDER BY sale_date
) AS default_frame,
SUM(amount) OVER (
PARTITION BY store
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS rows_frame
FROM daily_sales
ORDER BY store, sale_date, amount
Result
storesale_dateamountdefault_framerows_frame
north2026-06-01100100100
north2026-06-0250200150
north2026-06-0250200200
north2026-06-0380280280
north2026-06-05120400400
south2026-06-01707070
south2026-06-0290160160
south2026-06-0360220220
south2026-06-04110330330
south2026-06-0540370370
10 rows

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:

calendar
day
2026-06-01
2026-06-02
2026-06-03
2026-06-04
2026-06-05
WITH per_day AS (
SELECT
store,
sale_date,
SUM(amount) AS day_amount
FROM daily_sales
GROUP BY store, sale_date
),
stores AS (
SELECT DISTINCT
store
FROM daily_sales
),
spine AS (
SELECT
s.store,
c.day,
COALESCE(p.day_amount, 0) AS day_amount
FROM stores AS s
CROSS JOIN calendar AS c
LEFT JOIN per_day AS p
ON p.store = s.store
AND p.sale_date = c.day
)
SELECT
store,
day,
day_amount,
ROUND(
AVG(day_amount) OVER (
PARTITION BY store
ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
1
) AS avg_3_day
FROM spine
ORDER BY store, day
Result
storedayday_amountavg_3_day
north2026-06-01100100
north2026-06-02100100
north2026-06-038093.3
north2026-06-04060
north2026-06-0512066.7
south2026-06-017070
south2026-06-029080
south2026-06-036073.3
south2026-06-0411086.7
south2026-06-054070
10 rows

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.

monthly_revenue
monthregionrevenue
2026-01-01emea100
2026-02-01emea120
2026-03-01emea90
2026-04-01emea135
2026-01-01apac80
2026-02-01apac0
2026-04-01apac110

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:

WITH ordered AS (
SELECT
region,
month,
revenue,
LAG(revenue, 1) OVER (
PARTITION BY region
ORDER BY month
) AS prev_revenue,
LAG(month, 1) OVER (
PARTITION BY region
ORDER BY month
) AS prev_month
FROM monthly_revenue
)
SELECT
region,
month,
revenue,
revenue - prev_revenue AS change,
ROUND(
100.0 * (
revenue - prev_revenue
) / NULLIF(prev_revenue, 0),
1
) AS pct_change,
CASE
WHEN prev_month IS NULL THEN 'first'
WHEN DATE_DIFF('month', prev_month, month) = 1 THEN 'consecutive'
ELSE 'gap'
END AS months
FROM ordered
ORDER BY region, month
Result
regionmonthrevenuechangepct_changemonths
apac2026-01-0180NULLNULLfirst
apac2026-02-010-80-100consecutive
apac2026-04-01110110NULLgap
emea2026-01-01100NULLNULLfirst
emea2026-02-011202020consecutive
emea2026-03-0190-30-25consecutive
emea2026-04-011354550consecutive
7 rows

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:

orders
order_idcustomer_idorder_date
112026-02-01
212026-02-15
312026-03-20
422026-02-10
532026-03-01
632026-03-01
SELECT
customer_id,
order_id,
order_date,
DATE_DIFF(
'day',
LAG(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date, order_id
),
order_date
) AS days_since_previous
FROM orders
ORDER BY customer_id, order_date, order_id
Result
customer_idorder_idorder_datedays_since_previous
112026-02-01NULL
122026-02-1514
132026-03-2033
242026-02-10NULL
352026-03-01NULL
362026-03-010
6 rows

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.

job_runs
run_idjob_namerun_datestatus
1nightly_load2026-07-01success
2nightly_load2026-07-02success
3nightly_load2026-07-03failed
4nightly_load2026-07-04success
5nightly_load2026-07-05success
6nightly_load2026-07-06success
7nightly_load2026-07-06success
8nightly_load2026-07-08success
9hourly_sync2026-07-01success
10hourly_sync2026-07-02failed
11hourly_sync2026-07-03success

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:

WITH success_days AS (
SELECT DISTINCT
job_name,
run_date
FROM job_runs
WHERE status = 'success'
),
numbered AS (
SELECT
job_name,
run_date,
ROW_NUMBER() OVER (
PARTITION BY job_name
ORDER BY run_date
) AS rn
FROM success_days
),
islands AS (
SELECT
job_name,
run_date,
DATE_ADD('day', - rn, run_date) AS anchor
FROM numbered
),
streaks AS (
SELECT
job_name,
MIN(run_date) AS streak_start,
MAX(run_date) AS streak_end,
COUNT(*) AS days
FROM islands
GROUP BY job_name, anchor
)
SELECT
job_name,
streak_start,
streak_end,
days
FROM streaks AS s
WHERE days = (
SELECT
MAX(days)
FROM streaks AS m
WHERE m.job_name = s.job_name
)
ORDER BY job_name, streak_start
Result
job_namestreak_startstreak_enddays
hourly_sync2026-07-012026-07-011
hourly_sync2026-07-032026-07-031
nightly_load2026-07-042026-07-063
3 rows

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:

subscriptions
sub_idcustomer_idstart_dateend_date
112026-01-012026-01-31
212026-01-202026-02-28
312026-03-012026-03-31
412026-06-012026-06-30
522026-02-012026-02-10
622026-02-052026-02-07
722026-02-092026-02-12
WITH ordered AS (
SELECT
customer_id,
start_date,
end_date,
MAX(end_date) OVER (
PARTITION BY customer_id
ORDER BY start_date, end_date
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
) AS prev_max_end
FROM subscriptions
),
flagged AS (
SELECT
customer_id,
start_date,
end_date,
CASE
WHEN prev_max_end IS NULL THEN 1
WHEN start_date > DATE_ADD('day', 1, prev_max_end) THEN 1
ELSE 0
END AS new_period
FROM ordered
),
numbered AS (
SELECT
customer_id,
start_date,
end_date,
SUM(new_period) OVER (
PARTITION BY customer_id
ORDER BY start_date, end_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS period_id
FROM flagged
)
SELECT
customer_id,
MIN(start_date) AS covered_from,
MAX(end_date) AS covered_to,
COUNT(*) AS subscriptions_merged
FROM numbered
GROUP BY customer_id, period_id
ORDER BY customer_id, covered_from
Result
customer_idcovered_fromcovered_tosubscriptions_merged
12026-01-012026-03-313
12026-06-012026-06-301
22026-02-012026-02-123
3 rows

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.

events
user_idevent_tspage
12026-08-01 08:00:00home
12026-08-01 08:12:00pricing
12026-08-01 08:58:00home
12026-08-01 09:05:00docs
22026-08-01 08:30:00home
22026-08-01 10:30:00home

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 timeline of 1 user's events at 08:00, 08:12, 08:58 and 09:05 with gaps of 12, 46 and 7 minutes between them under a 30 minute limit; only the 46 minute gap passes the limit, and brackets group the first 2 events as session 1 and the last 2 as session 2A timeline of 1 user's events at 08:00, 08:12, 08:58 and 09:05 with gaps of 12, 46 and 7 minutes between them under a 30 minute limit; only the 46 minute gap passes the limit, and brackets group the first 2 events as session 1 and the last 2 as session 2
WITH gaps AS (
SELECT
user_id,
event_ts,
page,
DATE_DIFF(
'minute',
LAG(event_ts, 1) OVER (
PARTITION BY user_id
ORDER BY event_ts
),
event_ts
) AS minutes_since_previous
FROM events
),
flagged AS (
SELECT
user_id,
event_ts,
page,
minutes_since_previous,
CASE
WHEN minutes_since_previous IS NULL THEN 1
WHEN minutes_since_previous > 30 THEN 1
ELSE 0
END AS new_session
FROM gaps
)
SELECT
user_id,
event_ts,
page,
minutes_since_previous,
SUM(new_session) OVER (
PARTITION BY user_id
ORDER BY event_ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_number
FROM flagged
ORDER BY user_id, event_ts
Result
user_idevent_tspageminutes_since_previoussession_number
12026-08-01 08:00:00homeNULL1
12026-08-01 08:12:00pricing121
12026-08-01 08:58:00home462
12026-08-01 09:05:00docs72
22026-08-01 08:30:00homeNULL1
22026-08-01 10:30:00home1202
6 rows

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:

WITH gaps AS (
SELECT
user_id,
event_ts,
DATE_DIFF(
'minute',
LAG(event_ts, 1) OVER (
PARTITION BY user_id
ORDER BY event_ts
),
event_ts
) AS minutes_since_previous
FROM events
),
numbered AS (
SELECT
user_id,
event_ts,
SUM(
CASE
WHEN minutes_since_previous IS NULL THEN 1
WHEN minutes_since_previous > 30 THEN 1
ELSE 0
END
) OVER (
PARTITION BY user_id
ORDER BY event_ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_number
FROM gaps
),
sessions AS (
SELECT
user_id,
session_number,
MIN(event_ts) AS started_at,
COUNT(*) AS events,
DATE_DIFF('minute', MIN(event_ts), MAX(event_ts)) AS duration_minutes
FROM numbered
GROUP BY user_id, session_number
)
SELECT
user_id,
session_number,
started_at,
events,
duration_minutes,
ROUND(
100.0 * AVG(
CASE
WHEN events = 1 THEN 1.0
ELSE 0
END
) OVER (),
1
) AS bounce_pct
FROM sessions
ORDER BY user_id, session_number
Result
user_idsession_numberstarted_ateventsduration_minutesbounce_pct
112026-08-01 08:00:0021250
122026-08-01 08:58:002750
212026-08-01 08:30:001050
222026-08-01 10:30:001050
4 rows

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.

tickets
ticket_idteampriorityhours_to_close
1platformhigh4
2platformlow30
3platformmedium12
4platformhigh6
5ingestlow48
6ingestlow20
7ingestmedium8
8warehousehigh2

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:

SELECT
team,
SUM(
CASE
WHEN priority = 'high' THEN 1
ELSE 0
END
) AS high,
SUM(
CASE
WHEN priority = 'medium' THEN 1
ELSE 0
END
) AS medium,
SUM(
CASE
WHEN priority = 'low' THEN 1
ELSE 0
END
) AS low,
ROUND(
100.0 * AVG(
CASE
WHEN priority = 'high' THEN 1.0
ELSE 0
END
),
1
) AS pct_high
FROM tickets
GROUP BY team
ORDER BY team
Result
teamhighmediumlowpct_high
ingest0120
platform21150
warehouse100100
3 rows

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:

WITH per_team AS (
SELECT
team,
SUM(hours_to_close) AS hours
FROM tickets
GROUP BY team
)
SELECT
team,
hours,
ROUND(
100.0 * hours / SUM(hours) OVER (
),
1
) AS pct_of_total
FROM per_team
ORDER BY hours DESC
Result
teamhourspct_of_total
ingest7658.5
platform5240
warehouse21.5
3 rows

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.

employees
employee_idnamemanager_id
1AdaNULL
2Bo1
3Cy1
4Di2
5Ed2
6Flo3
7Gus4

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:

WITH RECURSIVE chain AS (
SELECT
employee_id,
name,
manager_id,
1 AS level,
name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.employee_id,
e.name,
e.manager_id,
c.level + 1,
c.path || ' > ' || e.name
FROM employees AS e
JOIN chain AS c
ON e.manager_id = c.employee_id
WHERE c.level < 10
)
SELECT
employee_id,
name,
level,
path
FROM chain
ORDER BY level, employee_id
Result
employee_idnamelevelpath
1Ada1Ada
2Bo2Ada > Bo
3Cy2Ada > Cy
4Di3Ada > Bo > Di
5Ed3Ada > Bo > Ed
6Flo3Ada > Cy > Flo
7Gus4Ada > Bo > Di > Gus
7 rows

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:

WITH RECURSIVE reports AS (
SELECT
manager_id AS ancestor_id,
employee_id AS report_id,
1 AS depth
FROM employees
WHERE manager_id IS NOT NULL
UNION ALL
SELECT
r.ancestor_id,
e.employee_id,
r.depth + 1
FROM reports AS r
JOIN employees AS e
ON e.manager_id = r.report_id
)
SELECT
m.employee_id,
m.name,
SUM(
CASE
WHEN r.depth = 1 THEN 1
ELSE 0
END
) AS direct_reports,
COUNT(r.report_id) AS total_reports
FROM employees AS m
LEFT JOIN reports AS r
ON r.ancestor_id = m.employee_id
GROUP BY m.employee_id, m.name
ORDER BY total_reports DESC, m.employee_id
Result
employee_idnamedirect_reportstotal_reports
1Ada26
2Bo23
3Cy11
4Di11
5Ed00
6Flo00
7Gus00
7 rows

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?+
This page works 20 through in full. Each one prints its data beside the solution and names the trap behind it, and every one of them runs in a browser editor you can edit. DataDriven's catalogue holds 928 SQL problems whose answers are checked against hidden data at no cost, and every one of them was asked in a data engineering interview.
Should I read the solution before trying the problem?+
No. Write your own query first, run it in the solution's editor, and open the solution only when you have a working answer or 20 minutes have passed. Reading first teaches you to recognise a solution, which is a different skill from producing one, and the round asks for the second.
How many SQL practice problems should I solve before a data engineering interview?+
Enough that each of the 10 pattern families on this page takes you under 20 minutes from a cold statement, with the edge cases handled. For most people that is 3 to 5 problems per family, roughly 40 in total, and a problem you solved and then rebuilt after breaking it is worth more than 10 you read.
Which SQL dialect should I practice in?+
The dialect of the company you are interviewing with when you know it, and PostgreSQL when you do not. Window functions and CTEs work the same everywhere, and so do anti joins and conditional aggregation. What changes is the date functions and string concatenation. Integer division changes too, and you should name the difference when you write the line.
Do data engineering interviews really ask for window functions?+
Yes, in nearly every SQL round past the first screen. Keeping the latest row per key is a window function, and so is top N per group; running totals and month over month change are too, and they decide most SQL rounds at the mid and senior levels.
What is the difference between RANK, DENSE_RANK and ROW_NUMBER?+
They differ only on ties. ROW_NUMBER gives tied rows different numbers in an order the engine picks, RANK gives them the same number and skips the next, and DENSE_RANK gives them the same number and continues. Keep the latest row with ROW_NUMBER, keep ties with DENSE_RANK, and reach for RANK when the statement wants the skipped positions.
How do I check a SQL solution when there is no answer key?+
Count the rows you expect before you run, trace 1 row through the query by hand, and then run the 5 edge cases. Start with an empty input and a NULL key, try a tie and a duplicate row, and finish with a group that has a single row. A query that survives those on data you wrote yourself is right far more often than one that matched a sample.
Are these SQL problems the ones asked in data engineering interviews?+
The patterns are, and the statements are written the way interviewers put them. The problems in the DataDriven catalogue are questions asked in real data engineering interviews, and each section here maps to a family in that catalogue, so finishing a section and opening its live problem is the shortest path from a worked solution to the real thing.
02 / Why practice

The candidate who gets the offer

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

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

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