Drill — Solutions Hidden by Default

SQL Problems

Ten drillable problems — five creation, five debugging — flavored to a GPU-marketplace schema. Multiple approaches per problem with tradeoff discussion.

🎯 10 problems ⏱ ~15 min each 📊 Progress saved
0 / 10 practiced
Schema for all problems

customers(customer_id, signed_up_at, country, plan)
gpu_sessions(session_id, customer_id, gpu_id, tier, started_at, ended_at, revenue)
gpus(gpu_id, supplier_id, tier, hourly_cost)
suppliers(supplier_id, name, joined_at, country)

A · Creation problems

P1. Top 3 customers by GPU-hours per week, last 8 weeks

Return (week, customer_id, gpu_hours, rank) for the top 3 customers by GPU-hours each ISO week for the last 8 weeks.

Show solution
top3_per_week.sql
WITH per_week AS (
  SELECT
    DATE_TRUNC('week', started_at)::DATE AS week,
    customer_id,
    SUM(EXTRACT(EPOCH FROM ended_at - started_at) / 3600.0) AS gpu_hours
  FROM gpu_sessions
  WHERE started_at >= NOW() - INTERVAL '8 weeks'
  GROUP BY 1, 2
),
ranked AS (
  SELECT
    week, customer_id, gpu_hours,
    DENSE_RANK() OVER (PARTITION BY week ORDER BY gpu_hours DESC) AS rk
  FROM per_week
)
SELECT week, customer_id, gpu_hours, rk
FROM ranked
WHERE rk <= 3
ORDER BY week, rk;

Tradeoff: DENSE_RANK vs ROW_NUMBER — ties keep all tied customers (could exceed 3 per week). ROW_NUMBER caps at exactly 3 but breaks ties arbitrarily. Ask the interviewer; default to DENSE_RANK + caveat.

P2. Weekly cohort retention to week N

For each signup-week cohort in the last 12 weeks, report retention rate at weeks 1, 2, 4, 8 (customer "retained" if they launched at least one session that week).

Show solution
cohort_retention.sql
WITH cohorts AS (
  SELECT customer_id, DATE_TRUNC('week', signed_up_at)::DATE AS cohort_week
  FROM customers
  WHERE signed_up_at >= NOW() - INTERVAL '12 weeks'
),
cohort_size AS (
  SELECT cohort_week, COUNT(*) AS size FROM cohorts GROUP BY 1
),
activity AS (
  SELECT DISTINCT
    g.customer_id,
    DATE_TRUNC('week', g.started_at)::DATE AS active_week
  FROM gpu_sessions g
),
retained AS (
  SELECT
    c.cohort_week,
    (a.active_week - c.cohort_week) / 7 AS weeks_since,
    COUNT(DISTINCT c.customer_id) AS retained
  FROM cohorts c
  JOIN activity a USING (customer_id)
  WHERE a.active_week >= c.cohort_week
  GROUP BY 1, 2
)
SELECT
  r.cohort_week, r.weeks_since,
  r.retained, s.size,
  r.retained * 1.0 / s.size AS retention_rate
FROM retained r
JOIN cohort_size s USING (cohort_week)
WHERE r.weeks_since IN (1, 2, 4, 8)
ORDER BY 1, 2;

Edge case: a customer can be active multiple times in week N — COUNT(DISTINCT) handles that. The "weeks_since" calc subtracts two dates, which gives days in PostgreSQL; other dialects need DATEDIFF.

P3. Sessionize events into 30-minute idle-gap sessions

Given events(customer_id, event_at), return (customer_id, session_id, session_start, session_end, n_events) where a session ends if there's a gap > 30 minutes.

