Interview Prep/SQL & Coding

Intermediate12 min read

SQL Interview Patterns You Must Know

The handful of SQL patterns that show up in almost every data engineering interview — window functions, deduplication, gaps and islands, and running totals — each with a worked example.

SQL rounds are not about obscure syntax. Interviewers reuse about six patterns. If you can recognise the pattern behind the question, you can write the query. This article walks through each one with a small, concrete example.

Tip — Say the grain out loud

Before writing anything, say: 'One row in my result = one ___.' Interviewers love this. It shows you think about data, not just syntax, and it stops most wrong answers before they happen.

Pattern 1 — Rank and pick the top N per group

The question sounds like: *'Find the top 3 cell sites by dropped calls in each region.'* The giveaway words are per or each. That means a window function partitioned by that group.

select region, cell_site_id, dropped_calls
from (
  select
    region,
    cell_site_id,
    dropped_calls,
    row_number() over (
      partition by region
      order by dropped_calls desc
    ) as rn
  from network_kpi_daily
  where event_date = '2026-03-01'
) t
where rn <= 3;

Know the difference between the three ranking functions — this is a very common follow-up:

FunctionValues on a tie (10, 10, 9)Use when
row_number()1, 2, 3You need exactly N rows, ties broken arbitrarily
rank()1, 1, 3Ties share a rank and the next rank skips
dense_rank()1, 1, 2Ties share a rank and the next rank does not skip

Pattern 2 — Deduplicate, keeping the latest row

Very common in real pipelines: a source sends the same record several times, and you want only the newest version of each key. This is the same window trick with row_number().

-- Keep the most recent record per subscriber
select * except(rn)
from (
  select
    *,
    row_number() over (
      partition by subscriber_id
      order by updated_at desc, ingestion_ts desc
    ) as rn
  from raw.subscriber_events
) t
where rn = 1;

Watch out — Add a tie-breaker

If two rows share the same updated_at, row_number() picks one at random — and your pipeline becomes non-deterministic. Always add a second sort key (an ingestion timestamp or a file name). Mentioning this unprompted scores real points.

Pattern 3 — Running totals and moving averages

Any question with *'cumulative'*, *'running'*, or *'7-day average'* is a window function with a frame clause.

select
  event_date,
  cell_site_id,
  data_volume_gb,

  -- running total from the beginning of time
  sum(data_volume_gb) over (
    partition by cell_site_id
    order by event_date
    rows between unbounded preceding and current row
  ) as cumulative_gb,

  -- 7-day moving average (today + 6 previous days)
  avg(data_volume_gb) over (
    partition by cell_site_id
    order by event_date
    rows between 6 preceding and current row
  ) as avg_7d

from network_kpi_daily;

Note — ROWS vs RANGE

ROWS counts physical rows. RANGE counts logical values, so duplicate dates get grouped together. If a site has two rows for the same date, ROWS 6 PRECEDING and RANGE 6 PRECEDING give different answers. Interviewers ask this to separate people who memorised syntax from people who understand it.

Pattern 4 — Comparing a row to the previous one

Questions like *'find days where traffic dropped more than 50% versus the day before'* use lag() (previous row) or lead() (next row).

with daily as (
  select
    cell_site_id,
    event_date,
    data_volume_gb,
    lag(data_volume_gb) over (
      partition by cell_site_id order by event_date
    ) as prev_day_gb
  from network_kpi_daily
)
select *
from daily
where prev_day_gb > 0
  and data_volume_gb < prev_day_gb * 0.5;

Pattern 5 — Gaps and islands (consecutive runs)

This is the classic hard one: *'find periods where a cell site was down for 3 or more consecutive days.'* The trick is that for a consecutive run, date - row_number() stays constant. Group by that constant.

with flagged as (
  select
    cell_site_id,
    event_date,
    -- for consecutive dates, this expression is the SAME value
    date_sub(event_date, interval
      row_number() over (partition by cell_site_id order by event_date) day
    ) as grp
  from outage_days
)
select
  cell_site_id,
  min(event_date) as outage_start,
  max(event_date) as outage_end,
  count(*)        as consecutive_days
from flagged
group by cell_site_id, grp
having count(*) >= 3;

Tip — How to explain it

Say: 'Dates go up by 1 each day and row_number also goes up by 1 each day, so their difference is constant inside a run and jumps whenever there is a gap. That constant becomes my group key.' One clear sentence beats a perfect query with no explanation.

Pattern 6 — Joins, and the traps inside them

TrapWhat happensHow to avoid it
Fan-out joinJoining a fact to a dimension with duplicate keys multiplies rows and inflates every SUMCheck uniqueness of the join key first; dedupe the dimension
Filter in WHERE on a LEFT JOINwhere d.col = 'x' silently turns a LEFT JOIN into an INNER JOINPut the condition in the ON clause instead
NULLs in NOT INwhere id not in (select id ...) returns zero rows if the subquery has any NULLUse NOT EXISTS or add where id is not null
Joining on a range with no boundsCross-join blow-up on billions of rowsAlways bound with a partition/date filter first

Practice questions

QWhat is the difference between WHERE and HAVING, and can you use a window function in either?show

WHERE filters rows before grouping. HAVING filters after grouping, so it can use aggregates like count(*). You cannot use a window function in either, because windows are evaluated after WHERE/GROUP BY/HAVING — you must wrap the query in a subquery or CTE and filter outside, which is exactly why the top-N pattern needs a subquery.

  • FROM / JOIN → WHERE → GROUP BY → HAVING → window functions → SELECT → DISTINCT → ORDER BY → LIMIT
  • This order explains why you cannot reference a SELECT alias in WHERE, but you can in ORDER BY

Likely follow-up: So what is the actual logical order of execution in SQL?

QHow would you find duplicate rows in a table?show

Group by the columns that should be unique and keep groups with more than one row. If you need the offending rows themselves, use a window count instead.

  • select subscriber_id, count(*) from t group by subscriber_id having count(*) > 1
  • Or: count(*) over (partition by subscriber_id) as c then filter c > 1 to see full rows
  • In practice, encode this as a dbt unique test so it fails the pipeline instead of a person finding it
QA query on a huge table is slow. Walk me through how you would fix it.show

Read the query plan first, then reduce the amount of data touched before optimising anything clever.

  • Prune early — filter on the partition column so you scan one day, not five years
  • Select fewer columns — columnar stores charge by column; select * is the most common cost bug
  • Check the join — is the big table being broadcast, or is a small table forcing a shuffle?
  • Look for skew — one key with 80% of the rows makes one worker do all the work
  • Pre-aggregate — if the same heavy aggregate is queried repeatedly, materialise it as a table

Likely follow-up: How do you know the filter actually pruned partitions?

QWrite a query to compute month-over-month growth in active subscribers.show

Aggregate to the month grain first, then use lag() to reach the previous month.

  • Aggregate: date_trunc(event_date, month) as month, count(distinct subscriber_id) as actives
  • Compare: lag(actives) over (order by month) as prev
  • Growth: safe_divide(actives - prev, prev) — use a null-safe divide so month one does not error