SQL Practice Questions: A Three-Tier Ladder Built From Real Interviews

Practice SQL for data scientist interviews with three verified tiers covering aggregation, joins, window functions, cohorts, and query debugging.

Author: PracHub

Published: 8/14/2026

SQL Practice Questions: A Three-Tier Ladder Built From Real Interviews

By PracHub
August 14, 2026
21 min read
0
SQL Practice Questions: A Three-Tier Ladder Built From Real Interviews

Quick Overview

Build SQL interview skill through three verified tiers: one-table metrics, joins and grain, then windows and cohorts. Each PostgreSQL example includes explicit assumptions, edge cases, and expected output.

Data ScientistFree

Good SQL practice isolates one decision at a time. In a data scientist interview, a simple metric can fail because COUNT(column) counts the wrong rows, a join changes the intended grain, or a cohort query treats an incomplete period as zero retention.

This guide uses a three-tier PostgreSQL ladder:

  1. One-table aggregation and denominators
  2. Joins, row survival, and result grain
  3. Window functions and cohort retention

Every walkthrough follows the same order: read the input tables, write the operation query, trace the row flow, and check the output table. The final capstone uses that sequence to audit a broken daily-active-users query.

Choose the right practice tier

Start where your reasoning becomes unreliable, not where the syntax looks impressive.

TierMain decisionMove on when you can...
1. One tableDefine the numerator and denominatorPredict how NULL and false values affect each count.
2. JoinsDecide which rows survive and at what grainExplain whether a right-side filter belongs in ON or WHERE.
3. Multi-step analysisRank rows and define complete time periodsBreak logic into stages, resolve ties, and identify censored periods.

Use the same practice loop for each problem:

  1. Define one output row in plain language.
  2. Name the numerator and denominator.
  3. Predict the row count and one edge-case result.
  4. Write the query.
  5. Compare the actual output with your prediction.
  6. Change one input row and explain what should change.

The datasets are intentionally small. You should be able to verify the outputs by hand before PostgreSQL confirms them.

Tier 1: one-table aggregation

The first operation asks for pickup attempts, successful pickups, and pickup rate by media type.

Input table

calls

call_idmediapickup
1videoY
2videoN
3videoY
4videoY
5voiceN
6voiceY
7voiceN
8voiceY

The output grain is one row per media type. All calls belong in the denominator, but only rows with pickup = 'Y' belong in the numerator.

Operation query

SELECT
  media,
  COUNT(*) AS attempts,
  SUM(CASE WHEN pickup = 'Y' THEN 1 ELSE 0 END) AS pickups,
  ROUND(
    SUM(CASE WHEN pickup = 'Y' THEN 1 ELSE 0 END) * 1.0
      / COUNT(*),
    3
  ) AS pickup_rate
FROM calls
GROUP BY media
ORDER BY media;

Row flow

How calls become pickup rates by media The query groups eight call rows into video and voice, counts all rows as attempts, counts only Y rows as pickups, and divides pickups by attempts. Group → count all attempts → count Y pickups → divide 8 call rows video 4 voice 4 Conditional count video 3 Y voice 2 Y Rates video .750 voice .500 COUNT(*) supplies the denominator; the CASE expression supplies the numerator.
The numerator and denominator are visible before any division happens.

Output table

mediaattemptspickupspickup_rate
video430.750
voice420.500

A common wrong answer uses COUNT(pickup). That counts every non-NULL value, including both Y and N, so it would report four pickups for each media type. PostgreSQL also supports COUNT(*) FILTER (WHERE pickup = 'Y'); the complete SUM(CASE ...) query above is the more portable form.

Change one pickup to NULL and decide whether that row is still an attempt. If it is, COUNT(*) should stay in the denominator. See SQL COUNT and its common traps for more counting edge cases.

Tier 2: joins and result grain

The next operation asks for ride activity by city while keeping registered users who have never taken a ride.

Input tables

users

user_idcity
1San Francisco
2San Francisco
3Phoenix
4Phoenix

rides

ride_iduser_idride_tsrating
1012025-07-01 09:004.0
1112025-07-09 18:005.0
1222025-07-14 12:004.5
1332025-07-03 08:003.0
1432025-07-28 20:005.0

