Hook: Interviewers love this because one missing frame can make LAST_VALUE quietly return the wrong answer.
Question: What is a window frame in SQL, and why does it matter?
Answer: A window frame is the specific slice of rows a window function can see for the current row. The full PARTITION BY group is the big bucket; the frame is the smaller moving window inside that bucket. It matters because functions like SUM, AVG, FIRST_VALUE, and LAST_VALUE can change their result depending on where the frame starts and ends.
Interview-Ready Answer: In SQL, a window frame is the row range that a window function evaluates for each current row. I think of the partition as the whole shelf and the frame as the handful of books I am allowed to look at right now. This matters a lot for running totals and especially for functions like LAST_VALUE, because the default frame often ends at the current row unless I explicitly extend it to the end of the partition.
A window function runs on rows that are related to the current row. The PARTITION BY clause splits data into groups, the ORDER BY clause gives those rows an order, and the frame says which rows around the current row are visible to the function.
Think of it like reading a report through a narrow ruler: the report is the partition, the ruler position is the current row, and the visible text under the ruler is the frame.
PARTITION BY.ORDER BY keys.ROWS, the engine counts physical rows. If you use RANGE, it includes rows with equal sort-key values, called peers (rows tied on the ordering expression). If you use GROUPS, it moves by peer groups instead of individual rows.The default is easy to forget:
ORDER BY inside the window and you do not write a frame, many engines follow the SQL-standard default of RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.ORDER BY inside the window, the frame is the entire partition.This is why a query can look correct and still surprise you. LAST_VALUE is the classic trap: with the default frame, the 'last' row is often just the current row, not the last row in the partition.
| Clause | How it counts | Best for | Common surprise |
|---|---|---|---|
ROWS | Physical rows | Exact running totals | Ties are separate rows |
RANGE | Sort-key value + peers | Same-day or same-price logic | All peers share the same frame |
GROUPS | Peer groups | Moving by tied groups | Less common, engine support varies |
When to use which: Use ROWS when you want a precise count of rows, such as 'the last 3 transactions'. Use RANGE when ties should move together, such as 'all sales on the same day'. Use GROUPS when you want to step by tie groups, not by individual rows.
Window functions usually need the data sorted by partition and order keys, so the first big cost is often O(n log n) per partition if the rows are not already ordered. After that, simple running aggregates like cumulative SUM are often computed in near O(n) time by reusing prior state, but wide frames can force the engine to keep more rows in memory. In practice, the memory footprint can grow with partition size, especially when the frame spans many rows and the engine cannot stream it cheaply.
If the input already matches the needed ordering, the optimizer may avoid a full sort or use an index-backed plan, which can make a huge difference on large tables.
RANGE; two rows with the same date can produce the same running total.LAST_VALUE trap: without an explicit frame ending at UNBOUNDED FOLLOWING, you often get the current row, not the last row.ORDER BY: the whole partition is the frame, which is fine for partition totals but not for running results.RANGE offset rules can be stricter than ROWS.Real-World Example: Imagine a checkout service in an e-commerce platform that calculates each customer's running spend for fraud checks and loyalty rewards.
The team groups purchases by customer and orders them by purchase time. For a running total, they use a frame that ends at the current row. But for a report that needs the customer's final spend on every row, they must extend the frame to the end of the partition. If they forget, LAST_VALUE or a final-balance calculation can silently show the wrong amount.
What goes wrong: a developer ships a dashboard using LAST_VALUE(amount) with the default frame. On every row, the dashboard shows the current transaction amount instead of the final amount in the customer partition. Support sees confused merchants, the logs look normal, and the bug hides for days because the query returns valid-looking numbers. The symptom is not an error; it is a believable but wrong report, which is exactly why frame bugs are dangerous.
-- PostgreSQL example: demonstrates ROWS vs RANGE, the default-frame trap in LAST_VALUE,
-- and the "whole partition" behavior when there is no ORDER BY in the window.
CREATE TEMP TABLE ledger (
tx_day date,
tx_id integer,
amount integer
);
INSERT INTO ledger (tx_day, tx_id, amount) VALUES
('2026-01-01', 1, 100),
('2026-01-01', 2, 50),
('2026-01-02', 3, 25),
('2026-01-03', 4, 75),
('2026-01-03', 5, 10);
-- 1) Compare ROWS and RANGE.
-- ROWS counts physical rows, so the total changes row by row.
-- RANGE includes all peers with the same ORDER BY value, so tied dates share the same result.
SELECT
tx_day,
tx_id,
amount,
SUM(amount) OVER (
ORDER BY tx_day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total_rows,
SUM(amount) OVER (
ORDER BY tx_day
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total_range,
-- Default frame ends at CURRENT ROW, so LAST_VALUE often returns the current row,
-- which is the classic interview trap.
LAST_VALUE(amount) OVER (
ORDER BY tx_day
) AS last_value_default_frame,
-- Fix: extend the frame to the end of the partition.
LAST_VALUE(amount) OVER (
ORDER BY tx_day
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_value_full_partition
FROM ledger
ORDER BY tx_day, tx_id;
-- 2) Edge case: with no ORDER BY in the window, the frame is the whole partition.
-- Every row gets the same total.
SELECT
tx_day,
tx_id,
amount,
SUM(amount) OVER () AS partition_total
FROM ledger
ORDER BY tx_day, tx_id;Follow-up & Tricky Questions:
ROWS and RANGE? ROWS counts individual rows, while RANGE groups rows with equal ordering values. That means peers share the same frame result under RANGE.ORDER BY is present? RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That is why running aggregates work without an explicit frame, but value functions can surprise you.ROW_NUMBER, RANK, and DENSE_RANK do not depend on the frame. They care about partition and ordering, but not the frame boundaries.LAST_VALUE often seem broken? UNBOUNDED FOLLOWING.GROUPS? ROWS and RANGE, and not every engine supports it equally.SUM(x) OVER (ORDER BY day), is that always a row-by-row running total? day, the default is often value-based RANGE, so peers on the same day get the same result. If you need exact row-by-row accumulation, write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.ROW_NUMBER()? ROW_NUMBER() is driven by ordering within the partition, not by the frame. Frames matter for aggregates and value functions, not for row numbering.ORDER BY the same as the final query ORDER BY? ORDER BY controls how the function sees rows; the final ORDER BY controls output display. They can be different, and that is a common source of confusion.Common Mistakes:
LAST_VALUE means the whole partition, but the default often stops at the current row. Correction: explicitly write UNBOUNDED FOLLOWING when you need the true last row.RANGE when they wanted exact rows: ties collapse together and change the result. Correction: use ROWS for precise row counts.Memory Hook: Partition is the library; frame is the open book pages under your finger.
Cheat Sheet:
PARTITION BY = which group.ORDER BY = row order inside the group.ROWS = physical rows.RANGE = peer values move together.LAST_VALUE needs a careful frame.Practice Tasks:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.RANGE and observe what happens when two rows share the same date.LAST_VALUE query so it returns the true final row in the partition.