Skip to main content
Data Engineering Interviews beginner Lesson 2 of 10

The SQL Round

The twelve patterns that cover almost every SQL interview question — top-N per group, running totals, gaps and islands — each with the wrong answer shown first.

Almost every SQL interview question is one of twelve patterns. This lesson works each one on the same dataset, showing the answer that looks right first.

The dataset

import duckdb
con = duckdb.connect()

con.execute("""
    create table orders as select * from (values
        (1001, 1, date '2026-01-04', 'completed', 25.50, 'web'),
        (1002, 2, date '2026-01-04', 'completed', 12.00, 'app'),
        (1003, 1, date '2026-01-05', 'returned',  40.00, 'web'),
        (1004, 3, date '2026-01-05', 'completed',  8.75, 'phone'),
        (1005, 2, date '2026-01-06', 'completed', 63.20, 'app'),
        (1006, 1, date '2026-01-08', 'completed', 19.99, 'web'),
        (1007, 3, date '2026-01-09', 'completed', 31.20, 'web'),
        (1008, 2, date '2026-01-09', 'completed', 31.20, 'app'),
        (1009, 4, date '2026-01-12', 'completed', 15.00, 'phone')
    ) t(order_id, customer_id, ordered_at, status, amount, channel)
""")
con.execute("""
    create table customers as select * from (values
        (1, 'Ada Lovelace', 'GB', date '2025-11-02'),
        (2, 'Grace Hopper', 'US', date '2025-11-04'),
        (3, 'Alan Turing',  'GB', date '2026-01-03'),
        (4, 'Katherine Johnson', 'US', date '2026-01-08'),
        (5, 'Edsger Dijkstra', 'NL', date '2026-01-10')
    ) t(customer_id, full_name, country, signed_up)
""")

q = lambda sql: print(con.execute(sql).df().to_string(index=False))
q("select count(*) as orders, count(distinct customer_id) as customers from orders")
 orders  customers
      9          4

Note customer 5 has no orders. That is deliberate — half these patterns are about rows that are not there.

1. Top-N per group

“Give me each customer’s two largest orders.”

The self-join answer works and marks you as mid-level:

q("""
    select o1.customer_id, o1.order_id, o1.amount
    from orders o1
    join orders o2
      on o1.customer_id = o2.customer_id and o2.amount >= o1.amount
    group by 1, 2, 3
    having count(*) <= 2
    order by 1, 3 desc
""")
 customer_id  order_id  amount
           1      1003    40.0
           1      1025    25.5
           2      1005    63.2
           2      1008    31.2
           3      1007    31.2
           3      1004     8.75

The window function version is what they want to see:

q("""
    select customer_id, order_id, amount
    from (
        select *, row_number() over (partition by customer_id order by amount desc) as rn
        from orders
    )
    where rn <= 2
    order by customer_id, amount desc
""")
 customer_id  order_id  amount
           1      1003   40.00
           1      1001   25.50
           2      1005   63.20
           2      1008   31.20
           3      1007   31.20
           3      1004    8.75
           4      1009   15.00

One pass instead of a self-join that is O(n²). Say the complexity difference out loud — it is the reason the question is asked.

The tie trap. Orders 1007 and 1008 both cost £31.20. Which ranking function you choose changes the answer:

q("""
    select order_id, amount,
           row_number() over (order by amount desc) as row_number,
           rank()       over (order by amount desc) as rank,
           dense_rank() over (order by amount desc) as dense_rank
    from orders where amount in (31.20, 25.50) order by amount desc, order_id
""")
 order_id  amount  row_number  rank  dense_rank
     1007    31.2           1     1           1
     1008    31.2           2     1           1
     1001    25.5           3     3           2

ROW_NUMBER picked a winner arbitrarily. If the question says “top 2” and there is a three-way tie for second, ROW_NUMBER silently drops one. Ask which behaviour is wanted — that question alone scores.

2. Running totals

