Tier 4 · Advanced

Window Frames (ROWS, RANGE, GROUPS) in SQL

By Owen Middleton · Updated September 2026 · Examples run on PostgreSQL 17

What are Window Frames in SQL?

The window frame clause controls exactly which rows feed into a window function's calculation for each row. It's a sliding boundary that moves as the function processes the partition.

You've already used window functions that compute running totals: SUM(revenue) OVER (PARTITION BY region ORDER BY sale_date). What you may not have noticed is that a default frame is silently active. When you include ORDER BY inside OVER, the frame automatically covers from the start of the partition through the current row. That's why running totals accumulate correctly without any extra syntax.

Once you want a rolling window — the last 7 days, the last N rows, a specific date range — you need to declare the frame explicitly. ROWS and RANGE are the two modes you'll use in practice.

How do you write a rolling window with ROWS?

ROWS counts physical row positions. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW means: the current row plus exactly the 6 rows before it in the sorted partition.

SUM(revenue) OVER (
    PARTITION BY region
    ORDER BY sale_date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)

If the data has one row per day, this is a 7-day rolling sum. But if there are gaps in the date sequence — a day with no data — ROWS still grabs exactly 6 prior rows regardless of their dates. You'd be summing across a longer calendar window than you intended.

How does RANGE handle gaps in dates?

RANGE operates on values, not positions. RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW includes all rows whose date falls within the 6 calendar days before the current row's date.

SUM(revenue) OVER (
    PARTITION BY region
    ORDER BY sale_date
    RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
)

Gaps are handled correctly: missing days contribute nothing. The window always represents a true 7-day calendar range regardless of how many rows it contains.

With UNBOUNDED PRECEDING, ROWS and RANGE produce identical results on this data — you can confirm it:

SELECT ordered_at::date AS order_date, total_amount,
  SUM(total_amount) OVER (ORDER BY ordered_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_running,
  SUM(total_amount) OVER (ORDER BY ordered_at RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS range_running
FROM orders
ORDER BY ordered_at
LIMIT 10

When do ROWS and RANGE give different answers?

The one thing that trips people up

ROWS and RANGE produce identical results when there are no gaps and one row per date. They diverge the moment gaps appear — and they diverge silently. Both queries run without error, but one returns the wrong values.

Use RANGE when gaps in the data matter and you want a true calendar window. Use ROWS when you need a fixed number of observations regardless of what dates they cover.

What does UNBOUNDED PRECEDING mean in a window frame?

UNBOUNDED PRECEDING and UNBOUNDED FOLLOWING

These boundary keywords cover the full partition. ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING covers every row for every calculation. This is the frame you need for LAST_VALUE and NTH_VALUE to behave as expected, and for any aggregate that should look at the entire group rather than a sliding window.

GROUPS is a third frame mode that counts peer groups (rows with equal ORDER BY values) rather than positions or value ranges. It's less common and covered when you encounter tied values in ranked windows.

Practice Window Frames in SQL

Practice · easy ecommerce · Brightlane

Brightlane's revenue operations team wants a rolling 3-order spend total per customer to see short-horizon spending behavior.

Write a query to return every order's ID, customer ID, order amount, and the combined total_amount across that customer's current order plus the two immediately preceding orders chronologically.

Assumptions:

  • Within each customer's orders, the rolling-3 sum at each row covers that order plus the two orders with the largest ordered_at strictly before it. The window is restricted to that customer.
  • For a customer's first order, the rolling-3 sum equals just that order's total_amount. For a customer's second order, the sum covers two orders. From the third order onward, it covers three.
  • The final result is sorted by customer_id ascending, then by ordered_at ascending.

Output:

  • One row per order, with columns id, customer_id, total_amount, and rolling_3_sum. Sorted by customer_id, then ordered_at.
Schema · ecommerce5 tables? = nullable
categories
idinteger
nametext
parent_id?integer
products
idinteger
nametext
category_id?integer
pricenumeric
stock_qtyinteger
attributes?jsonb
order_items
idinteger
order_id?integer
product_id?integer
quantityinteger
unit_pricenumeric
customers
idinteger
nametext
emailtext
city?text
countrytext
created_attimestamptz
is_activeboolean
orders
idinteger
customer_id?integer
ordered_attimestamptz
statustext
total_amountnumeric

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution

The full breakdown walks through the shape, each clause, why this approach beats the alternatives, and the trap to avoid.

See the full worked solution

9 Window Frames practice problems

Write a query to return every order's ID, customer ID, order amount, and the combined total_amount across that customer's current order plus the two immediately preceding orders chronologically.

easy ecommerce

Write a query to return every session's ID, user ID, event count, and the average event_count across that user's current session plus the two immediately preceding sessions chronologically.

easy analytics

Write a query to return every order's ID, ordered-at timestamp, total amount, and the combined total_amount across that order plus the two orders placed immediately after it in the global chronological sequence.

easy ecommerce

Write a query to return every order's ID, ordered-at timestamp, total amount, and the combined total_amount across every order whose ordered_at falls within the 30 calendar days ending on and including that order's ordered_at.

medium ecommerce

Write a query to return every order's ID, customer ID, order amount, and the average total_amount across that customer's current order plus the six immediately preceding orders chronologically.

medium ecommerce

Write a query to return every delivered order's ID, customer ID, amount, and the average total_amount across that customer's current delivered order plus the two immediately preceding delivered orders chronologically.

medium ecommerce

Write a query to return every order's ID, customer ID, order amount, and the difference between that order's total_amount and the average across that customer's current order plus the two immediately preceding orders chronologically.

medium ecommerce

Write a query to return every order's ID, ordered-at timestamp, total amount, the combined total_amount across that order plus up to the two immediately preceding orders chronologically, and the count of orders included in that sum.

hard ecommerce

Write a query to return every order's ID, ordered-at timestamp, total amount, the position-based rolling-3 sum, and the 30-day calendar rolling sum.

hard ecommerce

Start learning to practice all 9 Window Frames problems, with instant grading and mastery tracking.

Common questions about Window Frames

What is the default frame when you write ORDER BY inside OVER?

Everything from the start of the partition to the current row, compared by value rather than by position. That default is why a plain running total accumulates correctly without any frame clause written out.

Is there a frame at all when OVER has no ORDER BY?

The whole partition is in view, which is why an aggregate with a bare OVER clause gives every row the same figure. There is nothing to accumulate along, so there is nothing for a frame to slide over.

What does GROUPS mode do?

It counts peer groups rather than rows or values, so one step back means the previous set of tied rows however many there are. It is the least used of the three and worth knowing mainly so that an unfamiliar frame clause does not stop you reading a query.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.