Show solution
sessionize.sql
WITH flagged AS (
  SELECT
    customer_id, event_at,
    CASE
      WHEN LAG(event_at) OVER (PARTITION BY customer_id ORDER BY event_at) IS NULL
        OR EXTRACT(EPOCH FROM event_at -
             LAG(event_at) OVER (PARTITION BY customer_id ORDER BY event_at)) / 60 > 30
      THEN 1 ELSE 0
    END AS is_new_session
  FROM events
),
sessioned AS (
  SELECT
    customer_id, event_at,
    SUM(is_new_session) OVER (PARTITION BY customer_id ORDER BY event_at) AS session_id
  FROM flagged
)
SELECT
  customer_id, session_id,
  MIN(event_at) AS session_start,
  MAX(event_at) AS session_end,
  COUNT(*)      AS n_events
FROM sessioned
GROUP BY 1, 2
ORDER BY 1, 2;

P4. Funnel: signup → first session → repeat session

Of customers who signed up in the last 30 days: how many launched a first session within 7 days? Of those, how many launched a second session within 30 days of the first?

Show solution
funnel_3step.sql
WITH signup AS (
  SELECT customer_id, MIN(signed_up_at) AS signup_at
  FROM customers
  WHERE signed_up_at >= NOW() - INTERVAL '30 days'
  GROUP BY 1
),
first_session AS (
  SELECT s.customer_id, s.signup_at, MIN(g.started_at) AS first_at
  FROM signup s
  JOIN gpu_sessions g
    ON g.customer_id = s.customer_id
   AND g.started_at BETWEEN s.signup_at AND s.signup_at + INTERVAL '7 days'
  GROUP BY 1, 2
),
second_session AS (
  SELECT f.customer_id
  FROM first_session f
  JOIN gpu_sessions g
    ON g.customer_id = f.customer_id
   AND g.started_at > f.first_at
   AND g.started_at <= f.first_at + INTERVAL '30 days'
  GROUP BY 1
)
SELECT
  (SELECT COUNT(*) FROM signup)         AS signups,
  (SELECT COUNT(*) FROM first_session)  AS first_session_within_7d,
  (SELECT COUNT(*) FROM second_session) AS second_session_within_30d;

Senior touch: the time-window bounds go in the JOIN, not WHERE. Prevents the LEFT-JOIN-NULL trap if someone later switches to LEFT JOIN to count drop-offs.

P5. Longest consecutive-day usage streak per customer

For each customer, return the longest run of consecutive calendar days on which they had at least one session.

Show solution
longest_streak.sql
WITH daily AS (
  SELECT DISTINCT customer_id, DATE_TRUNC('day', started_at)::DATE AS d
  FROM gpu_sessions
),
keyed AS (
  SELECT
    customer_id, d,
    d - (ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY d))::INT AS run_key
  FROM daily
),
runs AS (
  SELECT customer_id, run_key, COUNT(*) AS streak_days
  FROM keyed GROUP BY 1, 2
)
SELECT customer_id, MAX(streak_days) AS longest_streak
FROM runs
GROUP BY 1
ORDER BY longest_streak DESC;

B · Debugging problems

P6. "Customers with no sessions" returns nothing

Intent: list customers who've never had a GPU session. The query returns zero rows even though we know such customers exist. Fix it.

broken.sql
SELECT customer_id FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM gpu_sessions);
Show fix & reasoning

Bug: if gpu_sessions.customer_id contains any NULL, NOT IN returns zero rows for every customer (because x NOT IN (..., NULL) evaluates to UNKNOWN).

fixed.sql
SELECT c.customer_id FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM gpu_sessions g WHERE g.customer_id = c.customer_id
);

Alternative: LEFT JOIN ... WHERE g.customer_id IS NULL. Both are correct; NOT EXISTS is the most defensible against NULLs.

P7. Customer revenue is doubled

Intent: total revenue per customer with their region (regions are stored in a separate dim table, one row per customer). The query produces revenue values 2× what they should be for some customers. Why?

broken.sql
SELECT c.customer_id, r.region, SUM(g.revenue) AS total
FROM customers c
JOIN customer_region_history r ON r.customer_id = c.customer_id
LEFT JOIN gpu_sessions g       ON g.customer_id = c.customer_id
GROUP BY 1, 2;
Show fix & reasoning

