RANGE vs. ROWS: The Rolling Revelation

Context: I was working through a practice problem with an Orders table with each order having a date and a purchase amount. I needed to get a 3-month rolling average to see how sales are trending over time.

Like any reasonable person, I wrote a solution that worked. It used a window function with ROWS BETWEEN, and I felt good about it (you know…because it passed the test cases until .01 seconds).

But then… I saw someone else solve the same problem using RANGE BETWEEN, and when I see new syntax I hit the docs.


The First Attempt: ROWS BETWEEN

Here’s the initial solution I came up with:

WITH confirmed_purchases AS (
  SELECT
    date_trunc('month', created_at) AS month,
    SUM(purchase_amt) AS total_purchase_amt
  FROM amazon_purchases
  WHERE purchase_amt > 0
  GROUP BY date_trunc('month', created_at)
)
SELECT
  TO_CHAR(month, 'YYYY-MM') AS month,
  AVG(total_purchase_amt) OVER (
    ORDER BY month
    ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
  ) AS rolling_avg
FROM confirmed_purchases;

This says: “For each row, give me the average of the current and previous two rows, ordered by month.” It works because we have one row per month.


The Twist: RANGE BETWEEN + INTERVAL

Then I saw someone use this:

WITH confirmed_purchases AS (
  SELECT
    date_trunc('month', created_at) AS month,
    SUM(purchase_amt) AS total_purchase_amt
  FROM amazon_purchases
  WHERE purchase_amt > 0
  GROUP BY date_trunc('month', created_at)
)
SELECT
  TO_CHAR(month, 'YYYY-MM') AS month,
  AVG(total_purchase_amt) OVER (
    ORDER BY month
    RANGE BETWEEN INTERVAL '2 MONTH' PRECEDING AND CURRENT ROW
  ) AS rolling_avg
FROM confirmed_purchases;

Instead of counting rows, this version says: “Give me all rows where the month is within the last 2 months of the current row.” And it does so using actual time logic. Very human-readable. Very elegant.


So What’s the Difference?

Let’s break it down:

ConceptROWS BETWEENRANGE BETWEEN
How it worksCounts a fixed number of rows before/afterConsiders rows based on a range of values
Use caseWorks well for sorted, dense dataBest for time-based or value-based sliding windows
Sensitive to duplicates/gapsYesNo
IntervalsNot supportedUses INTERVAL for time-based ranges

Why This Matters

In a clean dataset where you have one row per month, either approach will work.

But in real-world data:

When any of those things happen, RANGE is the safer and more flexible option. It respects actual time, not just row position.

Also, can we talk about how nice INTERVAL ‘2 MONTH’ reads? It’s like talking to SQL in plain English.

Final Thoughts

This is one of those little moments that made me go: “Wait… you can do that?”

I didn’t know RANGE existed let alone the partitioning supported by time intervals like that. Now that I do it’s 100% going in the toolbox.

Shoutout to whoever wrote that version of the query!

Until next time: keep it windowed, keep it rolling.