Interview Prep/SQL & Coding
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:
| Function | Values on a tie (10, 10, 9) | Use when |
|---|---|---|
row_number() | 1, 2, 3 | You need exactly N rows, ties broken arbitrarily |
rank() | 1, 1, 3 | Ties share a rank and the next rank skips |
dense_rank() | 1, 1, 2 | Ties 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
| Trap | What happens | How to avoid it |
|---|---|---|
| Fan-out join | Joining a fact to a dimension with duplicate keys multiplies rows and inflates every SUM | Check uniqueness of the join key first; dedupe the dimension |
| Filter in WHERE on a LEFT JOIN | where d.col = 'x' silently turns a LEFT JOIN into an INNER JOIN | Put the condition in the ON clause instead |
| NULLs in NOT IN | where id not in (select id ...) returns zero rows if the subquery has any NULL | Use NOT EXISTS or add where id is not null |
| Joining on a range with no bounds | Cross-join blow-up on billions of rows | Always 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 cthen filterc > 1to see full rows - In practice, encode this as a dbt
uniquetest 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