User 4 has no ride. The output grain is one row per city, not one row per user or ride.

Operation query

SELECT
  u.city,
  COUNT(r.ride_id) AS rides,
  COUNT(DISTINCT u.user_id) AS registered_users,
  COUNT(DISTINCT r.user_id) AS riders,
  ROUND(AVG(r.rating), 2) AS average_rating
FROM users AS u
LEFT JOIN rides AS r
  ON r.user_id = u.user_id
GROUP BY u.city
ORDER BY u.city;

Row flow

How a left join preserves registered users before city aggregation The left join creates three San Francisco ride rows, two Phoenix ride rows, and one Phoenix placeholder row for user four. Counting ride id ignores the placeholder while counting distinct users includes user four. Preserve users → match rides → group by city → count at each grain 4 users SF 1, 2 Phoenix 3, 4 Joined rows SF rides 3 Phoenix rides 2 user 4 NULL placeholder stays City metrics SF 3 rides SF 2 users PHX 2 rides PHX 2 users COUNT(r.ride_id) ignores the placeholder; COUNT(DISTINCT u.user_id) includes user 4.
The same joined rows support ride-level and user-level metrics when each count names the correct key.

Output table

cityridesregistered_usersridersaverage_rating
Phoenix2214.00
San Francisco3224.50

COUNT(*) would report one ride for user 4's placeholder row. Counting the non-nullable right-side key, r.ride_id, correctly contributes zero rides. Review SQL joins with runnable examples if that row-preservation step is not yet predictable.

Change the operation: filter for recent rides

Suppose recent means July 10 or later. Compare where the date predicate appears:

-- Keep all registered users; match only recent rides.
SELECT u.user_id, COUNT(r.ride_id) AS recent_rides
FROM users AS u
LEFT JOIN rides AS r
  ON r.user_id = u.user_id
 AND r.ride_ts >= TIMESTAMP '2025-07-10 00:00'
GROUP BY u.user_id
ORDER BY u.user_id;
-- Keep only users who have a recent ride.
SELECT u.user_id, COUNT(r.ride_id) AS recent_rides
FROM users AS u
LEFT JOIN rides AS r
  ON r.user_id = u.user_id
WHERE r.ride_ts >= TIMESTAMP '2025-07-10 00:00'
GROUP BY u.user_id
ORDER BY u.user_id;
How ON and WHERE date filters change left join output With the date filter in ON, all four users remain and users one and four have zero recent rides. With the date filter in WHERE, only users two and three remain. Same input → different row-survival rule → different output population Filter in ON Preserve all users, then count matches user 1 0 user 2 1 user 3 1 user 4 0 Filter in WHERE Remove rows without a recent match user 2 1 user 3 1 Choose the placement from the population promised by the metric.
The filter in ON reports zero activity; the filter in WHERE removes inactive users.

Output with the filter in ON

user_idrecent_rides
10
21
31
40

Output with the filter in WHERE

user_idrecent_rides
21
31

Neither placement is universally correct. The metric definition determines which population belongs in the output.

Tier 3: windows and observation periods

Tier 3 adds sequencing and time. Two mistakes become especially costly: choosing an arbitrary row from a tie and treating a future cohort period as measured zero.

Latest ride per user

Use the rides input table from Tier 2. The requested output grain is one latest ride per user who has taken a ride.

WITH ranked AS (
  SELECT
    r.*,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY ride_ts DESC, ride_id DESC
    ) AS rn
  FROM rides AS r
)
SELECT user_id, ride_id, ride_ts, rating
FROM ranked
WHERE rn = 1
ORDER BY user_id;
How row number selects the latest ride per user The query partitions rides by user, orders each partition by timestamp and ride id descending, numbers the rows, then keeps row number one. Partition by user → sort newest first → number rows → keep rn = 1 5 ride rows user 1 two user 2 one user 3 two Rank in each user ride 11 rn 1 ride 12 rn 1 ride 14 rn 1 3 selected user 1 11 user 2 12 user 3 14 ride_id DESC breaks timestamp ties so the selection is deterministic.
The filter runs after each user's rides receive a stable order.
user_idride_idride_tsrating
1112025-07-09 18:005.0
2122025-07-14 12:004.5
3142025-07-28 20:005.0