Bug: the table is customer_region_history, not customer_region — it has multiple rows per customer (one per region change). Joining it duplicates sessions, so SUM(revenue) doubles for customers with 2 region rows.

fixed.sql
WITH current_region AS (
  SELECT DISTINCT ON (customer_id)
    customer_id, region
  FROM customer_region_history
  ORDER BY customer_id, changed_at DESC
),
customer_revenue AS (
  SELECT customer_id, SUM(revenue) AS total
  FROM gpu_sessions GROUP BY 1
)
SELECT c.customer_id, r.region, COALESCE(cr.total, 0) AS total
FROM customers c
LEFT JOIN current_region r    ON r.customer_id = c.customer_id
LEFT JOIN customer_revenue cr ON cr.customer_id = c.customer_id;

The senior diagnostic: "Let me check the cardinality of customer_region_history. If it's not 1-per-customer, I have a fanout."

P8. Latest plan per customer returns nulls inconsistently

Intent: latest plan per customer. The query returns NULL for the plan column on most rows. Fix it.

broken.sql
SELECT
  customer_id,
  LAST_VALUE(plan) OVER (PARTITION BY customer_id ORDER BY changed_at) AS latest_plan
FROM plan_changes;
Show fix & reasoning

Bug: the default window frame with ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. LAST_VALUE over that frame returns the current row, not the partition's last.

fixed.sql
SELECT
  customer_id,
  LAST_VALUE(plan) OVER (
    PARTITION BY customer_id
    ORDER BY changed_at
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS latest_plan
FROM plan_changes;

-- Cleaner alternative: filter to the most recent row
SELECT customer_id, plan FROM (
  SELECT customer_id, plan,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY changed_at DESC) AS rn
  FROM plan_changes
) WHERE rn = 1;

P9. Daily DAU query suddenly slow

This query used to run in 2 seconds; now it takes 90. Same data volume, same shape. What's likely wrong and how do you investigate?

slow.sql
SELECT
  DATE(started_at)            AS day,
  COUNT(DISTINCT customer_id) AS dau
FROM gpu_sessions
WHERE DATE(started_at) >= CURRENT_DATE - 7
GROUP BY 1;
Show diagnosis

Diagnosis: the DATE(started_at) function call on the filtered column likely defeats partition pruning. If gpu_sessions is date-partitioned, the optimizer can't push the predicate down — it scans every partition.

fixed.sql
SELECT
  DATE_TRUNC('day', started_at)::DATE AS day,
  COUNT(DISTINCT customer_id)         AS dau
FROM gpu_sessions
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
  AND started_at <  CURRENT_DATE + INTERVAL '1 day'
GROUP BY 1;

Other suspects (mention all of these): stale statistics → ANALYZE; new skew on customer_id; an index dropped; warehouse compute downsized; concurrent heavy queries. The senior move is naming the checks in order: query plan first, then partition pruning, then stats, then skew.

P10. "Active users in the last 7 days" undercounts

Intent: customers who launched at least one session in the last 7 days. Stakeholders insist the number is too low. Find the bug.

broken.sql
SELECT COUNT(DISTINCT customer_id) AS active
FROM gpu_sessions
WHERE started_at BETWEEN CURRENT_DATE - 7 AND CURRENT_DATE;
Show fix & reasoning

Two bugs. (1) BETWEEN CURRENT_DATE - 7 AND CURRENT_DATE excludes today's sessions after midnight — CURRENT_DATE is 2026-05-12 00:00:00. (2) On warehouses where started_at is UTC, "last 7 days" by user-local time is a separate question — sessions in user-Pacific Sunday land in UTC Monday.

fixed.sql
SELECT COUNT(DISTINCT customer_id) AS active
FROM gpu_sessions
WHERE started_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
  AND started_at <  CURRENT_TIMESTAMP;

