In this article
  1. Before you start
  2. Why interviewers ask this
  3. Window functions from first principles
  4. Worked example
  5. Trade-offs and edge cases
  6. How to answer it in an interview
  7. Common mistakes
  8. Follow-up questions
  9. Sources

Before you start

The coding interview patterns and method guide gives the wider context for this topic. This page uses plain table queries only.

Why interviewers ask this

Window functions separate candidates who can describe a result from candidates who can build it. Candidates report rounds that include window functions (lag, running total, moving average) and self-joins, a multi-step query that combines a window function, a self-join and a filtered aggregate, and two phrasings of "highest earnings per delivery executive per date"; no public source confirms the exact questions. A self-join is a join of a table to itself under two aliases, so each row can be matched with other rows of the same table. The same top-per-group shape appears in "most popular artists by country".

A weak answer reaches for GROUP BY and a self-join and loses the row you needed. A strong answer ranks inside a partition, filters the rank in an outer query, and says what happens to ties. A Staff-level answer also states which tie rule the business wants before typing the query.

Window functions from first principles

Window function
A window function is a SQL function that computes a value for each row from a set of rows related to that row, written with an OVER clause, and that keeps every input row instead of collapsing them into one row per group.
Partition
A partition is the group of rows that share the same values in the PARTITION BY columns of a window, so the function restarts its counting and its comparisons inside each group.

In plain words

Picture a results sheet for several races. A GROUP BY summary is a leaderboard with one line per race. A window function leaves every runner's line and adds one extra column, such as "place in this race". The analogy breaks in one place. A sheet is already sorted, but a window sorts only inside the function, using the ORDER BY you put in OVER.

In one precise sentence, a window function adds a computed column to every row, using only the rows of its partition and frame. The PostgreSQL documentation describes window functions as calculations across sets of rows related to the current query row. A built-in one such as rank needs an OVER clause, and an ordinary aggregate acts as a window function only when undefined follows it; otherwise it collapses the rows. The same syntax is documented for SQLite and MySQL 8.0, so the ideas below carry across those databases.

Step 1: What does the OVER clause define?

The OVER clause defines the window: the rows, and their order, that a window function may see. PARTITION BY splits the table into partitions; ORDER BY inside OVER orders the rows within each partition. The PostgreSQL reference states that the built-in functions depend on the ordering of the window definition, so always write the ordering: without it the answer is left to chance.

Input: the table rows. Output: the same rows, grouped and ordered in the function's view only. For "highest earnings per delivery executive per date", the partition columns are de_id and the date, and the ordering is earnings from high to low. You now know the shape of every window query: a function name, then OVER (PARTITION BY ... ORDER BY ...). Nothing has been filtered yet.

Step 2: How do ranking functions treat ties?

row_number gives every row in the partition its own number from 1, even when earnings tie. rank gives tied rows the same rank and then leaves gaps; the PostgreSQL reference defines it as the row_number of the first row of the peer group. Peers are rows that tie under the window's ORDER BY. dense_rank also shares a rank among peers but leaves no gaps, because it counts peer groups.

Take one delivery executive on one date with four orders earning 50, 50, 30 and 20 (illustrative). row_number gives 1, 2, 3, 4; rank gives 1, 1, 3, 4; dense_rank gives 1, 1, 2, 3.1 Keeping rank 1 with rank returns both 50 orders; with row_number it returns one of them, chosen arbitrarily unless you add a tiebreaker column. That is the common error: using row_number when the question allows ties.

Rank skips after a tie while dense rank does notGrouped bars of the numbers given to four orders earning 50, 50, 30 and 20: row number gives 1 to 4, rank gives 1, 1, 3, 4 and dense rank gives 1, 1, 2, 3.Number given012345A 50B 50C 30D 20Order and earningsrow_numberrankdense_rank123411341123Diagram: Interview Clozo, CC BY 4.0 ·interview.clozo.ai/blog/sql-in-ml-engineer-loops.html
Ties share a rank, and only the dense version leaves no gap after them.CC BY 4.0 — source: How is SQL tested in ML-engineer interviews?
Text description of the diagram
  • Row number gives 1, 2, 3 and 4.
  • Rank gives 1, 1, 3 and 4.
  • Dense rank gives 1, 1, 2 and 3.

