In this post we are going to cover LAST_VALUE in Snowflake, together with FIRST_VALUE and NTH_VALUE.
FIRST_VALUE reaches to the start of the window and hands us a value from that row. LAST_VALUE reaches to the end of it. NTH_VALUE reaches to whichever position we ask for.
There is a very famous warning about LAST_VALUE: that it gives you the current row instead of the last one. It is true in several databases and it is not what Snowflake does. We will show that three different ways, and then write the frame that makes it happen anyway.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 53 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's see what the partitions look like first
- Let's start with FIRST_VALUE and take the top of the window
- Now let's try LAST_VALUE at the other end
- Can we prove that twice more?
- So where is the bug? Let's write the frame ourselves
- Can FIRST_VALUE fall into the same trap?
- Now let's change the frame on purpose
- Let's take the easy way out and reverse the ORDER BY
- Now let's try NTH_VALUE for any position we like
- Let's finish with both ends at once, on a real table
Let's create some sample data
Most of this post runs against EMPLOYEES, partitioned by department. The script below creates the table and puts eight rows into it.
Run it once in a worksheet. At the end we will also use the shared ORDERS table, which Episode 52 sets up in full.
-- EMPLOYEES comes from Episode 1 and ORDERS from Level 3. Every episode in
-- this series reuses the same tables, so you only set them up 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
);
-- Grace's DEPT_ID is NULL on purpose. PARTITION BY DEPT_ID gives her a
-- partition of her own, which is the single-row window every function on this
-- page has to cope with.
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');One employee has no DEPT_ID at all. That is on purpose. PARTITION BY DEPT_ID gives her a partition of her own, and a one-row window is the thing every function on this page has to cope with.
Let's see what the partitions look like first
Before we reach into a window it helps to know what the windows look like. That is a plain GROUP BY, with no window functions involved at all.
SELECT DEPT_ID, COUNT(*) AS HEADCOUNT, MAX(SALARY) AS TOP_IN_DEPT, MIN(SALARY) AS BOTTOM_IN_DEPT
FROM EMPLOYEES GROUP BY DEPT_ID ORDER BY DEPT_ID NULLS LAST;| DEPT_ID | HEADCOUNT | TOP_IN_DEPT | BOTTOM_IN_DEPT |
|---|---|---|---|
| 10 | 3 | 131000.00 | 96500.00 |
| 20 | 2 | 104000.00 | 87250.00 |
| 30 | 2 | 96500.00 | 71400.00 |
| NULL | 1 | 92000.00 | 92000.00 |
Four partitions. Three of them hold more than one person and one holds a single row, the employee whose DEPT_ID is NULL. She gets a partition to herself, because PARTITION BY groups nulls together exactly as GROUP BY does.
Keep TOP_IN_DEPT and BOTTOM_IN_DEPT in view. Those eight cells are what the window functions have to reproduce, and this is the department summary we will check everything against.
Let's start with FIRST_VALUE and take the top of the window
FIRST_VALUE does what the name says. It returns the value of its argument from the first row of the window.
So let's order the window by salary descending. The first row of each department is then its highest earner.
SELECT DEPT_ID, FIRST_NAME, SALARY,
FIRST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS TOP_IN_DEPT
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | TOP_IN_DEPT |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 131000.00 |
| 10 | Marcus | 96500.00 | 131000.00 |
| 20 | Lena | 104000.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 104000.00 |
| 30 | Priya | 96500.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 96500.00 |
| NULL | Grace | 92000.00 | 92000.00 |
TOP_IN_DEPT is constant inside each department and it is repeated on every row of it. That is the whole point of a window function rather than a GROUP BY. The aggregate-like answer arrives beside each row instead of replacing the rows.
Now compare that column against the TOP_IN_DEPT column of the department summary we built with GROUP BY. Same four numbers, worked out two completely different ways. One used MAX over groups. This one reached for the first row of an ordered window.
That equivalence only holds because the window is ordered by the same column the aggregate is taken over. FIRST_VALUE(SALARY) over ORDER BY HIRE_DATE is the earliest hire’s salary, which has nothing to do with MAX(SALARY). FIRST_VALUE answers “what was the value on the first row”, not “what was the largest value”. That is why it can fetch a column you are not sorting on at all.
Now let's try LAST_VALUE at the other end
There is a very well-known warning about LAST_VALUE, repeated in most SQL tutorials and true in several other databases. It says that LAST_VALUE written plainly does not give you the last row of the partition at all. It gives you the current row.
So let's write it plainly and see. Same partition, same ordering as the FIRST_VALUE query, no frame written out.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS BOTTOM_IN_DEPT
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | BOTTOM_IN_DEPT |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 96500.00 |
| 10 | Aisha | 118000.00 | 96500.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
That is the bottom of each partition, not the current row. Read the first department. All of its rows carry the same number, and it is the lowest salary in that department. It is the BOTTOM_IN_DEPT value from the department summary. The famous bug is not here.
The reason is the window frame, and this is the one thing to carry away from this post. A window function with an ORDER BY does not necessarily see the whole partition. It sees a frame inside it.
For FIRST_VALUE, LAST_VALUE and NTH_VALUE, Snowflake’s default frame is the entire partition. That is the same as writing ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. In the ANSI standard, and in the databases the warning comes from, the default stops at the current row instead, and that is precisely what produces the bug there.
That is a claim we can test, so let's test it. We write the full frame out and see whether anything changes.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS BOTTOM_IN_DEPT
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | BOTTOM_IN_DEPT |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 96500.00 |
| 10 | Aisha | 118000.00 | 96500.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
Identical column. And rather than compare two grids by eye, let's put both expressions in one SELECT so the two columns sit side by side on every row.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS PLAIN,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS WITH_FULL_FRAME
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | PLAIN | WITH_FULL_FRAME |
|---|---|---|---|---|
| 10 | Daniel | 131000.00 | 96500.00 | 96500.00 |
| 10 | Aisha | 118000.00 | 96500.00 | 96500.00 |
| 10 | Marcus | 96500.00 | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 | 92000.00 |
PLAIN and WITH_FULL_FRAME agree on every row. In Snowflake, for these functions, writing the full frame out changes nothing. It only makes the behaviour visible to whoever reads the query next.
Can we prove that twice more?
Two grids agreeing is the kind of thing a reader is entitled to be suspicious of, especially when it contradicts what they have been told. So let's count it instead of eyeballing it.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(PLAIN = WITH_FULL_FRAME) AS ROWS_WHERE_THEY_AGREE,
COUNT_IF(PLAIN <> WITH_FULL_FRAME) AS ROWS_WHERE_THEY_DIFFER
FROM (
SELECT LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS PLAIN,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS WITH_FULL_FRAME
FROM EMPLOYEES);| ROWS_TOTAL | ROWS_WHERE_THEY_AGREE | ROWS_WHERE_THEY_DIFFER |
|---|---|---|
| 8 | 8 | 0 |
COUNT_IF counts the rows where its predicate is true. Every row agrees, and none differ.
And the third way. We treat the two results as sets and subtract one from the other.
SELECT FIRST_NAME, LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC)
FROM EMPLOYEES
MINUS
SELECT FIRST_NAME, LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
FROM EMPLOYEES;| FIRST_NAME | LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) |
|---|
No rows. So there is no (name, value) pair the plain version produces that the fully-framed version does not. Three different angles, one answer: plain LAST_VALUE in Snowflake returns the last row of the partition.
This is also a decent template for checking any claim about SQL semantics. Put the two expressions on one row and look, count the rows where they differ, then subtract the results as sets. The second and third are the ones you can run against a table too wide and too long to read.
So where is the bug? Let's write the frame ourselves
The famous behaviour is real. It is just not the default, so we have to ask for it.
The frame people habitually write is a cumulative one: from the start of the partition up to the current row. Let's write it with RANGE, which is what an ORDER BY implies by default.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS X
FROM EMPLOYEES;002303 (0A000): SQL compilation error: error line 3 at position 32 Cumulative window frame unsupported for function LAST_VALUE
Cumulative window frame unsupported for function LAST_VALUE. It is a compilation error, so nothing runs and nothing wrong comes back. Snowflake has declined to answer a question whose answer would be useless.
Now let's write the same frame with ROWS instead of RANGE.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS X
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | X |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 118000.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
There is the famous answer. Every row returns its own salary.
With the frame written out in the query itself it is not mysterious at all. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is a window that ends at the current row, so the last row of everything up to me is, inevitably, me. The function is behaving perfectly. The frame was the wrong question.
Let's put both versions on one grid to make the contrast permanent.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS DEFAULT_FRAME,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS CUMULATIVE_ROWS
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | DEFAULT_FRAME | CUMULATIVE_ROWS |
|---|---|---|---|---|
| 10 | Daniel | 131000.00 | 96500.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 96500.00 | 118000.00 |
| 10 | Marcus | 96500.00 | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 | 92000.00 |
DEFAULT_FRAME is the bottom of the department on every row. CUMULATIVE_ROWS is the row’s own salary on every row. The two columns agree on exactly the rows that are the bottom of their own department, and differ everywhere else.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(DEFAULT_FRAME <> CUMULATIVE_ROWS) AS ROWS_WHERE_THEY_DIFFER,
COUNT_IF(CUMULATIVE_ROWS = SALARY) AS ROWS_WHERE_CUMULATIVE_IS_THIS_ROW
FROM (
SELECT SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS DEFAULT_FRAME,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS CUMULATIVE_ROWS
FROM EMPLOYEES);| ROWS_TOTAL | ROWS_WHERE_THEY_DIFFER | ROWS_WHERE_CUMULATIVE_IS_THIS_ROW |
|---|---|---|
| 8 | 4 | 8 |
Read that row left to right. 8 rows in total, 4 of them where the two frames disagree, and ROWS_WHERE_CUMULATIVE_IS_THIS_ROW equal to the row count. The cumulative column is the current row’s own salary on every single row, not just on the four where it diverges.
If you meet this bug in Snowflake, you wrote a cumulative ROWS frame. That is the whole diagnosis. The frame is often copied in from a tutorial written for another database, or inherited from a named WINDOW clause shared with a running total, where a cumulative frame is exactly right and for LAST_VALUE is exactly wrong.
Can FIRST_VALUE fall into the same trap?
It cannot, and the reason is worth a sentence. Every cumulative frame starts at the first row of the partition. So the first row of the frame is the first row of the partition no matter where the frame ends. Shrinking the far end cannot move the near end.
Which also means FIRST_VALUE is allowed the cumulative RANGE frame that LAST_VALUE was refused. Let's try it.
SELECT DEPT_ID, FIRST_NAME, SALARY,
FIRST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS X
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | X |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 131000.00 |
| 10 | Marcus | 96500.00 | 131000.00 |
| 20 | Lena | 104000.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 104000.00 |
| 30 | Priya | 96500.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 96500.00 |
| NULL | Grace | 92000.00 | 92000.00 |
Accepted, and the column is the same one plain FIRST_VALUE gave us. Now the ROWS variant, which was the frame that broke LAST_VALUE.
SELECT DEPT_ID, FIRST_NAME, SALARY,
FIRST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS X
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | X |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 131000.00 |
| 10 | Marcus | 96500.00 | 131000.00 |
| 20 | Lena | 104000.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 104000.00 |
| 30 | Priya | 96500.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 96500.00 |
| NULL | Grace | 92000.00 | 92000.00 |
The same column again. Three different frames, one answer. FIRST_VALUE is the quietly safe one of the pair, and LAST_VALUE is the one to think about.
Now let's change the frame on purpose
None of this makes frames a trap to avoid. A frame is how we say which rows the function may look at, and the answer changing when the frame changes is the feature, not the bug.
Here is a sliding three-row window: the row before, this row, and the row after.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS X
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | X |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 118000.00 |
| 10 | Aisha | 118000.00 | 96500.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
Now LAST_VALUE returns the next row’s salary, because in a window of three rows ending one row below the current one, that genuinely is the last row.
And on the bottom row of each department there is no next row. The frame stops short and the function returns that row’s own value.
One more, and it shows how differently RANGE behaves from ROWS. Same sliding idea, but measured in salary rather than in rows.
SELECT DEPT_ID, FIRST_NAME, SALARY,
LAST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING) AS X
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | X |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 131000.00 |
| 10 | Aisha | 118000.00 | 118000.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 104000.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 96500.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
Every row returns its own salary, and this time it has nothing to do with cumulative frames. RANGE offsets are measured in the ORDER BY column’s own units, so this frame means every row whose salary is within 10,000 of mine.
Read the SALARY column department by department and you can see why. Inside each department of this table no two salaries are that close. So every frame contains exactly one row, and the last row of a one-row window is that row.
ROWS counts rows. RANGE compares values. The same offsets written with the two keywords are different questions, and on data with duplicate ordering values they return different answers even when the offsets look the same. That comparison gets a whole episode of its own shortly.
Let's take the easy way out and reverse the ORDER BY
Here is what to write in practice, and it sidesteps every frame on this page. If we want the bottom of the partition, we do not reach for the end of the window. We turn the window around and reach for its start.
SELECT DEPT_ID, FIRST_NAME, SALARY,
FIRST_VALUE(SALARY) OVER (PARTITION BY DEPT_ID ORDER BY SALARY ASC) AS BOTTOM_IN_DEPT
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | BOTTOM_IN_DEPT |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 96500.00 |
| 10 | Aisha | 118000.00 | 96500.00 |
| 10 | Marcus | 96500.00 | 96500.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | 92000.00 |
FIRST_VALUE with ORDER BY SALARY ASC returns the same four numbers plain LAST_VALUE gave us, which are the BOTTOM_IN_DEPT values from the department summary.
The statement’s own trailing ORDER BY still presents the rows highest-paid first. That is the independence of the two ORDER BY clauses doing something useful.
Both forms are correct in Snowflake. This one has the advantage that there is nothing to remember and nothing for a reviewer to second-guess. No frame, no default to know about, and no tutorial from another database to argue with.
Now let's try NTH_VALUE for any position we like
NTH_VALUE generalises both of the others. We give it a position and it returns that row of the window.
So the second-highest salary in each department is position 2 over a descending window.
SELECT DEPT_ID, FIRST_NAME, SALARY,
NTH_VALUE(SALARY, 2) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS SECOND_HIGHEST
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | SECOND_HIGHEST |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 118000.00 |
| 10 | Aisha | 118000.00 | 118000.00 |
| 10 | Marcus | 96500.00 | 118000.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | NULL |
Each multi-person department reports its own second-highest salary, repeated on every row of that department. And the single-row partition reports NULL. She is alone, so there is no second row to fetch, and NTH_VALUE says so instead of inventing something.
That query writes the full frame out. Let's leave the frame off and see whether the same default applies.
SELECT DEPT_ID, FIRST_NAME, SALARY,
NTH_VALUE(SALARY, 2) OVER (PARTITION BY DEPT_ID ORDER BY SALARY DESC) AS SECOND_HIGHEST
FROM EMPLOYEES
ORDER BY DEPT_ID NULLS LAST, SALARY DESC;| DEPT_ID | FIRST_NAME | SALARY | SECOND_HIGHEST |
|---|---|---|---|
| 10 | Daniel | 131000.00 | 118000.00 |
| 10 | Aisha | 118000.00 | 118000.00 |
| 10 | Marcus | 96500.00 | 118000.00 |
| 20 | Lena | 104000.00 | 87250.00 |
| 20 | Tomas | 87250.00 | 87250.00 |
| 30 | Priya | 96500.00 | 71400.00 |
| 30 | Sofia | 71400.00 | 71400.00 |
| NULL | Grace | 92000.00 | NULL |
The same column, including the null. The default window for all three of these functions is the whole partition.
NTH_VALUE needs a constant position, and positions count from 1. So NTH_VALUE(X, 1) is FIRST_VALUE(X). If you find yourself wanting a position computed per row, what you want is a ranking function and a filter, not this.
Let's finish with both ends at once, on a real table
One last query, on ORDERS, the shared sample table from Level 3 that Episode 52 sets up in full.
We put FIRST_VALUE and LAST_VALUE on the same window, partitioned by customer and ordered by date. Then every order line carries the amount of that customer’s first order and their most recent one.
SELECT CUSTOMER_ID, ORDER_ID, ORDER_DATE, AMOUNT,
FIRST_VALUE(AMOUNT) OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS FIRST_ORDER_AMOUNT,
LAST_VALUE(AMOUNT) OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
AS LAST_ORDER_AMOUNT
FROM ORDERS
ORDER BY CUSTOMER_ID, ORDER_DATE
LIMIT 14;| CUSTOMER_ID | ORDER_ID | ORDER_DATE | AMOUNT | FIRST_ORDER_AMOUNT | LAST_ORDER_AMOUNT |
|---|---|---|---|---|---|
| 101 | 1001 | 2023-01-12 | 4200.00 | 4200.00 | 9640.00 |
| 101 | 1003 | 2023-02-14 | 6150.00 | 4200.00 | 9640.00 |
| 101 | 1006 | 2023-03-22 | 5020.00 | 4200.00 | 9640.00 |
| 101 | 1011 | 2023-06-08 | 8100.00 | 4200.00 | 9640.00 |
| 101 | 1017 | 2023-09-16 | 5540.00 | 4200.00 | 9640.00 |
| 101 | 1023 | 2023-12-27 | 6730.00 | 4200.00 | 9640.00 |
| 101 | 1029 | 2024-04-06 | 7460.00 | 4200.00 | 9640.00 |
| 101 | 1035 | 2024-07-18 | 8820.00 | 4200.00 | 9640.00 |
| 101 | 1040 | 2024-10-16 | 9640.00 | 4200.00 | 9640.00 |
| 102 | 1002 | 2023-01-28 | 890.50 | 890.50 | 1080.00 |
| 102 | 1008 | 2023-04-19 | 1120.00 | 890.50 | 1080.00 |
| 102 | 1014 | 2023-07-29 | 750.00 | 890.50 | 1080.00 |
| 102 | 1021 | 2023-11-24 | 980.00 | 890.50 | 1080.00 |
| 102 | 1028 | 2024-03-21 | 1240.00 | 890.50 | 1080.00 |
FIRST_ORDER_AMOUNT and LAST_ORDER_AMOUNT are constant within each customer’s block and change at the boundary between them.
The first customer’s block is complete inside the capture’s own LIMIT 14. It is 9 rows, and its first and last AMOUNT values are exactly what the two window columns report. The following block is cut off by that limit, so do not read it as that customer’s whole history.
That shape is immediately useful. First order beside latest order on one row gives us lifetime growth, reactivation and churn-risk reporting without a single subquery.
One thing to know before it surprises you
ORDERS contains exactly one row whose AMOUNT is NULL, deliberately, and it has been quietly relevant since Level 2.
SELECT ORDER_ID, CUSTOMER_ID, ORDER_DATE, STATUS, AMOUNT
FROM ORDERS WHERE AMOUNT IS NULL;| ORDER_ID | CUSTOMER_ID | ORDER_DATE | STATUS | AMOUNT |
|---|---|---|---|---|
| 1041 | 106 | 2024-11-05 | PENDING | NULL |
These functions return a value, not a row. If the first or last row of a window happens to be an order like that one, what comes back is NULL. Not the nearest row that has an amount, and not zero. That is correct behaviour and it is rarely what a dashboard wants.
All three functions accept IGNORE NULLS (the default is RESPECT NULLS), which tells them to skip rows whose value is null and keep looking. It changes which row the value comes from, so decide which you mean: the last order, or the last order that had an amount.
Key points
FIRST_VALUEandLAST_VALUEreturn a value from the first and last row of the frame, not of the partition. That is why the default frame decides what they mean.- In Snowflake the default frame for
FIRST_VALUE,LAST_VALUEandNTH_VALUEis the entire partition, not everything-up-to-the-current-row as in the ANSI standard. So plainLAST_VALUEcorrectly returns the bottom of the partition. - The famous “
LAST_VALUEgives you the current row” bug is real, but you have to writeROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWyourself to get it. The last row of everything up to me is me. - The same cumulative frame written with
RANGEis refused outright: cumulative window frame unsupported for functionLAST_VALUE. A compilation error, so no wrong answer escapes. FIRST_VALUEis immune, because every cumulative frame still starts at the first row of the partition.- Frames still work normally: a sliding frame makes
LAST_VALUEreturn the next row, and aRANGEframe measured in the ordering column’s units can reduce the window to a single row. - The simplest fix is not to use
LAST_VALUEat all. Reverse theORDER BYinsideOVERand useFIRST_VALUE. Same answer, nothing to remember. NTH_VALUE(X, n)takes a constant position counted from1, and returnsNULLwhen the window is shorter thannrows.- All three return a value, so a null in the row they reach comes back as a null.
IGNORE NULLSis the switch if you want the nearest row that has one.
Episode 54 — window frames properly. ROWS BETWEEN spelled out in full, what the default frame is for every other kind of window function, and running totals whose boundaries you choose rather than inherit.
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, including the compilation error, which is reproduced exactly as Snowflake returned it. The EMPLOYEES and ORDERS data is invented demo data created by that script. No performance or benchmark claims are made anywhere in this series. Function behaviour follows the FIRST_VALUE documentation, the LAST_VALUE documentation and the NTH_VALUE documentation.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.