The senior move is to ask "do you mean 7 calendar days, or the last 7×24 hours from right now?" before fixing — these can disagree by 5–15% depending on the day.

Drill protocol

How to use this page

Enable drill mode. Read each problem. Give yourself 12 minutes. Write the SQL on paper or in a notebook (not Postgres yet — the screen is whiteboard-flavored). Speak through your approach. Reveal the solution. Compare. Mark "practiced." Aim for 7/10 cleared before the screen, with at least 2 of the debugging ones.

✓ Check yourself

Answer from memory before opening each one.

  1. In problem 1, one week has customers with GPU-hours 10, 8, 8, 8 and 5. How many rows does that week return with DENSE_RANK and rank at most 3, and how many with ROW_NUMBER?

    Show answer

    DENSE_RANK gives ranks 1, 2, 2, 2, 3, so all five rows pass the filter. ROW_NUMBER returns exactly three, but which two of the tied customers appear is arbitrary. Pick based on whether the consumer needs a fixed count or fair ties. See creation problems.

  2. You run problem 2 for a cohort of 50 customers and see 40 retained at week 1 and 45 at week 2. Is that a bug?

    Show answer

    No. Retained means active in that exact week, not active in every week so far, so the number can rise as customers return. A cumulative view would be a different metric. Check the definition before treating a rise as an error. See creation problems.

  3. You run problem 4 and the second-session count comes out higher than the first-session count. What went wrong?

    Show answer

    The second step reads from the first, so it can only contain customers already in it. A count above the first means the step lost its GROUP BY customer_id, so a customer with several later sessions is counted several times. See creation problems.

  4. In problem 7, revenue is doubled for some customers. Explain the mechanism and why the fix aggregates revenue before joining the region.

    Show answer

    The region history has several rows per customer, so each session row repeats once per region row, and SUM over the repeated rows inflates. Reducing revenue to one row per customer and picking the current region makes both sides one row per customer, so the join cannot multiply. See debugging problems.

  5. Today is 2026-05-12 at 14:00. What time range does BETWEEN CURRENT_DATE - 7 AND CURRENT_DATE cover in problem 10, and what question do you ask before fixing it?

    Show answer

    It covers 2026-05-05 00:00 to 2026-05-12 00:00, so none of today's sessions count. Ask whether the stakeholder means seven calendar days or the last 168 hours, because the two answers differ by up to a day of activity. See debugging problems.

  6. A daily-active query ran in 2 seconds last month and takes 90 now with the same data volume. What do you check first, and why in that order?

    Show answer

    Read the query plan first, because it shows what changed without guessing. Then check whether a function on the date column blocks partition pruning, then statistics, skew and warehouse size. The plan narrows the rest. See debugging problems.

Takeaway

  • A top-N per group problem hinges on the tie rule, and you should ask for it rather than assume it.
  • Cohort retention counts customers active in a given week, so the curve can rise and is not a survival curve.
  • Check row counts at each step of a funnel; each later step must be a subset of the earlier one.
  • Fan-out from a one-to-many join is the usual cause of inflated sums; aggregate first or fix the grain before joining.
  • For last N days, ask whether the stakeholder means calendar days or a rolling window, then write half-open bounds.
  • Practise by writing on paper, speaking the approach, and revealing the solution only afterwards.

The second half of the screen moves from SQL to pandas, and the next chapter covers its idioms. → Pandas Fundamentals

Further reading

  • SELECT — PostgreSQL documentation, 2026. DISTINCT ON, LIMIT and the full clause order behind problems 1 and 7.
  • Window Functions — PostgreSQL documentation, 2026. Ranking functions used in problems 1 and 8.
  • Subquery Expressions — PostgreSQL documentation, 2026. The NOT IN and NOT EXISTS behaviour behind problem 6.
  • Using EXPLAIN — PostgreSQL documentation, 2026. Reading plans for the slow query in problem 9.