LAG and LEAD in SQL Server: Real-World Examples
LAG (column, offset, default) OVER (PARTITION BY ... ORDER BY ...) -> value from a PREVIOUS row
LEAD(column, offset, default) OVER (PARTITION BY ... ORDER BY ...) -> value from a NEXT row
Used for: day-over-day / month-over-month change, time between events,
detecting status changes, comparing with next value.
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic table: StockPrices (daily closing prices for 3 stocks)
DROP TABLE IF EXISTS dbo.StockPrices;
CREATE TABLE dbo.StockPrices
(
Symbol VARCHAR(10) NOT NULL,
TradeDate DATE NOT NULL,
ClosePrice DECIMAL(10,2) NOT NULL,
Volume INT NOT NULL,
PRIMARY KEY (Symbol, TradeDate)
);
INSERT INTO dbo.StockPrices (Symbol, TradeDate, ClosePrice, Volume) VALUES
('INFY', '2025-06-02', 1520.50, 51000), ('INFY', '2025-06-03', 1534.20, 48000),
('INFY', '2025-06-04', 1528.00, 39000), ('INFY', '2025-06-05', 1550.75, 62000),
('INFY', '2025-06-06', 1561.10, 58000), ('INFY', '2025-06-09', 1549.90, 41000),
('INFY', '2025-06-10', 1572.30, 70000), ('INFY', '2025-06-11', 1580.00, 66000),
('TCS', '2025-06-02', 3410.00, 22000), ('TCS', '2025-06-03', 3398.40, 25000),
('TCS', '2025-06-04', 3425.60, 27000), ('TCS', '2025-06-05', 3440.10, 30000),
('TCS', '2025-06-06', 3431.00, 21000), ('TCS', '2025-06-09', 3460.80, 35000),
('TCS', '2025-06-10', 3455.25, 19000), ('TCS', '2025-06-11', 3489.90, 40000),
('WIPRO','2025-06-02', 265.40, 90000), ('WIPRO','2025-06-03', 262.10, 95000),
('WIPRO','2025-06-04', 259.80, 99000), ('WIPRO','2025-06-05', 261.00, 87000),
('WIPRO','2025-06-06', 266.70,120000), ('WIPRO','2025-06-09', 270.20,110000),
('WIPRO','2025-06-10', 268.90, 85000), ('WIPRO','2025-06-11', 272.40,105000);
Messages(24 rows affected)
Topic table: TicketStatusLog (status history of support tickets)
DROP TABLE IF EXISTS dbo.TicketStatusLog;
CREATE TABLE dbo.TicketStatusLog
(
LogID INT IDENTITY(1,1) PRIMARY KEY,
TicketID INT NOT NULL,
Status VARCHAR(20) NOT NULL,
ChangedAt DATETIME2(0) NOT NULL
);
INSERT INTO dbo.TicketStatusLog (TicketID, Status, ChangedAt) VALUES
(101, 'Open', '2025-06-01 09:00'), (101, 'In Progress', '2025-06-01 11:30'),
(101, 'On Hold', '2025-06-02 10:00'), (101, 'In Progress', '2025-06-03 09:15'),
(101, 'Resolved', '2025-06-03 17:45'),
(102, 'Open', '2025-06-02 14:00'), (102, 'In Progress', '2025-06-02 14:20'),
(102, 'Resolved', '2025-06-02 16:00'),
(103, 'Open', '2025-06-03 08:00'), (103, 'In Progress', '2025-06-04 12:00'),
(103, 'Reopened', '2025-06-05 09:00'), (103, 'Resolved', '2025-06-06 18:30');
GO
Messages(12 rows affected)
Part A: Basic LAG and LEAD
SELECT Symbol, TradeDate, ClosePrice,
LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS PrevClose,
LEAD(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS NextClose,
LAG(ClosePrice, 2) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS Close2DaysAgo,
LAG(ClosePrice, 1, 0) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS PrevCloseDefault0
FROM dbo.StockPrices
ORDER BY Symbol, TradeDate;
Output
| Symbol | TradeDate | ClosePrice | PrevClose | NextClose | Close2DaysAgo | PrevCloseDefault0 |
| INFY | 2025-06-02 | 1520.50 | NULL | 1534.20 | NULL | 0.00 |
| INFY | 2025-06-03 | 1534.20 | 1520.50 | 1528.00 | NULL | 1520.50 |
| INFY | 2025-06-04 | 1528.00 | 1534.20 | 1550.75 | 1520.50 | 1534.20 |
| INFY | 2025-06-05 | 1550.75 | 1528.00 | 1561.10 | 1534.20 | 1528.00 |
| INFY | 2025-06-06 | 1561.10 | 1550.75 | 1549.90 | 1528.00 | 1550.75 |
| INFY | 2025-06-09 | 1549.90 | 1561.10 | 1572.30 | 1550.75 | 1561.10 |
| INFY | 2025-06-10 | 1572.30 | 1549.90 | 1580.00 | 1561.10 | 1549.90 |
| INFY | 2025-06-11 | 1580.00 | 1572.30 | NULL | 1549.90 | 1572.30 |
| TCS | 2025-06-02 | 3410.00 | NULL | 3398.40 | NULL | 0.00 |
| TCS | 2025-06-03 | 3398.40 | 3410.00 | 3425.60 | NULL | 3410.00 |
| TCS | 2025-06-04 | 3425.60 | 3398.40 | 3440.10 | 3410.00 | 3398.40 |
| TCS | 2025-06-05 | 3440.10 | 3425.60 | 3431.00 | 3398.40 | 3425.60 |
| TCS | 2025-06-06 | 3431.00 | 3440.10 | 3460.80 | 3425.60 | 3440.10 |
| TCS | 2025-06-09 | 3460.80 | 3431.00 | 3455.25 | 3440.10 | 3431.00 |
| TCS | 2025-06-10 | 3455.25 | 3460.80 | 3489.90 | 3431.00 | 3460.80 |
... showing 15 of 24 rows
Part B: Day-over-day Change
SELECT Symbol, TradeDate, ClosePrice,
ClosePrice - LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS ChangeAmt,
CAST(100.0 * (ClosePrice - LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate))
/ LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS DECIMAL(6,2)) AS ChangePct,
CASE WHEN ClosePrice > LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) THEN 'UP'
WHEN ClosePrice < LAG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) THEN 'DOWN'
ELSE '-' END AS Direction
FROM dbo.StockPrices
ORDER BY Symbol, TradeDate;
Output
| Symbol | TradeDate | ClosePrice | ChangeAmt | ChangePct | Direction |
| INFY | 2025-06-02 | 1520.50 | NULL | NULL | - |
| INFY | 2025-06-03 | 1534.20 | 13.70 | 0.90 | UP |
| INFY | 2025-06-04 | 1528.00 | -6.20 | -0.40 | DOWN |
| INFY | 2025-06-05 | 1550.75 | 22.75 | 1.49 | UP |
| INFY | 2025-06-06 | 1561.10 | 10.35 | 0.67 | UP |
| INFY | 2025-06-09 | 1549.90 | -11.20 | -0.72 | DOWN |
| INFY | 2025-06-10 | 1572.30 | 22.40 | 1.45 | UP |
| INFY | 2025-06-11 | 1580.00 | 7.70 | 0.49 | UP |
| TCS | 2025-06-02 | 3410.00 | NULL | NULL | - |
| TCS | 2025-06-03 | 3398.40 | -11.60 | -0.34 | DOWN |
| TCS | 2025-06-04 | 3425.60 | 27.20 | 0.80 | UP |
| TCS | 2025-06-05 | 3440.10 | 14.50 | 0.42 | UP |
| TCS | 2025-06-06 | 3431.00 | -9.10 | -0.26 | DOWN |
| TCS | 2025-06-09 | 3460.80 | 29.80 | 0.87 | UP |
| TCS | 2025-06-10 | 3455.25 | -5.55 | -0.16 | DOWN |
... showing 15 of 24 rows
Part C: Month-over-month and Year-over-year Revenue
Month-over-month growth (Sales table, 2025)
WITH Monthly AS
(
SELECT DATEFROMPARTS(YEAR(SaleDate), MONTH(SaleDate), 1) AS SaleMonth,
SUM(TotalAmount) AS Revenue
FROM dbo.Sales
GROUP BY DATEFROMPARTS(YEAR(SaleDate), MONTH(SaleDate), 1)
)
SELECT SaleMonth, Revenue,
LAG(Revenue) OVER (ORDER BY SaleMonth) AS PrevMonthRevenue,
CAST(100.0 * (Revenue - LAG(Revenue) OVER (ORDER BY SaleMonth))
/ NULLIF(LAG(Revenue) OVER (ORDER BY SaleMonth), 0) AS DECIMAL(8,2)) AS MoMGrowthPct
FROM Monthly
ORDER BY SaleMonth;
Output
| SaleMonth | Revenue | PrevMonthRevenue | MoMGrowthPct |
| 2025-01-01 | 2609130.00 | NULL | NULL |
| 2025-02-01 | 1678350.00 | 2609130.00 | -35.67 |
| 2025-03-01 | 3100570.00 | 1678350.00 | 84.74 |
| 2025-04-01 | 3223180.00 | 3100570.00 | 3.95 |
| 2025-05-01 | 1898440.00 | 3223180.00 | -41.10 |
| 2025-06-01 | 3124020.00 | 1898440.00 | 64.56 |
Same month last year (Orders table covers 2024 and 2025) -> LAG by 12
WITH Monthly AS
(
SELECT DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1) AS OrderMonth,
SUM(oi.LineTotal) AS Revenue
FROM dbo.Orders o JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
GROUP BY DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1)
)
SELECT OrderMonth, Revenue,
LAG(Revenue, 12) OVER (ORDER BY OrderMonth) AS SameMonthLastYear
FROM Monthly
ORDER BY OrderMonth;
-- NOTE: LAG(…,12) only works if NO month is missing. Safer: self join on DATEADD(YEAR,-1,month).
Output
| OrderMonth | Revenue | SameMonthLastYear |
| 2024-01-01 | 851410.00 | NULL |
| 2024-02-01 | 757610.00 | NULL |
| 2024-03-01 | 885825.00 | NULL |
| 2024-04-01 | 903466.50 | NULL |
| 2024-05-01 | 895150.00 | NULL |
| 2024-06-01 | 953160.00 | NULL |
| 2024-07-01 | 808200.00 | NULL |
| 2024-08-01 | 709800.00 | NULL |
| 2024-09-01 | 508075.00 | NULL |
| 2024-10-01 | 584910.00 | NULL |
| 2024-11-01 | 446850.00 | NULL |
| 2024-12-01 | 388775.00 | NULL |
| 2025-01-01 | 757675.00 | 851410.00 |
| 2025-02-01 | 373550.00 | 757610.00 |
| 2025-03-01 | 655075.00 | 885825.00 |
... showing 15 of 18 rows
Part D: Time BETWEEN Events
Days between consecutive orders of each customer
SELECT CustomerID, OrderID, OrderDate,
LAG(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate, OrderID) AS PrevOrderDate,
DATEDIFF(DAY, LAG(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate, OrderID), OrderDate) AS DaysSincePrev
FROM dbo.Orders
ORDER BY CustomerID, OrderDate;
Output
| CustomerID | OrderID | OrderDate | PrevOrderDate | DaysSincePrev |
| 1 | 1 | 2024-01-09 | NULL | NULL |
| 1 | 10 | 2024-03-12 | 2024-01-09 | 63 |
| 1 | 13 | 2024-04-02 | 2024-03-12 | 21 |
| 1 | 26 | 2024-06-28 | 2024-04-02 | 87 |
| 1 | 39 | 2024-09-23 | 2024-06-28 | 87 |
| 1 | 46 | 2024-11-06 | 2024-09-23 | 44 |
| 1 | 52 | 2024-12-18 | 2024-11-06 | 42 |
| 1 | 64 | 2025-03-08 | 2024-12-18 | 80 |
| 1 | 71 | 2025-04-25 | 2025-03-08 | 48 |
| 1 | 73 | 2025-05-09 | 2025-04-25 | 14 |
| 1 | 75 | 2025-05-21 | 2025-05-09 | 12 |
| 2 | 14 | 2024-04-05 | NULL | NULL |
| 2 | 20 | 2024-05-17 | 2024-04-05 | 42 |
| 2 | 21 | 2024-05-22 | 2024-05-17 | 5 |
| 2 | 35 | 2024-08-27 | 2024-05-22 | 97 |
... showing 15 of 80 rows
Average gap between orders per customer (who buys most often?)
WITH Gaps AS
(
SELECT CustomerID,
DATEDIFF(DAY, LAG(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate, OrderID), OrderDate) AS GapDays
FROM dbo.Orders
)
SELECT CustomerID, COUNT(GapDays) + 1 AS Orders, AVG(GapDays * 1.0) AS AvgDaysBetweenOrders
FROM Gaps
GROUP BY CustomerID
HAVING COUNT(GapDays) > 0
ORDER BY AvgDaysBetweenOrders;
Output
| CustomerID | Orders | AvgDaysBetweenOrders |
| 10 | 4 | 30.000000 |
| 12 | 5 | 37.000000 |
| 3 | 13 | 41.250000 |
| 5 | 8 | 45.000000 |
| 1 | 11 | 49.800000 |
| 2 | 6 | 59.200000 |
| 9 | 3 | 60.000000 |
| 8 | 3 | 87.000000 |
| 15 | 6 | 96.400000 |
| 11 | 3 | 100.500000 |
| 17 | 4 | 121.333333 |
| 4 | 4 | 139.000000 |
| 7 | 2 | 187.000000 |
| 13 | 3 | 229.500000 |
| 16 | 2 | 274.000000 |
... showing 15 of 16 rows
How long each ticket stayed in each status (use LEAD for the "end time")
SELECT TicketID, Status, ChangedAt AS StartedAt,
LEAD(ChangedAt) OVER (PARTITION BY TicketID ORDER BY ChangedAt) AS EndedAt,
DATEDIFF(MINUTE, ChangedAt,
LEAD(ChangedAt) OVER (PARTITION BY TicketID ORDER BY ChangedAt)) / 60.0 AS HoursInStatus
FROM dbo.TicketStatusLog
ORDER BY TicketID, ChangedAt;
Output
| TicketID | Status | StartedAt | EndedAt | HoursInStatus |
| 101 | Open | 2025-06-01 09:00:00 | 2025-06-01 11:30:00 | 2.500000 |
| 101 | In Progress | 2025-06-01 11:30:00 | 2025-06-02 10:00:00 | 22.500000 |
| 101 | On Hold | 2025-06-02 10:00:00 | 2025-06-03 09:15:00 | 23.250000 |
| 101 | In Progress | 2025-06-03 09:15:00 | 2025-06-03 17:45:00 | 8.500000 |
| 101 | Resolved | 2025-06-03 17:45:00 | NULL | NULL |
| 102 | Open | 2025-06-02 14:00:00 | 2025-06-02 14:20:00 | 0.333333 |
| 102 | In Progress | 2025-06-02 14:20:00 | 2025-06-02 16:00:00 | 1.666666 |
| 102 | Resolved | 2025-06-02 16:00:00 | NULL | NULL |
| 103 | Open | 2025-06-03 08:00:00 | 2025-06-04 12:00:00 | 28.000000 |
| 103 | In Progress | 2025-06-04 12:00:00 | 2025-06-05 09:00:00 | 21.000000 |
| 103 | Reopened | 2025-06-05 09:00:00 | 2025-06-06 18:30:00 | 33.500000 |
| 103 | Resolved | 2025-06-06 18:30:00 | NULL | NULL |
Find tickets that went Backward (Resolved/In Progress -> On Hold or Reopened)
SELECT TicketID, PrevStatus, Status, ChangedAt
FROM (
SELECT TicketID, Status, ChangedAt,
LAG(Status) OVER (PARTITION BY TicketID ORDER BY ChangedAt) AS PrevStatus
FROM dbo.TicketStatusLog
) t
WHERE Status IN ('On Hold', 'Reopened');
Output
| TicketID | PrevStatus | Status | ChangedAt |
| 101 | In Progress | On Hold | 2025-06-02 10:00:00 |
| 103 | In Progress | Reopened | 2025-06-05 09:00:00 |
Part E: FIRST_VALUE and LAST_VALUE
-- LAST_VALUE needs a full frame! The default frame stops at the CURRENT row.
SELECT Symbol, TradeDate, ClosePrice,
FIRST_VALUE(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS FirstClose,
LAST_VALUE(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS LastClose_WRONG,
LAST_VALUE(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS LastClose_CORRECT,
CAST(100.0 * (ClosePrice - FIRST_VALUE(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate))
/ FIRST_VALUE(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate) AS DECIMAL(6,2)) AS GainSinceStartPct
FROM dbo.StockPrices
ORDER BY Symbol, TradeDate;
Output
| Symbol | TradeDate | ClosePrice | FirstClose | LastClose_WRONG | LastClose_CORRECT | GainSinceStartPct |
| INFY | 2025-06-02 | 1520.50 | 1520.50 | 1520.50 | 1580.00 | 0.00 |
| INFY | 2025-06-03 | 1534.20 | 1520.50 | 1534.20 | 1580.00 | 0.90 |
| INFY | 2025-06-04 | 1528.00 | 1520.50 | 1528.00 | 1580.00 | 0.49 |
| INFY | 2025-06-05 | 1550.75 | 1520.50 | 1550.75 | 1580.00 | 1.99 |
| INFY | 2025-06-06 | 1561.10 | 1520.50 | 1561.10 | 1580.00 | 2.67 |
| INFY | 2025-06-09 | 1549.90 | 1520.50 | 1549.90 | 1580.00 | 1.93 |
| INFY | 2025-06-10 | 1572.30 | 1520.50 | 1572.30 | 1580.00 | 3.41 |
| INFY | 2025-06-11 | 1580.00 | 1520.50 | 1580.00 | 1580.00 | 3.91 |
| TCS | 2025-06-02 | 3410.00 | 3410.00 | 3410.00 | 3489.90 | 0.00 |
| TCS | 2025-06-03 | 3398.40 | 3410.00 | 3398.40 | 3489.90 | -0.34 |
| TCS | 2025-06-04 | 3425.60 | 3410.00 | 3425.60 | 3489.90 | 0.46 |
| TCS | 2025-06-05 | 3440.10 | 3410.00 | 3440.10 | 3489.90 | 0.88 |
| TCS | 2025-06-06 | 3431.00 | 3410.00 | 3431.00 | 3489.90 | 0.62 |
| TCS | 2025-06-09 | 3460.80 | 3410.00 | 3460.80 | 3489.90 | 1.49 |
| TCS | 2025-06-10 | 3455.25 | 3410.00 | 3455.25 | 3489.90 | 1.33 |
... showing 15 of 24 rows
Each employee vs the highest and lowest paid person in their department
SELECT DepartmentID, FirstName, Salary,
FIRST_VALUE(FirstName) OVER (PARTITION BY DepartmentID ORDER BY Salary DESC) AS TopEarner,
FIRST_VALUE(FirstName) OVER (PARTITION BY DepartmentID ORDER BY Salary ASC) AS LowestEarner
FROM dbo.Employees
WHERE DepartmentID IS NOT NULL
ORDER BY DepartmentID, Salary DESC;
Output
| DepartmentID | FirstName | Salary | TopEarner | LowestEarner |
| 1 | Rajesh | 250000.00 | Rajesh | Rajesh |
| 2 | Anita | 180000.00 | Anita | Rahul |
| 2 | Arjun | 100000.00 | Anita | Rahul |
| 2 | Sneha | 58000.00 | Anita | Rahul |
| 2 | Pooja | 55000.00 | Anita | Rahul |
| 2 | Ritu | 55000.00 | Anita | Rahul |
| 2 | Rahul | 52000.00 | Anita | Rahul |
| 3 | Kavya | 110000.00 | Kavya | Swati |
| 3 | Rohit | 50000.00 | Kavya | Swati |
| 3 | Swati | 42000.00 | Kavya | Swati |
| 4 | Vikram | 190000.00 | Vikram | Amit |
| 4 | Kiran | 95000.00 | Vikram | Amit |
| 4 | Sanjay | 90000.00 | Vikram | Amit |
| 4 | Neha | 75000.00 | Vikram | Amit |
| 4 | Divya | 70000.00 | Vikram | Amit |
... showing 15 of 24 rows
Practice Questions
- For each stock find the day with the biggest single-day % increase.
- Find customers who have not ordered for more than 90 days between two orders.
- Show each employee and the next person hired after them (LEAD on HireDate).
- For each region, compare each month's revenue with the NEXT month (LEAD).
- Find stocks that went UP for 3 consecutive days.