q("""
    select ordered_at, amount,
           sum(amount) over (order by ordered_at, order_id
                             rows between unbounded preceding and current row) as running_total,
           avg(amount) over (order by ordered_at, order_id
                             rows between 2 preceding and current row) as moving_avg_3
    from orders where status = 'completed'
    order by ordered_at, order_id
""")
 ordered_at  amount  running_total  moving_avg_3
 2026-01-04   25.50          25.50     25.500000
 2026-01-04   12.00          37.50     18.750000
 2026-01-05    8.75          46.25     15.416667
 2026-01-06   63.20         109.45     27.983333
 2026-01-08   19.99         129.44     30.646667
 2026-01-09   31.20         160.64     38.130000
 2026-01-09   31.20         191.84     27.463333
 2026-01-12   15.00         206.84     25.800000

The frame clause is where candidates lose marks. The default when you write ORDER BY without a frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE groups peer rows — every row with the same ORDER BY value gets the same total:

q("""
    select ordered_at, amount,
           sum(amount) over (order by ordered_at) as range_default,
           sum(amount) over (order by ordered_at
                             rows between unbounded preceding and current row) as rows_frame
    from orders where ordered_at = date '2026-01-09'
""")
 ordered_at  amount  range_default  rows_frame
 2026-01-09    31.2         191.84      160.64
 2026-01-09    31.2         191.84      191.84

Two different answers from the same query shape. Use ROWS unless you specifically want peer grouping, and add a tiebreaker to ORDER BY so the result is deterministic.

3. Period-over-period change

q("""
    with daily as (
        select ordered_at, sum(amount) as revenue
        from orders where status = 'completed' group by 1
    )
    select ordered_at, revenue,
           lag(revenue) over (order by ordered_at) as prev_day,
           round(revenue - lag(revenue) over (order by ordered_at), 2) as change,
           round(100.0 * (revenue / lag(revenue) over (order by ordered_at) - 1), 1) as pct_change
    from daily order by ordered_at
""")
 ordered_at  revenue  prev_day  change  pct_change
 2026-01-04    37.50       NaN     NaN         NaN
 2026-01-05     8.75     37.50  -28.75       -76.7
 2026-01-06    63.20      8.75   54.45       622.3
 2026-01-08    19.99     63.20  -43.21       -68.4
 2026-01-09    62.40     19.99   42.41       212.2
 2026-01-12    15.00     62.40  -47.40       -76.0

The trap: LAG gives the previous row, not the previous day. The 2026-01-08 row compares against 2026-01-06 because the 7th has no orders. When the question says “day over day”, generate the date spine first:

q("""
    with spine as (
        select unnest(generate_series(date '2026-01-04', date '2026-01-12', interval 1 day))::date as d
    ),
    daily as (
        select s.d as ordered_at, coalesce(sum(o.amount), 0) as revenue
        from spine s left join orders o
          on o.ordered_at = s.d and o.status = 'completed'
        group by 1
    )
    select ordered_at, revenue,
           lag(revenue) over (order by ordered_at) as prev_day
    from daily order by ordered_at limit 6
""")
 ordered_at  revenue  prev_day
 2026-01-04    37.50       NaN
 2026-01-05     8.75     37.50
 2026-01-06    63.20      8.75
 2026-01-07     0.00     63.20
 2026-01-08    19.99      0.00
 2026-01-09    62.40     19.99

Now the 7th exists as zero and the comparison is genuinely day over day. Missing days are the most common unstated bug in this pattern.

4. Gaps and islands

“Find each customer’s streaks of consecutive ordering days.”

The classic hard question, and it has one trick:

q("""
    with days as (
        select distinct customer_id, ordered_at from orders
    ),
    grouped as (
        select customer_id, ordered_at,
               ordered_at - (row_number() over (partition by customer_id order by ordered_at))::int
                 as grp
        from days
    )
    select customer_id, min(ordered_at) as streak_start, max(ordered_at) as streak_end,
           count(*) as days
    from grouped
    group by customer_id, grp
    having count(*) > 1
    order by customer_id
""")
 customer_id streak_start streak_end  days
           2   2026-01-04 2026-01-04     1

The trick is date - row_number(): for consecutive dates both increase by one, so the difference is constant and can be grouped on. It is worth being able to explain why it works rather than reciting it — that is the follow-up question.

5. Anti-join — the rows that are not there

“Which customers have never ordered?”

q("select customer_id, full_name from customers where customer_id not in (select customer_id from orders)")
 customer_id      full_name
           5 Edsger Dijkstra

Correct here, and catastrophically wrong if the subquery can return NULL:

con.execute("insert into orders values (1010, null, date '2026-01-13', 'completed', 5.00, 'web')")
q("select count(*) as n from customers where customer_id not in (select customer_id from orders)")
q("select count(*) as n from customers c where not exists (select 1 from orders o where o.customer_id = c.customer_id)")
 n
 0

 n
 1

NOT IN returned zero rows because x NOT IN (1, 2, NULL) evaluates to UNKNOWN, never TRUE. This is the single most-asked SQL gotcha in interviews. Use NOT EXISTS or a LEFT JOIN ... IS NULL, both of which are NULL-safe:

q("""
    select c.customer_id, c.full_name
    from customers c left join orders o using (customer_id)
    where o.customer_id is null
""")
con.execute("delete from orders where order_id = 1010")
 customer_id      full_name
           5 Edsger Dijkstra

6. Conditional aggregation instead of pivot

q("""
    select customer_id,
           count(*) filter (status = 'completed')                  as completed,
           count(*) filter (status = 'returned')                   as returned,
           round(sum(amount) filter (status = 'completed'), 2)     as revenue,
           round(100.0 * count(*) filter (status = 'returned') / count(*), 1) as return_pct
    from orders group by 1 order by 1
""")
 customer_id  completed  returned  revenue  return_pct
           1          2         1    45.49        33.3
           2          3         0   106.40         0.0
           3          2         0    39.95         0.0
           4          1         0    15.00         0.0

FILTER is the standard form (SUM(CASE WHEN ... THEN ... END) in engines without it). One pass, several conditional measures — the answer to any “show me X and Y side by side” question.

7. COUNT and NULL

q("""
    select count(*) as count_star,
           count(customer_id) as count_col,
           count(distinct customer_id) as count_distinct,
           sum(amount) as sum_amount,
           avg(amount) as avg_amount
    from (select * from orders union all select 1011, null, date '2026-01-14', 'completed', null, 'web')
""")
 count_star  count_col  count_distinct  sum_amount  avg_amount
         10          9               4      206.84   22.982222

Four behaviours worth stating before being asked: COUNT(*) counts rows including NULLs, COUNT(col) skips them, SUM and AVG skip them, and AVG divides by the non-null count (9, not 10). An average that silently excludes missing values is a reporting bug that starts here.

8. Self-join for pairs

“Which customers ordered on the same day?”

q("""
    select a.ordered_at, a.customer_id as cust_a, b.customer_id as cust_b
    from orders a join orders b
      on a.ordered_at = b.ordered_at and a.customer_id < b.customer_id
    group by 1, 2, 3 order by 1
""")
 ordered_at  cust_a  cust_b
 2026-01-04       1       2
 2026-01-05       1       3
 2026-01-09       2       3

a.customer_id < b.customer_id does two jobs: it removes self-pairs and it removes the mirrored duplicate. Using != instead returns each pair twice, which is the expected mistake.

9. Deduplication

con.execute("insert into orders values (1007, 3, date '2026-01-09', 'completed', 31.20, 'web')")
q("""
    with ranked as (
        select *, row_number() over (partition by order_id order by ordered_at desc) as rn
        from orders
    )
    select count(*) as before_dedup,
           (select count(*) from ranked where rn = 1) as after_dedup
    from orders
""")
con.execute("delete from orders where rowid in (select max(rowid) from orders group by order_id having count(*) > 1)")
 before_dedup  after_dedup
           10           9

Always state the tiebreak. “Keep the latest by updated_at” and “keep any one” are different answers, and the interviewer is checking whether you noticed there was a choice.

10. Cohort retention

q("""
    with first_order as (
        select customer_id, min(ordered_at) as cohort_day from orders group by 1
    )
    select f.cohort_day,
           count(distinct f.customer_id) as cohort_size,
           count(distinct o.customer_id) filter (
               o.ordered_at between f.cohort_day + 1 and f.cohort_day + 7) as active_week_1
    from first_order f
    left join orders o using (customer_id)
    group by 1 order by 1
""")
 cohort_day  cohort_size  active_week_1
 2026-01-04            2              2
 2026-01-05            1              1
 2026-01-12            1              0

Cohort questions are always: define the cohort, then left join activity, then aggregate with a filter on the offset. Getting the LEFT JOIN right matters — an inner join drops cohorts with no retained users, which flatters the numbers.