Step 3: How do you filter on a rank?

A window function result cannot be filtered in the same query's WHERE clause, so the standard pattern wraps the ranking in a subquery and filters outside it. A subquery is a SELECT statement written inside another query, in parentheses, whose result the outer query treats as a table. A common table expression is a named subquery introduced with WITH; both work. Some databases shorten this: Snowflake documents a undefined clause that filters on window results without a subquery, and BigQuery and DuckDB offer the same clause, while PostgreSQL and MySQL do not.

Input: rows plus a rank column. Output: only the rows whose rank passes the filter. This is the answer to the two "maximum earnings per delivery executive per date" questions and to "most popular artists by country" (rank artists inside each country by their play count, then keep rank 1). You now know the complete top-N-per-group pattern. Changing "top one" to "top few" is a change of the filter, not of the structure. The k closest points page shows the same top-k idea in code.

Step 4: How do lag and frames give comparisons and running totals?

lag returns the value from an earlier row in the partition, and a frame limits the rows an aggregate reads. Per the PostgreSQL reference, lag(value, offset, default) uses an offset of 1 and a default of NULL when you omit them, so the first row returns NULL instead of an error. A running total is a sum over a frame that starts at the partition start; a moving average is an avg over a short frame.

The PostgreSQL syntax section says the default frame runs from the start of the partition through the last peer of the current row, which is why last_value often misleads. With the default frame, rows that tie on the ORDER BY value get the same running total. State the frame explicitly with ROWS to get one row at a time:

