← All articles
16 min read

SQL Window Functions Interview Questions with Examples (2026)

Window functions show up constantly in data and backend SQL rounds. Here are ten classic problems, each solved with the query and a step-by-step result table.

SQL window functions interview questions test whether you can compute per-row values, such as ranks, running totals, and previous-row comparisons, across a group of related rows without collapsing them. Almost every one reduces to a few building blocks: PARTITION BY, ORDER BY, a frame, and the right function (ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, or an aggregate). This guide works through ten classic problems with the query and the result table for each, so you can see exactly what every step produces.

Key Takeaways

  • A window function is any function followed by OVER (...). It adds a column to each row instead of collapsing rows like GROUP BY.
  • ROW_NUMBER, RANK, and DENSE_RANK differ only on ties: 1,2,3,4, 1,2,2,4, and 1,2,2,3. Say which one the question needs before you write it.
  • With ORDER BY and no explicit frame, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Duplicates in the sort key break naive running totals.
  • You cannot filter on a window function in WHERE. Wrap it in a CTE, or use QUALIFY on engines that support it.
  • Gaps and islands reduces to one trick: value - ROW_NUMBER() is constant inside a consecutive run.
  • Sessionization and retention are both "LAG, flag, then running sum or MIN over partition" problems.

What Is a SQL Window Function?

A window function is a calculation over a set of rows related to the current row, returned as a new column on every row. The set of rows is the window. Unlike GROUP BY, the input rows stay in the output, so you can show an employee's salary next to their department's average in the same row.

The full syntax looks like this:

function_name(args) OVER (
  PARTITION BY partition_cols
  ORDER BY sort_cols
  ROWS | RANGE BETWEEN frame_start AND frame_end
)

Each part answers one question:

ClauseQuestion it answersIf omitted
PARTITION BYWhich rows belong to the same group?The whole result set is one partition
ORDER BYIn what order are rows inside a partition?No order; ranking and LAG/LEAD are not meaningful
Frame (ROWS/RANGE)Which neighbors count for an aggregate?With ORDER BY: start of partition to current row (RANGE). Without ORDER BY: whole partition

Interviewers often ask where window functions run in query order. They are computed after FROM, WHERE, GROUP BY, and HAVING, and before ORDER BY and LIMIT. That is why a window function can wrap an aggregate (SUM(COUNT(*)) OVER ()) but cannot appear in WHERE. The PostgreSQL window function tutorial documents this ordering and the default frame clearly, and the behavior is the same in other major engines.

ROWS vs RANGE frames

ROWS counts physical rows. RANGE groups rows by the value of the ORDER BY column, so rows with the same value (peers) are always included together. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means "this row and the two before it." RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW means "every row whose date is within two days of this one," which matters when dates have gaps. PostgreSQL 11 and later support offset RANGE frames on dates; some engines restrict RANGE offsets to numeric types.

ROW_NUMBER vs RANK vs DENSE_RANK

These three ranking functions are the most asked window functions, and the only difference between them is tie handling. Here is the sample table used in the first problems (salaries in thousands):

emp_idnamedeptsalary
1AnaEng150
2BenEng140
3CaiEng140
4DevEng120
5EliSales90
6FaySales85
7GusSales70
SELECT name, dept, salary,
       ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn,
       RANK()       OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS drnk
FROM employees;
namedeptsalaryrnrnkdrnk
AnaEng150111
BenEng140222
CaiEng140322
DevEng120443
EliSales90111
FaySales85222
GusSales70333

Notice that Ben and Cai tie at 140. ROW_NUMBER splits them arbitrarily, and the order can change between runs unless you add a tiebreaker like ORDER BY salary DESC, emp_id. RANK skips to 4 after the tie. DENSE_RANK continues at 3.

Use this decision rule:

The question says...Use
"Exactly N rows per group" or "pick one row"ROW_NUMBER with a deterministic tiebreaker
"Top N salaries" counting distinct valuesDENSE_RANK
"Rank like a leaderboard" (ties share a place, next place skips)RANK
"Bucket into quartiles"NTILE(4)
"Percentile position"PERCENT_RANK or CUME_DIST