PostgreSQL's DISTINCT ON can solve the same task. Aggregate-and-join and correlated-subquery forms can also work. The key interview points are output grain and deterministic tie behavior. The window functions interview guide compares ranking functions and frames.

Cohort retention with a declared data horizon

Assume event data is complete through March 31, 2025. We represent that with an exclusive boundary of April 1.

app_users

user_idsignup_ts
12025-01-05 10:00
22025-01-18 14:00
32025-02-03 09:00
42025-02-20 16:00

events

user_idevent_tsnote
12025-01-10 11:00after signup
12025-02-07 12:00after signup
12025-03-05 08:00after signup
22025-01-20 15:00after signup
22025-03-03 18:00after signup
32025-02-01 08:00before signup, exclude
32025-02-04 10:00after signup
32025-03-08 10:00after signup
42025-02-22 17:00after signup

The January cohort has three fully observed calendar months: January, February, and March. The February cohort has two: February and March. April is not zero retention for the February cohort; it is outside the observation window.

WITH params AS (
  SELECT DATE '2025-04-01' AS data_through_exclusive
),
cohort_members AS (
  SELECT
    user_id,
    signup_ts,
    DATE_TRUNC('month', signup_ts)::date AS cohort_month
  FROM app_users
),
cohort_sizes AS (
  SELECT cohort_month, COUNT(*) AS cohort_size
  FROM cohort_members
  GROUP BY cohort_month
),
periods AS (
  SELECT
    cs.cohort_month,
    cs.cohort_size,
    gs.month_n,
    (cs.cohort_month + gs.month_n * INTERVAL '1 month')::date
      AS activity_month
  FROM cohort_sizes AS cs
  CROSS JOIN params AS p
  CROSS JOIN LATERAL GENERATE_SERIES(
    0,
    (
      EXTRACT(YEAR FROM AGE(
        p.data_through_exclusive - INTERVAL '1 month',
        cs.cohort_month
      )) * 12
      + EXTRACT(MONTH FROM AGE(
        p.data_through_exclusive - INTERVAL '1 month',
        cs.cohort_month
      ))
    )::int
  ) AS gs(month_n)
),
activity AS (
  SELECT
    cm.cohort_month,
    DATE_TRUNC('month', e.event_ts)::date AS activity_month,
    COUNT(DISTINCT cm.user_id) AS active_users
  FROM cohort_members AS cm
  JOIN events AS e
    ON e.user_id = cm.user_id
   AND e.event_ts >= cm.signup_ts
  CROSS JOIN params AS p
  WHERE e.event_ts < p.data_through_exclusive
  GROUP BY cm.cohort_month, DATE_TRUNC('month', e.event_ts)::date
)
SELECT
  p.cohort_month,
  p.month_n,
  p.cohort_size,
  COALESCE(a.active_users, 0) AS active_users,
  ROUND(COALESCE(a.active_users, 0) * 100.0 / p.cohort_size, 1)
    AS retention_pct
FROM periods AS p
LEFT JOIN activity AS a
  ON a.cohort_month = p.cohort_month
 AND a.activity_month = p.activity_month
ORDER BY p.cohort_month, p.month_n;
How a maturity-aware cohort query creates retention rows Users are assigned to January or February cohorts, fully observed months are generated through March, pre-signup events are removed, distinct active users are counted, and missing observed activity becomes zero. Assign cohort → generate mature months → filter events → count active users Cohorts Jan 1, 2 Feb 3, 4 Mature periods Jan m0 m1 m2 Feb m0 m1 No future April row Retention rows Jan 100 50 100 Feb 100 50 pre-signup removed Only a generated, fully observed period may safely turn missing activity into zero.
The date grid distinguishes observed zero activity from a period that has not happened yet.
cohort_monthmonth_ncohort_sizeactive_usersretention_pct
2025-01-01022100.0
2025-01-0112150.0
2025-01-01222100.0
2025-02-01022100.0
2025-02-0112150.0

The event join requires e.event_ts >= cm.signup_ts, so user 3's February 1 event does not count before the February 3 signup. In a real analysis, also document whether the denominator is the original cohort, eligible users, or users observable in each period.

