open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 04 · 03

Window frames: ROWS vs RANGE vs GROUPS

The frame is the slice of rows a window function actually aggregates. The default is RANGE UNBOUNDED PRECEDING AND CURRENT ROW — which includes all peer rows tied on the ORDER BY value, quietly overcounting running totals on ties. ROWS does not.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A finance running-total report reconciles perfectly in staging and is off by thousands in production. The difference: production has two sales on the same sale_date. The running total jumps by both amounts on the first of the tied rows, then sits flat on the second — every tied day overcounts the early rows. The query used the default frame. Nobody typed RANGE, but they got it, and RANGE treats same-date rows as peers and pulls them all in at once. This is the most expensive default in window functions. By the end of this lesson you’ll know what the frame actually is, why RANGE silently overcounts on ties, and the one-line rule that makes running totals correct in production.

The frame is the slice that actually gets aggregated

PARTITION BY decides which rows share a window; ORDER BY orders them; the frame decides which subset of that ordered partition a function aggregates for each row. The frame is a moving window-within-the-window. For a running total at row 5, the frame is “rows 1 through 5”; at row 6 it slides to “rows 1 through 6.” Frame syntax has three parts: a mode (ROWS / RANGE / GROUPS) and two bounds.

The bounds you’ll use constantly:

  • UNBOUNDED PRECEDING — the first row of the partition.
  • CURRENT ROW — where the calculation currently sits.
  • n PRECEDING / n FOLLOWING — n positions back or forward.
  • UNBOUNDED FOLLOWING — the last row of the partition.

So ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is the classic running total; ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a 3-row trailing window (a moving average); ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING is the whole partition. Together the mode and bounds give you precise control over which rows feed the calculation — without spelling them out explicitly, you inherit the default, which may not be what you want.

RANGE vs ROWS: the difference is ties

ROWS counts physical rows: 1 PRECEDING means literally the one row before this one. RANGE works on values of the ORDER BY expression: CURRENT ROW in RANGE mode does not mean “this one row” — it means “all rows whose ORDER BY value equals this row’s value,” i.e. all peers. If three sales share sale_date = '2026-01-10', those three rows are peers, and under RANGE they enter and leave the frame together.

For a running SUM ordered by sale_date, the consequence is exact and damaging: on a tied date, every one of the tied rows gets the same running total — the total including all peers — instead of accumulating one at a time. The first tied row already shows the full day’s contribution; the others repeat it. If your report expects each row to add its own amount in sequence, the tied rows are wrong.

-- Default frame == RANGE; tied sale_date rows share one running value (overcounts early rows)
SELECT sale_date, amount,
       SUM(amount) OVER (ORDER BY sale_date) AS running_default
FROM sales;

-- ROWS frame: each physical row adds exactly its own amount, in order
SELECT sale_date, amount,
       SUM(amount) OVER (ORDER BY sale_date
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_rows
FROM sales;

The default frame, and the production rule

When you add ORDER BY inside OVER and specify no frame, Postgres uses RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That is the SQL-standard default — and it is RANGE, not ROWS. On data with no ties in the ORDER BY column the two are identical, which is exactly why bugs sneak through: it works on a clean dev dataset and breaks the day production has two rows with the same timestamp or date.

The production rule is blunt: for any running aggregate, write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly. It makes the frame physical (one row at a time), it is also generally faster (Postgres needn’t scan ahead to find all peers), and it removes the silent tie behavior. Use RANGE only when you genuinely want peers grouped — for example a value-based frame like RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW, which ROWS cannot express. GROUPS is the third mode: its bounds count peer groups rather than rows or values — GROUPS 1 PRECEDING reaches back one whole tie-group — a rare but precise tool when you want “the previous distinct date’s rows, however many.”

Edge cases

A subtle corollary: RANGE n PRECEDING is only legal when there is exactly one ORDER BY column and it is numeric, date, or interval — because Postgres must compute value - n to define the bound. RANGE 100 PRECEDING over amount means “all rows whose amount is within 100 below the current row’s amount.” Try RANGE with a multi-column ORDER BY and a numeric offset and you get an error; ROWS has no such restriction because it counts positions, not values. So ROWS is both the safer default and the more broadly applicable mode. The databases track’s execution-plans chapter shows how each frame mode appears as a WindowAgg node and what it costs.

Quiz

With ORDER BY sale_date and no explicit frame, two sales share sale_date = '2026-01-10'. What running total do those two tied rows show under the default frame?

Quiz

Which frame should you write for a correct, one-row-at-a-time running total?

Complete the analogy

Fill in the blank: under the default RANGE frame, rows tied on the ORDER BY value are treated as _______ and enter the frame together, which makes running totals overcount the early tied rows; switching to ROWS counts physical positions instead.

Recall before you leave
  1. 01
    What is a window frame, and what are its two parts?
  2. 02
    Why can a running total be wrong under the default frame, and what's the fix?
  3. 03
    When is RANGE actually the right choice over ROWS?
Recap

The frame is the part of window mechanics that separates people who use window functions from people who trust them in production. After PARTITION BY scopes the window and ORDER BY sequences it, the frame chooses which subset of those ordered rows each function actually aggregates, via a mode (ROWS, RANGE, GROUPS) and two bounds. The default when you add an inner ORDER BY is RANGE UNBOUNDED PRECEDING AND CURRENT ROW — and because RANGE operates on values, rows tied on the ORDER BY column are peers that enter the frame together, so a running total repeats the peer-inclusive value across all tied rows and overcounts the early ones. It passes on tie-free dev data and breaks in production the day two rows share a timestamp. The rule is to write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly for running aggregates — it is correct, usually faster, and broadly applicable — and to reach for RANGE only for genuine value-based windows like INTERVAL '7 days' PRECEDING. Now when you write any running aggregate, type the ROWS frame clause immediately — before you run the query, before you write the next window — so the tie behavior never reaches production. With frames understood, the next lesson turns to ranking functions, where tie behavior reappears as the difference between ROW_NUMBER, RANK, and DENSE_RANK.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.