Problem 1: Top-N per Group

Prompt: Return the top two earners in each department.

This is the single most common window function interview question. LeetCode 185, "Department Top Three Salaries," is the same pattern. The trap is ties, so ask whether "top two" means two people or the top two salary values.

WITH ranked AS (
  SELECT name, dept, salary,
         DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS drnk
  FROM employees
)
SELECT name, dept, salary
FROM ranked
WHERE drnk <= 2;
namedeptsalary
AnaEng150
BenEng140
CaiEng140
EliSales90
FaySales85

With ROW_NUMBER instead, Eng returns only Ana and one of Ben or Cai. On Snowflake, BigQuery, Databricks, or DuckDB you can skip the CTE with QUALIFY drnk <= 2. PostgreSQL and MySQL do not support QUALIFY, so the CTE version is the safe default in an interview.

Problem 2: Nth Highest Salary

Prompt: Find the second highest distinct salary, or NULL if there is none (LeetCode 176).

SELECT MAX(salary) AS second_highest
FROM (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS drnk
  FROM employees
) t
WHERE drnk = 2;

Distinct salaries in order are 150, 140, 120, 90, 85, 70, so the answer is 140. Wrapping in MAX guarantees one row with NULL when no second salary exists. ROW_NUMBER would be wrong here: it would also return 140 on this data, but on a table with two people at 150 it would return 150.

Problem 3: Deduplicate and Keep the Latest Row

Prompt: A user_emails table has several rows per user. Keep only the most recent one.

user_idemailupdated_at
1a@old.com2026-01-05
1a@new.com2026-03-10
2b@x.com2026-02-01
WITH ordered AS (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) AS rn
  FROM user_emails
)
SELECT user_id, email, updated_at
FROM ordered
WHERE rn = 1;
user_idemailupdated_atrn
1a@new.com2026-03-101
1a@old.com2026-01-052
2b@x.com2026-02-011

The filter keeps the two rn = 1 rows. This is routine work for data engineers, who use it to clean change-data-capture feeds, so it is a staple of data engineering interviews. Here ROW_NUMBER is correct because you want exactly one row, even if two updates share a timestamp.

Running Totals and Moving Averages

Aggregates become running calculations as soon as you add ORDER BY inside OVER. The next problems use this sales_daily table. Note that 2026-09-04 is missing.

dayamount
2026-09-01100
2026-09-0250
2026-09-0380
2026-09-05120
2026-09-0640

Problem 4: Running total

SELECT day, amount,
       SUM(amount) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM sales_daily;
dayamountrunning_total
2026-09-01100100
2026-09-0250150
2026-09-0380230
2026-09-05120350
2026-09-0640390

Why write the frame explicitly? Because the default is RANGE, which treats duplicate sort values as one block. If an orders table has two orders on the same day (10 and 20) followed by one of 5, SUM(amount) OVER (ORDER BY day) returns 30, 30, 35. With ROWS you get 10, 30, 35.

Problem 5: Three-day moving average

SELECT day, amount,
       ROUND(AVG(amount) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS ma_rows,
       ROUND(AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW), 2) AS ma_range
FROM sales_daily;
dayamountma_rowsma_range
2026-09-01100100.00100.00
2026-09-025075.0075.00
2026-09-038076.6776.67
2026-09-0512083.33100.00
2026-09-064080.0080.00

The two columns diverge on 2026-09-05. ROWS averages the previous three rows (50, 80, 120) even though they span five calendar days. RANGE looks back two calendar days and only finds 09-03 and 09-05. Neither treats the missing day as zero. If the business wants zeros, build a date spine with generate_series and LEFT JOIN the sales onto it first.

LAG and LEAD: Comparing a Row to Its Neighbors

LAG(col, n, default) returns the value n rows before the current row in the window order, and LEAD returns the value n rows after. Both default to n = 1 and return NULL when there is no such row unless you pass a default. Use them for period-over-period change, time between events, and detecting state changes.

Problem 6: Day-over-day change

