ROWS vs RANGE vs GROUPS — Frame Modes in Depth

Frame clauses control exactly which rows a window function sees. You've used ROWS BETWEEN ... already — but there are two other frame modes (RANGE and GROUPS) with fundamentally different semantics. Knowing when each applies prevents subtle bugs in running totals, moving averages, and period comparisons.


The three frame modes

function() OVER (
    ORDER BY col
    {ROWS | RANGE | GROUPS} BETWEEN <start> AND <end>
)
Mode Unit Tied rows treated as
ROWS Physical row positions Individual rows
RANGE Value ranges A single logical group
GROUPS Groups of tied rows A group unit

ROWS — exact row positions

ROWS counts physical rows before and after the current row, regardless of their values.

WITH daily AS (
    SELECT
        o.created_at::DATE               AS day,
        SUM(oi.quantity * oi.unit_price) AS revenue
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    WHERE o.status = 'completed'
    GROUP BY o.created_at::DATE
)
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS rows_3day_sum
FROM daily
ORDER BY day;

2 PRECEDING AND CURRENT ROW = exactly 3 rows: the current row and the 2 rows immediately before it (by position), regardless of the date gap between them.

Use ROWS for: moving averages, running totals, any calculation where exact row counts matter.


RANGE — value-based boundaries

RANGE defines boundaries based on the value of the ORDER BY column, not row position. All rows within the value range are included.

-- RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (the default when ORDER BY is present)
WITH daily AS (
    SELECT
        o.created_at::DATE               AS day,
        SUM(oi.quantity * oi.unit_price) AS revenue
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    WHERE o.status = 'completed'
    GROUP BY o.created_at::DATE
)
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS range_running_total
FROM daily
ORDER BY day;

When multiple rows share the same day value, RANGE includes all of them in the "current row" boundary. All tied rows show the same running total — the sum through the last tied row.

With numeric offsets, RANGE becomes powerful for time-based windows:

-- Sum revenue for the current day and the 6 days preceding it (by DATE value, not row count)
WITH daily AS (
    SELECT
        o.created_at::DATE               AS day,
        SUM(oi.quantity * oi.unit_price) AS revenue
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    WHERE o.status = 'completed'
    GROUP BY o.created_at::DATE
)
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
    ) AS true_7day_sum
FROM daily
ORDER BY day;

This is a true 7-calendar-day window — even if some days are missing from the data, the window still spans exactly 7 days. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW would only span 6 data rows (which might be fewer than 7 calendar days if there are gaps).

Purchase this course to unlock the full lesson.

Sign up