Snowflake SQL Tutorial 55 - ROWS vs RANGE in Snowflake

Snowflake SQL · Episode 55

In this post we are going to cover ROWS and RANGE in Snowflake.

They are the two keywords a window frame can start with, and they take exactly the same bound words. ROWS counts rows. RANGE compares values. Most of the time they give us the same answer, which is why this one catches people out.

We will find the one row where they come apart, measure both frames to see why, and then look at the frame Snowflake gives us when we write ORDER BY inside OVER and no frame at all. That default is not the one most people assume.

Every query below was run on a real Snowflake account, and every result you see is the output it gave back.

Episode 55 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.

Let's create some sample data

Episode 54 wrote every frame with the keyword ROWS. The second keyword, RANGE, accepts the same bound words, and on most data it returns exactly the same numbers.

The difference only ever shows up where the ORDER BY has a tie in it. So the first job is to show that our table has one.

Sample data
-- Creates the EMPLOYEES table used by every episode in this series.
-- Part of the shared sample-data script; run it once.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;

CREATE OR REPLACE TABLE EMPLOYEES (
    EMP_ID          INT,
    FIRST_NAME      VARCHAR,
    LAST_NAME       VARCHAR,
    EMAIL           VARCHAR,
    DEPT_ID         INT,
    MANAGER_ID      INT,
    SALARY          NUMBER(10,2),
    COMMISSION_PCT  NUMBER(4,2),
    HIRE_DATE       DATE
);

INSERT INTO EMPLOYEES VALUES
    (1, 'Aisha',  'Khan',      'aisha.khan@example.com',      10,    6, 118000.00, NULL,  '2019-03-11'),
    (2, 'Marcus', 'Reid',      'marcus.reid@example.com',     10,    6,  96500.00, NULL,  '2021-07-01'),
    (3, 'Lena',   'Fischer',   'lena.fischer@example.com',    20,    6, 104000.00, 0.05,  '2018-01-22'),
    (4, 'Tomas',  'Novak',     'tomas.novak@example.com',     20,    3,  87250.00, NULL,  '2022-09-05'),
    (5, 'Priya',  'Raman',     'priya.raman@example.com',     30,    6,  96500.00, 0.10,  '2020-11-17'),
    (6, 'Daniel', 'O''Connor', 'daniel.oconnor@example.com',  10, NULL, 131000.00, NULL,  '2016-05-30'),
    (7, 'Sofia',  'Marino',    'sofia.marino@example.com',    30,    5,  71400.00, 0.08,  '2023-02-13'),
    (8, 'Grace',  'Lin',       'grace.lin@example.com',     NULL,    6,  92000.00, NULL,  '2021-04-19');

Let's ask which salaries are shared, and by whom.

Query — which salaries are shared, and by whom
SELECT SALARY, COUNT(*) AS PEOPLE,
       LISTAGG(FIRST_NAME, ', ') WITHIN GROUP (ORDER BY FIRST_NAME) AS WHO
FROM EMPLOYEES GROUP BY SALARY HAVING COUNT(*) > 1;
Result1 row · 3 columns
SALARYPEOPLEWHO
96500.002Marcus, Priya

One row back, so there is exactly one shared salary in EMPLOYEES, and two people hold it: Marcus and Priya.

Most of the grids on this page are ordered by SALARY, so it helps to know where those two sit in that order.

Query — the rows in salary order
SELECT ROW_NUMBER() OVER (ORDER BY SALARY, FIRST_NAME) AS RN, FIRST_NAME, SALARY
FROM EMPLOYEES ORDER BY SALARY, FIRST_NAME;
Result8 rows · 3 columns
RNFIRST_NAMESALARY
1Sofia71400.00
2Tomas87250.00
3Grace92000.00
4Marcus96500.00
5Priya96500.00
6Lena104000.00
7Aisha118000.00
8Daniel131000.00

Marcus is row 4 and Priya is row 5. Keep that pair in mind. Every difference we are about to see comes from those two holding the same salary.

Let's run the same total with ROWS and with RANGE

Here is the same running total computed twice in one SELECT. Identical ORDER BY, identical bounds, identical function. The only difference between the two columns is the word ROWS against the word RANGE.