SELECT day, amount,
       LAG(amount) OVER (ORDER BY day) AS prev_amount,
       amount - LAG(amount) OVER (ORDER BY day) AS change
FROM sales_daily;
dayamountprev_amountchange
2026-09-01100NULLNULL
2026-09-0250100-50
2026-09-03805030
2026-09-051208040
2026-09-0640120-80

Watch the 09-05 row. LAG compares to the previous row, which is 09-03, not the previous calendar day. If the question says "compared with yesterday," either use a date spine or self-join on day - 1. For percent change, divide by NULLIF(LAG(amount) OVER (ORDER BY day), 0) to avoid division by zero.

Problem 7: Share of group total

Prompt: Show each employee's salary as a percentage of their department's total, and how far they are from the department average.

SELECT name, dept, salary,
       ROUND(100.0 * salary / SUM(salary) OVER (PARTITION BY dept), 1) AS pct_of_dept,
       ROUND(salary - AVG(salary) OVER (PARTITION BY dept), 1) AS vs_avg
FROM employees;
namedeptsalarypct_of_deptvs_avg
AnaEng15027.312.5
BenEng14025.52.5
CaiEng14025.52.5
DevEng12021.8-17.5
EliSales9036.78.3
FaySales8534.73.3
GusSales7028.6-11.7

No ORDER BY inside OVER means the frame is the whole partition, so every Eng row sees the total of 550 and average of 137.5. Multiply by 100.0 rather than 100 so integer division does not truncate to zero in PostgreSQL and SQL Server.

How Do You Solve Gaps and Islands in SQL?

Gaps and islands problems ask you to find runs of consecutive values, such as login streaks or uninterrupted uptime.

Gaps and islands questions are where good SQL candidates freeze, because the ROW_NUMBER subtraction trick is hard to rediscover live. TechScreen runs invisibly during your screen share on CoderPad, HackerRank, or Zoom and can surface the right window-function pattern and a step-by-step query in real time. New users get 3 free tokens to try it on a practice round.

Get started free →

Problem 8: Longest login streak

user_idlogin_date
12026-09-01
12026-09-02
12026-09-03
12026-09-05
12026-09-06
12026-09-09

The trick: subtract ROW_NUMBER() days from each date. Inside a consecutive run, the date and the row number both go up by one, so the result stays fixed and becomes a group key.

WITH d AS (
  SELECT DISTINCT user_id, login_date FROM logins
),
g AS (
  SELECT user_id, login_date,
         login_date - CAST(ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS int) AS grp
  FROM d
)
SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS days
FROM g
GROUP BY user_id, grp
ORDER BY days DESC;

Step one, the intermediate g table:

login_daterow_numbergrp (date minus rn)
2026-09-0112026-08-31
2026-09-0222026-08-31
2026-09-0332026-08-31
2026-09-0542026-09-01
2026-09-0652026-09-01
2026-09-0962026-09-03

Step two, grouped:

streak_startstreak_enddays
2026-09-012026-09-033
2026-09-052026-09-062
2026-09-092026-09-091

The DISTINCT step matters. Two logins on the same day would give that date two row numbers and split the island. The date - integer syntax is PostgreSQL; use DATEADD(day, -rn, login_date) in SQL Server or Snowflake and DATE_SUB in MySQL. LeetCode 180, "Consecutive Numbers," is the same idea on integer ids.

Problem 9: Sessionization

Prompt: Group each user's events into sessions, where a gap of more than 30 minutes starts a new session.

This is gaps and islands on timestamps: LAG, a flag, then a running sum.