Capstone: audit a broken DAU query

The requested metric is registered daily active users on August 15, 2025.

Input tables

site_users

user_id
1
2
3

events

user_idevent_ts
12025-08-15 09:00
12025-08-15 12:00
22025-08-15 13:00
92025-08-15 15:00
32025-08-16 10:00

User 9 is an orphan event identity and is not a registered user.

Broken and corrected operations

-- Broken: groups at user grain instead of returning one site-wide row.
SELECT
  u.user_id,
  COUNT(DISTINCT e.user_id) AS dau
FROM site_users AS u
LEFT JOIN events AS e
  ON e.user_id = u.user_id
WHERE DATE(e.event_ts) = DATE '2025-08-15'
GROUP BY u.user_id
ORDER BY u.user_id;
-- Correct for registered DAU.
SELECT COUNT(DISTINCT e.user_id) AS registered_dau
FROM events AS e
JOIN site_users AS u
  ON u.user_id = e.user_id
WHERE e.event_ts >= TIMESTAMP '2025-08-15 00:00'
  AND e.event_ts <  TIMESTAMP '2025-08-16 00:00';

Row flow

Why the broken DAU query returns per-user rows and the corrected query returns one site-wide count The broken query filters events, drops inactive users, groups by user, and returns two rows of one. The corrected query joins events to registered users, applies a half-open date range, counts distinct users, and returns one row with two. Same input → different grain → different shape Broken query 1. DATE filter removes nonmatches 2. GROUP BY creates one row per user 3. Each group counts one distinct id user 1 → 1 user 2 → 1 Correct query 1. Join keeps registered identities 2. Half-open range keeps August 15 3. No GROUP BY means one site-wide row registered_dau → 2 Ask what one output row represents before debugging syntax.
The corrected operation matches both the registered-user population and the requested site-wide grain.

Output tables

Broken output

user_iddau
11
21

Correct output

registered_dau
2

If the business intentionally counted every event identity, including identities missing from site_users, the answer would be 3 and the user table should not define the population. That is a metric-definition decision, not a syntax decision.

The half-open timestamp range also allows PostgreSQL to consider an ordinary index on the raw timestamp or prune time-based partitions. Wrapping the column in DATE(...) commonly prevents those optimizations, although an expression index can support the wrapped form. The SQL order of operations guide explains why the original LEFT JOIN and WHERE combination removes unmatched rows.

For a focused 45-minute practice session:

  • 5 minutes: choose one tier and restate the prompt.
  • 15 minutes: solve one operation without notes.
  • 10 minutes: trace the row flow and compare the output.
  • 10 minutes: change an edge-case row and explain the effect.
  • 5 minutes: record the mistake to revisit.

Use PracHub's SQL interview question bank to choose a prompt, but keep each session focused on one failure mode.

FAQ

Should I use COUNT(*) or COUNT(column)?

Use COUNT(*) when every result row belongs in the count. Use COUNT(column) when rows with NULL in that column should not count. After a LEFT JOIN, counting a non-nullable key from the right table counts matches without counting the placeholder row.

Does a right-table filter always belong in ON for a LEFT JOIN?

No. Put it in ON when unmatched left-side rows must remain in the population. Put it in WHERE when the requested population requires a matching right-side row. Define the population first.

Is a missing cohort row the same as zero retention?

Not until the period is known to be complete and the query has generated the expected cohort-period row. A missing row can mean zero activity, an incomplete future period, or a bug in the date grid. Declare the data horizon before applying COALESCE(..., 0).

Why add a second column to a window ORDER BY?

The first sort key can tie. A stable unique key such as ride_id makes the selected row deterministic. Without it, two executions are allowed to choose different tied rows.

When should I move to the next tier?

Move up when you can predict the current tier's output, explain its edge cases, and reproduce the operation later without copying. If you only discover the metric definition after seeing the result, stay at that tier and vary the input table.

Is this guide only for data scientists?

Data scientists are the primary audience because the examples emphasize metric definitions, cohorts, and analytical judgment. The same core skills apply to data analysts and data engineers, with different emphasis in later interview rounds.


Comments (0)