Query — ROWS and RANGE, same bounds
SELECT FIRST_NAME, SALARY,
       SUM(SALARY) OVER (ORDER BY SALARY
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS BY_ROWS,
       SUM(SALARY) OVER (ORDER BY SALARY
                         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_RANGE
FROM EMPLOYEES
ORDER BY SALARY, FIRST_NAME;
Result8 rows · 4 columns
FIRST_NAMESALARYBY_ROWSBY_RANGE
Sofia71400.0071400.0071400.00
Tomas87250.00158650.00158650.00
Grace92000.00250650.00250650.00
Marcus96500.00347150.00443650.00
Priya96500.00443650.00443650.00
Lena104000.00547650.00547650.00
Aisha118000.00665650.00665650.00
Daniel131000.00796650.00796650.00

Read the two columns down together. They agree on Sofia, Tomas and Grace.

They part company on Marcus’s row, the first half of the tie, where BY_RANGE is the larger number. On Priya’s row they agree again, and from Lena down they never differ again.

Look at what BY_RANGE does to that pair. Marcus and Priya get the same value, and it is the value BY_ROWS only reaches on the second of them.

So where does the difference come from?

The sums are not where the difference lives. The frames are. So let's measure both frames the way Episode 54 did, with COUNT(*) over each of them in the same query.

Query — the size of each frame
SELECT FIRST_NAME, SALARY,
       COUNT(*) OVER (ORDER BY SALARY
                      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS ROWS_FRAME_SIZE,
       COUNT(*) OVER (ORDER BY SALARY
                      RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RANGE_FRAME_SIZE
FROM EMPLOYEES
ORDER BY SALARY, FIRST_NAME;
Result8 rows · 4 columns
FIRST_NAMESALARYROWS_FRAME_SIZERANGE_FRAME_SIZE
Sofia71400.0011
Tomas87250.0022
Grace92000.0033
Marcus96500.0045
Priya96500.0055
Lena104000.0066
Aisha118000.0077
Daniel131000.0088

Row by row the two sizes match, until Marcus’s row. There ROWS_FRAME_SIZE is 4 and RANGE_FRAME_SIZE is 5. That single pair of cells explains every other difference on this page.

  • ROWS counts rows. CURRENT ROW means stop at this row, and Marcus is the fourth row, so his frame holds four.
  • RANGE counts values. CURRENT ROW means stop at this value, and Priya has the same value, so Priya is inside Marcus’s frame even though she comes after him.

Rows that share the current row’s ordering value are called its peers, and that is the whole rule in one sentence. RANGE pulls in every peer, in both directions.

Which is also why both halves of a tie always get the same answer from RANGE. Their frames are the same frame.

Let's count it instead of eyeballing it

Eyeballing eight rows is fine. Eyeballing a production table is not, so let's ask Snowflake how many rows the two columns disagree on.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(BY_ROWS = BY_RANGE)  AS ROWS_WHERE_THEY_AGREE,
       COUNT_IF(BY_ROWS <> BY_RANGE) AS ROWS_WHERE_THEY_DIFFER
FROM (
  SELECT SUM(SALARY) OVER (ORDER BY SALARY
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS BY_ROWS,
         SUM(SALARY) OVER (ORDER BY SALARY
                           RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_RANGE
  FROM EMPLOYEES);
Result1 row · 3 columns
ROWS_TOTALROWS_WHERE_THEY_AGREEROWS_WHERE_THEY_DIFFER
871

Eight rows, seven agreements, one disagreement. The whole of this post is that one row. On a table where the ordering column has thousands of repeats it would be most of them.

What if the ORDER BY has no ties?

Then the two keywords cannot differ at all. If the ORDER BY has no duplicate values, no row has any peers, and there is nothing for RANGE to pull in.

Let's run the same pair of running totals ordered by EMP_ID instead of SALARY. The salaries are unchanged and the tie is still in the table, but EMP_ID is unique, so the tie is not in the ORDER BY any more.

Query
SELECT EMP_ID, FIRST_NAME, SALARY,
       SUM(SALARY) OVER (ORDER BY EMP_ID
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS BY_ROWS,
       SUM(SALARY) OVER (ORDER BY EMP_ID
                         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_RANGE
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDFIRST_NAMESALARYBY_ROWSBY_RANGE
1Aisha118000.00118000.00118000.00
2Marcus96500.00214500.00214500.00
3Lena104000.00318500.00318500.00
4Tomas87250.00405750.00405750.00
5Priya96500.00502250.00502250.00
6Daniel131000.00633250.00633250.00
7Sofia71400.00704650.00704650.00
8Grace92000.00796650.00796650.00
Query — counted
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(BY_ROWS <> BY_RANGE) AS ROWS_WHERE_THEY_DIFFER
FROM (
  SELECT SUM(SALARY) OVER (ORDER BY EMP_ID ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_ROWS,
         SUM(SALARY) OVER (ORDER BY EMP_ID RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_RANGE
  FROM EMPLOYEES);
Result1 row · 2 columns
ROWS_TOTALROWS_WHERE_THEY_DIFFER
80

Identical on every row, and no disagreements at all. RANGE only ever bites when your ORDER BY contains duplicates. The tie has to be in the ordering expression, not merely somewhere in the table.

The question to ask about your own query

This is also the practical way to decide whether you need to care. Can the ordering expression repeat? If it is a surrogate key or a unique timestamp, the two keywords are interchangeable and you can write whichever you like. If it is a salary, a date, a score or an amount, they are not, and the choice is a real one.

So which one do we get when we write no frame at all?

This is the part that catches people out, because it is silent. When we write ORDER BY inside OVER and no frame clause, we still get a frame. Snowflake supplies one. Which one?

Let's put three columns in one query, all with the same ordering. The bare default, then the same thing with RANGE spelled out, then with ROWS spelled out.

Query
SELECT FIRST_NAME, SALARY,
       SUM(SALARY) OVER (ORDER BY SALARY)                                   AS DEFAULT_FRAME,
       SUM(SALARY) OVER (ORDER BY SALARY RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS EXPLICIT_RANGE,
       SUM(SALARY) OVER (ORDER BY SALARY ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS EXPLICIT_ROWS
FROM EMPLOYEES
ORDER BY SALARY, FIRST_NAME;
Result8 rows · 5 columns
FIRST_NAMESALARYDEFAULT_FRAMEEXPLICIT_RANGEEXPLICIT_ROWS
Sofia71400.0071400.0071400.0071400.00
Tomas87250.00158650.00158650.00158650.00
Grace92000.00250650.00250650.00250650.00
Marcus96500.00443650.00443650.00347150.00
Priya96500.00443650.00443650.00443650.00
Lena104000.00547650.00547650.00547650.00
Aisha118000.00665650.00665650.00665650.00
Daniel131000.00796650.00796650.00796650.00

DEFAULT_FRAME and EXPLICIT_RANGE are the same column. EXPLICIT_ROWS is the odd one out, and it is the odd one out on exactly the row we would expect by now, Marcus’s.

Reading two columns and declaring them identical is eyeballing again, so let's do it as a set operation. MINUS returns the rows of the first query that are not in the second.

Query
SELECT FIRST_NAME, SUM(SALARY) OVER (ORDER BY SALARY) FROM EMPLOYEES
MINUS
SELECT FIRST_NAME, SUM(SALARY) OVER (ORDER BY SALARY
                                     RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
FROM EMPLOYEES;
Result0 rows · 2 columns
FIRST_NAMESUM(SALARY) OVER (ORDER BY SALARY)

Nothing comes back. The default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

The silent one

A running total written as SUM(SALARY) OVER (ORDER BY SALARY) is a RANGE running total. If your ordering column has duplicates and you expected row-by-row accumulation, you are already getting something else, and it will look right on every row that happens not to be tied. If you want ROWS, write ROWS.

So which one should we use?

Neither keyword is the right one. They answer different questions, and the way to choose is to work out which question we are actually asking.

A question about values

What is the total payroll at or below this salary? That is a question about a salary, not about a row, so it wants RANGE.

Query — RANGE
SELECT FIRST_NAME, SALARY,
       SUM(SALARY) OVER (ORDER BY SALARY
                         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS PAYROLL_AT_OR_BELOW
FROM EMPLOYEES
ORDER BY SALARY, FIRST_NAME;
Result8 rows · 3 columns
FIRST_NAMESALARYPAYROLL_AT_OR_BELOW
Sofia71400.0071400.00
Tomas87250.00158650.00
Grace92000.00250650.00
Marcus96500.00443650.00
Priya96500.00443650.00
Lena104000.00547650.00
Aisha118000.00665650.00
Daniel131000.00796650.00

Marcus and Priya get the same number, and they have to. They earn the same, so the payroll at or below their salary is one figure, not two. A smaller number for Marcus would be a wrong answer to this question.

A question about positions

What is the sum of this row and the two rows before it? That is a question about position in a sequence, so it wants ROWS. It also needs a tiebreak in the ORDER BY so that “before” is well defined at all.

Query — ROWS, with EMP_ID as the tiebreak
SELECT FIRST_NAME, SALARY,
       SUM(SALARY) OVER (ORDER BY SALARY, EMP_ID
                         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS LAST_THREE_ROWS
FROM EMPLOYEES
ORDER BY SALARY, EMP_ID;
Result8 rows · 3 columns
FIRST_NAMESALARYLAST_THREE_ROWS
Sofia71400.0071400.00
Tomas87250.00158650.00
Grace92000.00250650.00
Marcus96500.00275750.00
Priya96500.00285000.00
Lena104000.00297000.00
Aisha118000.00318500.00
Daniel131000.00353000.00

Here Marcus and Priya get different numbers, and that is equally correct, because they are different rows with different rows behind them.

ROWSRANGE
The bounds countrowsvalues
CURRENT ROW meansstop at this rowstop at this value, peers included
Tied rows getdifferent framesthe same frame
Frame size on a tieas askedas asked, plus the peers
Answers“the last n rows”“at or below this value”
Accepts a numeric offsetno, counts onlyyes, a band of values
Is the default when you write only ORDER BYnoyes

So the test is one question. Is your question about values, or about positions? Payroll at or below a salary is values. The last three rows is positions.

Now let's try a band of values

So far RANGE has only been a different reading of the same bounds. It can also do something ROWS has no way of expressing. It can take a number as the offset, so the frame becomes a band of values rather than a count of rows.

RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING is everybody whose salary is within 10,000 of mine, in either direction. The frame is measured in currency, not in rows.

Query
SELECT FIRST_NAME, SALARY,
       COUNT(*)    OVER (ORDER BY SALARY RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING) AS PEERS_WITHIN_10K,
       MIN(SALARY) OVER (ORDER BY SALARY RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING) AS BAND_FROM,
       MAX(SALARY) OVER (ORDER BY SALARY RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING) AS BAND_TO
FROM EMPLOYEES
ORDER BY SALARY, FIRST_NAME;
Result8 rows · 5 columns
FIRST_NAMESALARYPEERS_WITHIN_10KBAND_FROMBAND_TO
Sofia71400.00171400.0071400.00
Tomas87250.00487250.0096500.00
Grace92000.00487250.0096500.00
Marcus96500.00587250.00104000.00
Priya96500.00587250.00104000.00
Lena104000.00396500.00104000.00
Aisha118000.001118000.00118000.00
Daniel131000.001131000.00131000.00

PEERS_WITHIN_10K is the size of each band, and BAND_FROM and BAND_TO are its two ends. That is what makes the grid readable.

We can see that Marcus and Priya share the widest band, that Tomas and Grace share an identical band without sharing a salary, and that three people are alone in theirs: the lowest paid, the second highest and the highest. Nobody else sits within 10,000 of them.

We cannot write that with ROWS. ROWS counts positions, and a position knows nothing about how far apart two salaries are. 2 PRECEDING gives us the two rows above us whether their salaries are next to ours or nowhere near them.

What a numeric offset requires

A numeric offset needs something it can subtract from, so the ORDER BY inside OVER has to be a single expression of a type that supports it. A number, or a date or timestamp with an INTERVAL offset. Episode 56 uses the interval form for a rolling window measured in days.

Let's finish with the same pair on dates

One last pair, on the ORDERS table. We take daily totals with a cumulative sum over them, computed both ways. The query carries its own LIMIT 12, so the grid is the first stretch of the series rather than all of it.

Sample data
-- Creates the ORDERS table the daily-totals query on this page uses.
-- Part of the shared sample-data script; run it once.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;

CREATE OR REPLACE TABLE ORDERS (
    ORDER_ID     INT,
    CUSTOMER_ID  INT,
    ORDER_DATE   DATE,
    SHIP_DATE    DATE,
    STATUS       VARCHAR,
    AMOUNT       NUMBER(10,2),
    QUANTITY     INT
);

INSERT INTO ORDERS VALUES
    (1001, 101, '2023-01-12', '2023-01-15', 'SHIPPED',   4200.00,  12),
    (1002, 102, '2023-01-28', '2023-02-02', 'SHIPPED',    890.50,   3),
    (1003, 101, '2023-02-14', '2023-02-16', 'SHIPPED',   6150.00,  20),
    (1004, 103, '2023-02-27', NULL,         'CANCELLED', 1500.00,   5),
    (1005, 104, '2023-03-09', '2023-03-14', 'SHIPPED',   2310.75,   8),
    (1006, 101, '2023-03-22', '2023-03-24', 'SHIPPED',   5020.00,  16),
    (1007, 105, '2023-04-05', '2023-04-11', 'SHIPPED',   7890.00,  25),
    (1008, 102, '2023-04-19', '2023-04-23', 'SHIPPED',   1120.00,   4),
    (1009, 103, '2023-05-02', '2023-05-06', 'SHIPPED',   3340.25,  11),
    (1010, 106, '2023-05-21', NULL,         'PENDING',    640.00,   2),
    (1011, 101, '2023-06-08', '2023-06-10', 'SHIPPED',   8100.00,  27),
    (1012, 104, '2023-06-25', '2023-06-30', 'SHIPPED',   1975.50,   7),
    (1013, 105, '2023-07-11', '2023-07-15', 'SHIPPED',   6420.00,  21),
    (1014, 102, '2023-07-29', '2023-08-03', 'RETURNED',   750.00,   3),
    (1015, 107, '2023-08-14', '2023-08-17', 'SHIPPED',   9250.00,  30),
    (1016, 103, '2023-08-30', '2023-09-04', 'SHIPPED',   2860.00,   9),
    (1017, 101, '2023-09-16', '2023-09-19', 'SHIPPED',   5540.00,  18),
    (1018, 106, '2023-10-03', '2023-10-08', 'SHIPPED',   1430.00,   5),
    (1019, 105, '2023-10-22', '2023-10-26', 'SHIPPED',   7015.00,  23),
    (1020, 107, '2023-11-07', '2023-11-10', 'SHIPPED',  10400.00,  34),
    (1021, 102, '2023-11-24', '2023-11-29', 'SHIPPED',    980.00,   4),
    (1022, 104, '2023-12-09', NULL,         'CANCELLED', 2250.00,   8),
    (1023, 101, '2023-12-27', '2023-12-30', 'SHIPPED',   6730.00,  22),
    (1024, 103, '2024-01-15', '2024-01-18', 'SHIPPED',   3910.00,  13),
    (1025, 107, '2024-01-31', '2024-02-05', 'SHIPPED',  11200.00,  36),
    (1026, 105, '2024-02-17', '2024-02-21', 'SHIPPED',   8340.00,  27),
    (1027, 106, '2024-03-04', '2024-03-09', 'SHIPPED',   1690.00,   6),
    (1028, 102, '2024-03-21', '2024-03-25', 'SHIPPED',   1240.00,   5),
    (1029, 101, '2024-04-06', '2024-04-09', 'SHIPPED',   7460.00,  24),
    (1030, 109, '2024-04-23', '2024-04-28', 'SHIPPED',   5180.00,  17),
    (1031, 103, '2024-05-10', '2024-05-14', 'SHIPPED',   4025.00,  14),
    (1032, 107, '2024-05-28', '2024-06-01', 'SHIPPED',  12850.00,  41),
    (1033, 105, '2024-06-13', NULL,         'PENDING',   6900.00,  22),
    (1034, 109, '2024-06-30', '2024-07-04', 'SHIPPED',   4760.00,  16),
    (1035, 101, '2024-07-18', '2024-07-21', 'SHIPPED',   8820.00,  29),
    (1036, 104, '2024-08-02', '2024-08-07', 'SHIPPED',   2540.00,   9),
    (1037, 107, '2024-08-25', '2024-08-29', 'SHIPPED',  13100.00,  43),
    (1038, 109, '2024-09-11', '2024-09-15', 'SHIPPED',   5930.00,  19),
    (1039, 102, '2024-09-29', '2024-10-03', 'RETURNED',  1080.00,   4),
    (1040, 101, '2024-10-16', '2024-10-19', 'SHIPPED',   9640.00,  31);

-- One deliberate NULL amount, inserted separately so it is easy to see why it
-- is there. It makes COUNT(*), COUNT(AMOUNT) and SUM() give different answers.
INSERT INTO ORDERS VALUES
    (1041, 106, '2024-11-05', NULL, 'PENDING', NULL, 3);
Query
WITH D AS (
  SELECT ORDER_DATE, SUM(AMOUNT) AS DAY_TOTAL FROM ORDERS GROUP BY ORDER_DATE
)
SELECT ORDER_DATE, DAY_TOTAL,
       SUM(DAY_TOTAL) OVER (ORDER BY ORDER_DATE ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_ROWS,
       SUM(DAY_TOTAL) OVER (ORDER BY ORDER_DATE RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS BY_RANGE
FROM D
ORDER BY ORDER_DATE
LIMIT 12;
Result12 rows · 4 columns
ORDER_DATEDAY_TOTALBY_ROWSBY_RANGE
2023-01-124200.004200.004200.00
2023-01-28890.505090.505090.50
2023-02-146150.0011240.5011240.50
2023-02-271500.0012740.5012740.50
2023-03-092310.7515051.2515051.25
2023-03-225020.0020071.2520071.25
2023-04-057890.0027961.2527961.25
2023-04-191120.0029081.2529081.25
2023-05-023340.2532421.5032421.50
2023-05-21640.0033061.5033061.50
2023-06-088100.0041161.5041161.50
2023-06-251975.5043137.0043137.00

Identical, twelve rows out of twelve. On its own that proves nothing at all. A dozen agreeing rows is exactly what we would see on tied data too, as long as the tie happened to be further down.

So let's not leave it hanging. We ask whether this ordering column has any duplicates in it.

Query — does any order date repeat?
SELECT ORDER_DATE, COUNT(*) AS N
FROM ORDERS GROUP BY ORDER_DATE HAVING COUNT(*) > 1 ORDER BY ORDER_DATE;
Result0 rows · 2 columns
ORDER_DATEN

No rows, so no ORDER_DATE appears twice anywhere in ORDERS. That is the reason the two columns match, and it is a fact about this data rather than a property of RANGE.

The agreement holds for the whole series, not just the twelve rows shown, because there is no tie anywhere for RANGE to pull in.

What changes the day the data does

Put two orders on the same date, which real order tables do constantly, and those two columns come apart on exactly the rows that share a date. Nothing about the query changed. The data changed. A frame you have only ever tested on unique ordering values has not been tested.

Key points

  • ROWS counts rows. RANGE counts values. The bound words are identical.
  • Under RANGE, CURRENT ROW means this value, so every row tying with the current row, its peers, is inside the frame, in both directions.
  • Both halves of a tie therefore get the same RANGE answer, because they get the same frame.
  • They can only differ where the ORDER BY contains duplicates. Order by something unique and the two are interchangeable.
  • The default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, not ROWS. Writing only ORDER BY inside OVER gives you RANGE.
  • Ask whether your question is about values (“at or below this salary”) or about positions (“the last three rows”). Both answers are correct; only one of them answers your question.
  • RANGE accepts a numeric or interval offset, which makes the frame a band of values. ROWS cannot express that at all.
  • A numeric offset needs a single ordering expression that can be subtracted from: a number, or a date or timestamp with an INTERVAL.
  • Columns that agree on your data are not a property of the keywords. Check whether the ordering column can repeat before you conclude anything.
Next in this series

Episode 56 — moving averages and rolling windows. Frames doing real work on a time series, the three things that go wrong on real data, and RANGE with an INTERVAL offset for a rolling window measured in days rather than in rows.

Every query and every result grid on this page was captured from a live Snowflake account (version 10.34.101) running the demo script for this episode. The EMPLOYEES and ORDERS data is invented demo data created by that script. No performance or benchmark claims are made anywhere in this series. Frame behaviour follows the Snowflake window functions documentation and the window frame syntax reference.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.