WITH flagged AS (
  SELECT user_id, ts,
         CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) <= INTERVAL '30 minutes'
              THEN 0 ELSE 1 END AS new_session
  FROM events
)
SELECT user_id, ts,
       SUM(new_session) OVER (PARTITION BY user_id ORDER BY ts
                              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
FROM flagged;
tsprev_tsgap (min)new_sessionsession_id
10:00NULLNULL11
10:1010:001001
10:2510:101501
11:3010:256512
11:4011:301002
13:0011:408013

The first row has a NULL gap, so the comparison is not true and the ELSE branch marks it as a new session. The same flag-and-sum template answers "group rows until the value changes" questions.

Problem 10: Cohort Retention

Prompt: For each signup cohort (first active month), what share of users were active N months later?

Retention is the classic product-analytics question in data scientist interviews. MIN() OVER (PARTITION BY user_id) assigns each activity row its cohort without a separate join.

user_idactivity_month
12026-01-01
12026-02-01
12026-03-01
22026-01-01
22026-03-01
32026-02-01
32026-03-01
42026-02-01
WITH a AS (
  SELECT user_id, activity_month,
         MIN(activity_month) OVER (PARTITION BY user_id) AS cohort
  FROM activity
),
b AS (
  SELECT cohort, user_id,
         (EXTRACT(YEAR FROM activity_month) - EXTRACT(YEAR FROM cohort)) * 12
       + (EXTRACT(MONTH FROM activity_month) - EXTRACT(MONTH FROM cohort)) AS month_n
  FROM a
)
SELECT cohort, month_n,
       COUNT(DISTINCT user_id) AS users,
       ROUND(100.0 * COUNT(DISTINCT user_id)
             / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (PARTITION BY cohort ORDER BY month_n), 1) AS retention_pct
FROM b
GROUP BY cohort, month_n
ORDER BY cohort, month_n;
cohortmonth_nusersretention_pct
2026-01-0102100.0
2026-01-011150.0
2026-01-0122100.0
2026-02-0102100.0
2026-02-011150.0

Two details earn credit here. First, FIRST_VALUE runs over the grouped result, which shows you know window functions execute after GROUP BY. Second, January's month-2 retention is 100% because user 2 came back after skipping February. If the interviewer wants "rolling" retention (active in month N or later) or "consecutive" retention, the definition changes the query, so clarify before writing. The February cohort has no month-2 row because the data ends in March, and you should say that out loud rather than report it as 0%.

Common Window Function Mistakes in Interviews

Most failed answers come from a short list of errors. Check your query against it before saying "done":

  1. Filtering in WHERE. WHERE ROW_NUMBER() OVER (...) = 1 is a syntax error. Use a CTE.
  2. Nondeterministic ROW_NUMBER. Without a unique tiebreaker, results can change between runs.
  3. LAST_VALUE surprise. With the default frame, LAST_VALUE returns the current row. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
  4. RANGE peers in running totals. Duplicate sort keys share one cumulative value.
  5. Rows vs calendar days. LAG and ROWS frames ignore missing dates.
  6. Integer division. salary / total can return 0. Multiply by 100.0 first.
  7. Missing PARTITION BY. A ranking without it ranks across the whole table, which silently answers a different question.

The full list of functions you should know is short. The PostgreSQL window functions reference lists them: ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, and NTH_VALUE, plus any aggregate used with OVER.

How to Answer a Window Function Question Live

Interviewers grade the process as much as the query. A five-step routine keeps you from rushing into the wrong function:

  1. Restate the grain. "One row per department per rank" or "one row per user session."
  2. Name the partition and the order. Most bugs come from getting one of these wrong.
  3. Decide on ties and frames out loud. "I'll use DENSE_RANK because two people at the same salary should both count."
  4. Build in CTEs. Compute the window column first, then filter or aggregate in the next step. It is easier to debug and to explain.
  5. Trace a tiny example. Walk three or four rows through by hand, exactly like the tables above.

Talking through steps 2 and 3 is the same skill covered in our guide on how to think out loud in a coding interview. If you go blank, the recovery tactics in what to do when stuck in a coding interview apply to SQL rounds too.

Practice Problems

Try these without looking back. Each maps to one of the ten patterns above.

#PromptPatternKey function
1Top three products by revenue in each categoryTop-N per groupDENSE_RANK
2Each customer's first order and its amountDedup / first rowROW_NUMBER
3Cumulative signups by weekRunning totalSUM ... ROWS
4Seven-day rolling average of daily active usersMoving averageAVG ... RANGE + date spine
5Month-over-month revenue growth percentagePeriod changeLAG + NULLIF
6Days between each user's consecutive ordersNeighbor diffLAG
7Users who logged in at least three days in a rowGaps and islandsROW_NUMBER subtraction
8Periods when a server status stayed "down"Islands on stateLAG flag + running SUM
9Each product's share of category revenueShare of totalSUM() OVER (PARTITION BY)
10Monthly cohort retention for months 0 to 3RetentionMIN() OVER + FIRST_VALUE

For broader coverage of joins, NULL handling, indexes, and transactions, pair this with our main SQL interview questions guide. If your SQL round arrives as a timed screen, the online assessment tips cover pacing and test-case strategy. Backend candidates should also expect lighter versions of these problems, as described in the backend engineer interview guide.

Practicing ten patterns is one thing, and recalling the right frame clause with an interviewer watching is another. TechScreen is an invisible AI interview assistant that stays hidden during screen shares and helps you structure window-function queries, spot tie and frame edge cases, and explain your reasoning in real time. Start with 3 free tokens, no credit card required.

Get started free →

Frequently Asked Questions

What are SQL window functions?

A SQL window function computes a value for each row using a set of related rows, called the window, without collapsing those rows the way GROUP BY does. You define the window with an OVER clause that can include PARTITION BY (which rows belong together), ORDER BY (their sequence), and a frame (how many neighboring rows to include). Common examples are ROW_NUMBER, RANK, LAG, LEAD, and aggregates such as SUM or AVG used with OVER.

What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?

All three number rows in the order you specify, but they treat ties differently. ROW_NUMBER always gives unique, consecutive numbers, so tied rows get an arbitrary order unless you add a tiebreaker. RANK gives tied rows the same number and then skips ahead, producing 1, 2, 2, 4. DENSE_RANK also gives ties the same number but never skips, producing 1, 2, 2, 3. Pick based on how the question wants ties handled.

Why can't I use a window function in a WHERE clause?

SQL evaluates WHERE, GROUP BY, and HAVING before it computes window functions, so the window result does not exist yet when WHERE runs. The standard fix is to compute the window function in a CTE or subquery and filter on its alias in the outer query. Some warehouses, including Snowflake, BigQuery, Databricks, and DuckDB, also support a QUALIFY clause that filters on window results directly, but PostgreSQL and MySQL do not.

What is the default window frame in SQL?

If the OVER clause has an ORDER BY but no explicit frame, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That makes SUM behave as a running total, but RANGE treats rows with equal ORDER BY values as peers, so duplicates share one total. Without ORDER BY, the frame is the whole partition. This default is also why LAST_VALUE often returns the current row instead of the last one.

How do you solve a gaps and islands problem in SQL?

For consecutive dates or numbers, subtract a ROW_NUMBER from the value itself. Within an unbroken run, both increase by one each row, so the difference stays constant and works as a group key. Then GROUP BY that key to get each island's start, end, and length. For time-based gaps like sessions, use LAG to flag rows where the gap exceeds a threshold, then take a running SUM of the flags to assign group ids.

Are window functions asked in data engineer and data analyst interviews?

Yes. Window functions are a common advanced topic in SQL rounds for data engineers, data analysts, data scientists, and analytics engineers, and they also appear in some backend interviews. Typical prompts are top-N per group, running totals, period-over-period change with LAG, deduplication, streaks, and retention. Platforms such as LeetCode, HackerRank, and StrataScratch all include window-function problems in their SQL sets.

Do MySQL and SQLite support window functions?

Yes. MySQL added window functions in version 8.0 and SQLite added them in version 3.25, so any current version of either supports ROW_NUMBER, RANK, LAG, LEAD, and aggregate windows. PostgreSQL, SQL Server, Oracle, Snowflake, BigQuery, and Redshift also support them. Syntax for frames and date arithmetic differs between engines, so confirm which dialect your interview uses before you start writing.

Ready to use AI assistance in your next interview?

TechScreen is the invisible AI assistant trusted by engineers interviewing at Google, Meta, Amazon, and hundreds of other companies. Start with 3 free tokens — no credit card required.

Ace your next interview →