SELECT day, amount,
       lag(amount) OVER (ORDER BY day) AS prev_amount,
       sum(amount) OVER (ORDER BY day
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
       avg(amount) OVER (ORDER BY day
            ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3
FROM daily_sales;

The moving average reads the current row and the two before it. On daily amounts 10, 20, 30, 40, 50 (illustrative) the query returns the values below.1 The prefix-sum page shows the running total in Python.

lag is empty on the first row, the running total adds every row so far, and the moving average only ever sees three rows.
DayAmountprev_amountrunning_totalmoving_avg_3
110NULL1010
220103015
330206020
4403010030
5504015040
A window function adds a column and an outer query filters itFlow of a window query: table rows are partitioned and ordered, a ranking function adds a column, an outer query filters on that column, and a frame feeds running totals.Table rowsPartition andorderOVER clauseRank columnties handledOuter filterkeep top ranksFrame totalrunning sumDiagram: Interview Clozo, CC BY 4.0 ·interview.clozo.ai/blog/sql-in-ml-engineer-loops.html

A window function adds a column to every row, so the filter on the rank has to happen in an outer query.CC BY 4.0 — source: How is SQL tested in ML-engineer interviews?
Step-by-step description
  1. Rows are partitioned and ordered.
  2. A function adds a rank column.
  3. An outer query keeps the wanted ranks.
  4. A frame gives running totals.
  • The OVER clause partitions and orders the rows.
  • A ranking function adds a column and ties are handled by the function chosen.
  • An outer query filters on the rank column.
  • A frame limits the rows read for running totals.

Why do ML-engineer rounds use window functions?

ML-engineer rounds use window functions because building training data means computing per-user features and labels from event tables without collapsing the rows. lag gives "the previous event's value" as a feature, a frame gives a rolling average, and lead gives "the next event" as a label. The frame decides whether the feature is honest:

SELECT user_id, event_time, amount,
       avg(amount) OVER (PARTITION BY user_id ORDER BY event_time
            ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING) AS avg_prev_7,
       lead(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS next_event_time
FROM events;

A frame ending at 1 PRECEDING, as in the PostgreSQL frame syntax, stops one row before the current row, so the current row and every later row stay out of the feature. The label can then not leak into the feature, which is called point-in-time correctness. The frame counts rows (the 7 is illustrative), so say aloud whether you mean rows or days.

Knowing it is half the job. Saying it is the other half.

Interview Clozo is an AI interview copilot for real interview rounds: it hears the question and puts a strong answer on your screen, and you say it in your own words. See the features or how it works.

Start Free Trial →

Worked example

Two delivery executives on one date. Executive 1 has orders A, B, C, D earning 50, 50, 30 and 20; executive 2 has orders E and F earning 40 and 25 (all illustrative). The task: the orders with maximum earnings per executive per date.

SELECT order_number, de_id, order_date, earnings
FROM (
  SELECT order_number, de_id, order_date, earnings,
         rank() OVER (PARTITION BY de_id, order_date
                      ORDER BY earnings DESC) AS rk
  FROM orders
) AS ranked
WHERE rk = 1;

The inner query adds rk; the outer query keeps rank 1. The result is orders A and B for executive 1 and order E for executive 2, three rows in total. With row_number instead, executive 1 would return only one of A and B.1

Now the Nth-highest question. Salaries 90, 80, 80, 70, 60, 60, 50 (illustrative) have a fourth-highest distinct salary of 60. Candidates report this question with LIMIT and TOP forbidden. Forbidding them tests whether you can express rank logic; dense_rank or a correlated COUNT(DISTINCT ...) subquery both answer it. The dense_rank version:

SELECT DISTINCT salary
FROM (
  SELECT salary,
         dense_rank() OVER (ORDER BY salary DESC) AS dr
  FROM salaries
) AS ranked
WHERE dr = 4;

It returns 60. Using rank would return 70, because the tied 80s push 70 to rank 4.1

rank and dense_rank agree until the first tie, and then only dense_rank answers "Nth distinct value".
QuantityValue (illustrative input)
row_number on 50, 50, 30, 201, 2, 3, 4
rank on 50, 50, 30, 201, 1, 3, 4
dense_rank on 50, 50, 30, 201, 1, 2, 3
Fourth-highest distinct salary60
Salary at rank 470
percent_rank of the third of five rows0.5

The last row follows the documented formula (rank minus 1) divided by (partition rows minus 1), which is (3 − 1) / (5 − 1).1

Trade-offs and edge cases

Choose the ranking function from the business rule, not from habit. The table below compares the three.

Each ranking function answers a different tie questionTable comparing row_number, rank and dense_rank by how they treat ties, whether a gap follows a tie, whether tied winners are kept and whether they find the Nth distinct value.Questionrow_numberrankdense_rankTied rows getdifferentnumbersthe samerankthe samerankGap after a tienoyesnoKeeps all tiedwinnersnoyesyesNth distinct valuenonoyesDiagram: Interview Clozo, CC BY 4.0 ·interview.clozo.ai/blog/sql-in-ml-engineer-loops.html
Pick the ranking function by what should happen to ties: keep them, break them or count distinct values.CC BY 4.0 — source: How is SQL tested in ML-engineer interviews?
Text description of the diagram
  • Row number gives every row its own number.
  • Rank leaves a gap after tied rows.
  • Dense rank counts distinct values and leaves no gap.

Edge cases to name aloud:

  • Ties with row_number. The pick among tied rows is arbitrary unless the ORDER BY has a tiebreaker, so add a unique column when the output must be repeatable.
  • last_value. With the default frame it returns the current peer group's last row, not the partition's. Give the frame an explicit end.
  • Self-join versus window. A self-join compares rows by matching them to each other, which can multiply rows and cost more; a window function reads one ordered pass per partition. A self-join is still natural for pairing rows that need a custom match condition (a performance claim for your own database should come from its plan output).
  • Syntax differs by database. Filtering on a window result without a subquery works with QUALIFY in some databases and needs the subquery pattern in others, so say which database you assume.

How to answer it in an interview

Interviewer asks

Write a query for the order with maximum earnings per delivery executive per date.

A strong answer

First I would ask whether ties should all be returned, because that picks the function. Assuming yes, I rank orders with rank() over a partition by executive and date, ordered by earnings descending. A window result cannot go in the same WHERE, so I wrap that in a subquery and keep rank 1. If only one row per group is wanted, I switch to row_number and add a tiebreaker column so the result is repeatable.

The spoken structure: ask about ties, name the partition and ordering, say why the filter lives in an outer query, then give the tie variant.

Common mistakes

  • "I will use row_number and keep 1." (wrong) That drops tied winners at random. (right) "I use rank when ties should all stay, and row_number with a tiebreaker when exactly one row is wanted."
  • "The fourth highest is where rank equals 4." (wrong) Tied values leave gaps. (right) "I use dense_rank, which counts distinct values."
  • "I will filter rk = 1 in the WHERE of the same query." (wrong) The window column does not exist there yet. (right) "I rank in a subquery or common table expression (CTE) and filter outside."
  • "last_value gives the end of the group." (wrong) The default frame stops at the current peer group. (right) "I set the frame to end at the partition's last row."
  • "A window function is another GROUP BY." (wrong) It keeps every row. (right) "An aggregate with OVER adds a column; GROUP BY collapses rows."

Follow-up questions

Define popular first, for example total plays. Aggregate plays per artist and country, then rank artists within each country with dense_rank or rank ordered by plays, and keep rank 1 in an outer query. This question is reported by candidates, with no public source on the exact schema.

How do you get the 4th highest salary without limit or top?

Rank salaries with dense_rank() OVER (ORDER BY salary DESC) in a subquery and keep the row whose rank equals the number you need, as in the worked example. dense_rank counts distinct values, so tied salaries do not shift the answer.

What if the question asks for more than one top row per group?

Keep the same structure and relax the outer filter to ranks up to the number wanted. Decide again whether ties may push the count above that number.

How do you write a multi-step query with a window function, a self-join and a filtered aggregate?

Build it in named steps, each one a CTE, meaning a named subquery: filter and aggregate first, add the window column second, self-join the ranked result to itself last. Say each step's output out loud before writing the next. This sketch, on a stated toy table orders(customer_id, order_date, status, amount), finds each day's top customer and that customer's total on the previous day:

WITH day_totals AS (            -- step 1: filtered aggregate
  SELECT customer_id, order_date, sum(amount) AS day_total
  FROM orders
  WHERE status = 'paid'
  GROUP BY customer_id, order_date
),
ranked AS (                     -- step 2: window column
  SELECT customer_id, order_date, day_total,
         rank() OVER (PARTITION BY order_date
                      ORDER BY day_total DESC) AS rk
  FROM day_totals
)
SELECT t.order_date, t.customer_id, t.day_total,   -- step 3: self-join
       p.day_total AS previous_day_total
FROM ranked AS t
LEFT JOIN day_totals AS p
  ON p.customer_id = t.customer_id
 AND p.order_date = t.order_date - 1
WHERE t.rk = 1;

Date arithmetic such as t.order_date - 1 differs between databases, so name the dialect. The Python interview questions page covers the other half of such rounds.

How do you list all books of a topic when the topic is stored in the shelves table?

Join the two tables on the shelf key and filter on the topic. Candidates report this plain-join question; the column names below are illustrative:

SELECT b.title
FROM books AS b
JOIN shelves AS s ON s.shelf_id = b.shelf_id
WHERE s.topic = 'databases';

Listing the books on one given shelf needs no join if books holds the shelf key: filter books directly.

When is a self-join better than a window function?

Use a self-join when two rows must be matched by a condition that is not just "earlier row in the same order", such as pairing records in overlapping ranges; the merge intervals page shows that style of reasoning in code. For ranks, previous values and running totals the window function is shorter and clearer.

Key takeaways
  • A window function adds a computed column to every row; GROUP BY collapses rows.
  • Pick row_number, rank or dense_rank by the tie rule the question implies.
  • Rank in a subquery or CTE, then filter the rank outside.
  • lag returns NULL on the first row by default, and the default frame ends at the current peer group.
  • Ask about ties before you write the query.

Sources

  1. PostgreSQL Documentation (current): Window Functions — PostgreSQL Global Development Group, 26 Oct 2026
  2. PostgreSQL Documentation (current): Value Expressions, window function calls (Section 4.2.8) — PostgreSQL Global Development Group, 26 Oct 2026
  3. PostgreSQL Documentation (current): Window Function tutorial (Section 3.5) — PostgreSQL Global Development Group, 26 Oct 2026
  4. SQLite: Window Functions — SQLite, 26 Oct 2026
  5. MySQL 8.0 Reference Manual: Window Function Concepts and Syntax — Oracle (MySQL), 26 Oct 2026
  6. Snowflake documentation: QUALIFY — Snowflake, 26 Oct 2026