This tutorial explains how to use SQL window functions like
FIRST_VALUE(), LAST_VALUE(), and NTH_VALUE() — also known as the
SQL first value, last value, and nth value functions.
You’ll learn how to return specific values from a window frame to compare each row
with the first, last, or nth row in its group.
"Xorthax doesn’t just want totals—he’s nosy. He wants to know what happened first, what came last, and what the third-highest bid was. These functions let you peek at specific rows in the window frame without collapsing data."
These functions let you return a specific value from within the window frame for each row.
FIRST_VALUE() returns the first value in the window frameLAST_VALUE() returns the last value in the window frameNTH_VALUE(n) returns the nth value in the frame (you specify n)They’re great for comparisons, flags, or “difference from benchmark” logic.
FIRST_VALUE(column) OVER (...)
LAST_VALUE(column) OVER (...)
NTH_VALUE(column, n) OVER (...)
Typically used with PARTITION BY and ORDER BY, and very often customized with a frame clause to control what “first” and “last” mean.
A frame clause defines which rows are included when a window function runs for the current row. It controls the “visible slice” of the partition.
Examples:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — includes all rows up to the current oneROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING — includes current row and all after it
This is critical for LAST_VALUE() and NTH_VALUE() — without it, they might return the current row’s value instead of the truly last or nth.
| CustomerID | OrderDate | Amount |
|---|---|---|
| C1 | 2023-01-01 | 100 |
| C1 | 2023-01-10 | 200 |
| C1 | 2023-01-20 | 300 |
SELECT CustomerID, OrderDate, Amount,
FIRST_VALUE(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS FirstAmt,
LAST_VALUE(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS LastAmt,
NTH_VALUE(Amount, 2) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS SecondAmt
FROM Orders;
| OrderDate | Amount | FirstAmt | LastAmt | SecondAmt |
|---|---|---|---|---|
| 2023-01-01 | 100 | 100 | 300 | NULL |
| 2023-01-10 | 200 | 100 | 300 | 200 |
| 2023-01-20 | 300 | 100 | 300 | 200 |
🔍 Explanation:
FIRST_VALUE is consistent for each row in the partitionLAST_VALUE needs a frame clause, otherwise it returns the “last so far” (same as current row)NTH_VALUE returns NULL if there aren’t enough rows in the frame yetFIRST_VALUE() when you need the starting point (first price, first date, first event)LAST_VALUE() to compare against the final row’s value — just make sure to define the full frame!NTH_VALUE(n) to grab a benchmark row (like “third purchase” or “fifth attempt”)
LAST_VALUE() — you’ll just get the current row’s valueNTH_VALUE() to always return a result — early rows won’t have enough dataFIRST_VALUE() always means the earliest in time — it depends on ORDER BYLAG() or LEAD(). You'll need to wrap them in COALESCE() or filter out NULLs manually.
Sometimes you want to compare each row’s value to the FIRST_VALUE() in its partition — for example, to measure growth or difference from a starting point.
| CustomerID | OrderDate | Amount |
|---|---|---|
| 1 | 2024-01-01 | 100 |
| 1 | 2024-02-01 | 120 |
| 1 | 2024-03-01 | 150 |
| 2 | 2024-01-15 | 200 |
| 2 | 2024-02-15 | 180 |
| 2 | 2024-03-15 | 250 |
SELECT
CustomerID,
OrderDate,
Amount,
FIRST_VALUE(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS FirstAmt,
Amount - FIRST_VALUE(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS DiffFromFirst
FROM Orders;
| CustomerID | OrderDate | Amount | FirstAmt | DiffFromFirst |
|---|---|---|---|---|
| 1 | 2024-01-01 | 100 | 100 | 0 |
| 1 | 2024-02-01 | 120 | 100 | 20 |
| 1 | 2024-03-01 | 150 | 100 | 50 |
| 2 | 2024-01-15 | 200 | 200 | 0 |
| 2 | 2024-02-15 | 180 | 200 | -20 |
| 2 | 2024-03-15 | 250 | 200 | 50 |
🔍 Explanation: The FIRST_VALUE() function assigns the earliest amount for each CustomerID to every row in that group.
Subtracting it from the current amount gives a simple measure of change since the first order.
💡 You can apply the same idea with LAST_VALUE() to compare against the final amount, or with NTH_VALUE() to reference any specific position in the ordered window.
Sometimes you want to combine window functions with aggregate functions — for example, to add row-level context (like a customer's first order date) before summarizing their full history.
FIRST_VALUE() in a CTE to anchor each customer’s first order date, then apply aggregates like COUNT(), MIN(), and MAX() to summarize activity.
WITH Anchored AS (
SELECT CustomerID,
OrderDate,
Amount,
FIRST_VALUE(OrderDate) OVER (
PARTITION BY CustomerID
ORDER BY OrderDate
) AS StartDate
FROM Orders
)
SELECT CustomerID,
MIN(OrderDate) AS FirstOrder,
MAX(OrderDate) AS LastOrder,
COUNT(*) AS TotalOrders,
MAX(OrderDate) - MIN(OrderDate) AS Duration
FROM Anchored
GROUP BY CustomerID;
🔍 Explanation: This pattern has two steps:
Anchored) uses FIRST_VALUE() to add each customer’s starting date to every row.⚠️ Important: You can’t mix window functions and aggregates directly in the same SELECT clause. For example, this version will fail:
SELECT CustomerID,
FIRST_VALUE(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS StartDate,
MIN(OrderDate) AS FirstOrder
FROM Orders
GROUP BY CustomerID;
❌ Error: “column 'orders.orderdate' must appear in the GROUP BY clause or be used in an aggregate function.”
That’s why the CTE (or a subquery) is necessary — it separates the windowing step from the aggregation step, allowing SQL to first compute the per-row window function and then safely collapse rows using aggregates.
LAST_VALUE() and NTH_VALUE()ORDER BY is sorting as you expectROW_NUMBER() in test queries to verify sequenceSee also: Frame Clauses, Aggregate Window Functions, LEAD / LAG