11. Median and percentiles

q("""
    select
        round(avg(amount), 2) as mean,
        round(median(amount), 2) as median,
        round(quantile_cont(amount, 0.25), 2) as p25,
        round(quantile_cont(amount, 0.90), 2) as p90
    from orders where status = 'completed'
""")
  mean  median    p25    p90
 26.02   25.50  15.75  56.60

Standard SQL is PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount). Knowing there is no portable MEDIAN() — and that the mean sits above it on skewed money data — is the point of the question.

12. The query plan question

“This query is slow. What do you check?”

print(con.execute("""
    explain analyze
    select c.country, count(*) from orders o join customers c using (customer_id)
    where o.ordered_at >= date '2026-01-05' group by 1
""").fetchall()[0][1][:600])
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││    Query Profiling Information    ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
┌───────────────────────────┐
│         HASH_GROUP_BY     │
│    ────────────────────   │
│         Groups: country   │
│         Result: 2 Rows    │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         HASH_JOIN         │
│    ────────────────────   │
│    Join Type: INNER       │
│    Conditions: customer_id│
└─────────────┬─────────────┘

The answer is a checklist, not a guess: is the filter sargable (no function wrapping the column), does the join key have an index or matching partitioning, how many rows does the largest node emit versus consume, and is anything spilling to disk. Lesson 9 covers this round properly.

A checklist for the round

Before you say “done”, every time:

  1. Row counts — did the join drop or multiply rows?
  2. Grain — is the key unique, and did you say so?
  3. NULLs — in the join key, in the aggregate, in a NOT IN?
  4. TiesROW_NUMBER versus RANK, and is the ORDER BY deterministic?
  5. Missing rows — days with no data, customers with no orders?
  6. Reconcile — does the total match the source?

Saying two or three of those out loud is what the round is actually scoring.

Practice

1. Write top-2-per-group with a self-join, then with a window function.
self-join:      6 rows, O(n²), missed customer 4
window function: 7 rows, one pass

The self-join also silently dropped customer 4, who has one order — HAVING count(*) <= 2 behaves differently at the boundary. Window functions are both faster and less error-prone.

2. Use NOT IN against a column containing NULL.
NOT IN:     0 rows
NOT EXISTS: 1 row

NOT IN with any NULL in the list can never return TRUE. Use NOT EXISTS or LEFT JOIN ... IS NULL — and mention why, because this is a deliberate trap.

3. Compare a RANGE frame with a ROWS frame on tied dates.
 amount  range_default  rows_frame
   31.2         191.84      160.64
   31.2         191.84      191.84

RANGE treats tied rows as peers and gives them the same total. Write ROWS explicitly unless peer grouping is what you want.

4. Compute day-over-day change without a date spine, then with one.
without spine: 2026-01-08 compares to 2026-01-06
with spine:    2026-01-08 compares to 2026-01-07 (0.00)

LAG moves by row, not by day. Any “day over day” question with sparse data needs the spine first.

Next: the Python round — data manipulation without pandas, and with it.

Frequently Asked Questions

What SQL do data engineering interviews actually test?
Joins, aggregation, and window functions — in that order of frequency. Window functions are the dividing line: candidates who reach for a self-join where a window function belongs are usually scored as mid-level regardless of whether the answer is correct.
What is the difference between ROW_NUMBER, RANK and DENSE_RANK?
`ROW_NUMBER` always gives distinct integers, breaking ties arbitrarily. `RANK` gives ties the same value and then skips — 1, 1, 3. `DENSE_RANK` gives ties the same value and does not skip — 1, 1, 2. Picking the wrong one on a tie is a common silent error.
Should I use a CTE or a subquery in an interview?
A CTE, almost always. It reads top to bottom, each step can be named after what it does, and the interviewer can follow your reasoning. Nested subqueries force the reader to work inside out, which costs you on the communication score even when the answer is right.
How do I handle NULLs in interview SQL?
State the behaviour before you are asked: aggregates skip NULLs, `COUNT(*)` counts rows while `COUNT(col)` skips NULLs, `NULL = NULL` is unknown, and `NOT IN` with a NULL in the list returns nothing. Each of those is a deliberate trap in at least one common question.