Skip to content

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
SymbolTradeDateClosePricePrevCloseNextCloseClose2DaysAgoPrevCloseDefault0
INFY2025-06-021520.50NULL1534.20NULL0.00
INFY2025-06-031534.201520.501528.00NULL1520.50
INFY2025-06-041528.001534.201550.751520.501534.20
INFY2025-06-051550.751528.001561.101534.201528.00
INFY2025-06-061561.101550.751549.901528.001550.75
INFY2025-06-091549.901561.101572.301550.751561.10
INFY2025-06-101572.301549.901580.001561.101549.90
INFY2025-06-111580.001572.30NULL1549.901572.30
TCS2025-06-023410.00NULL3398.40NULL0.00
TCS2025-06-033398.403410.003425.60NULL3410.00
TCS2025-06-043425.603398.403440.103410.003398.40
TCS2025-06-053440.103425.603431.003398.403425.60
TCS2025-06-063431.003440.103460.803425.603440.10
TCS2025-06-093460.803431.003455.253440.103431.00
TCS2025-06-103455.253460.803489.903431.003460.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
SymbolTradeDateClosePriceChangeAmtChangePctDirection
INFY2025-06-021520.50NULLNULL-
INFY2025-06-031534.2013.700.90UP
INFY2025-06-041528.00-6.20-0.40DOWN
INFY2025-06-051550.7522.751.49UP
INFY2025-06-061561.1010.350.67UP
INFY2025-06-091549.90-11.20-0.72DOWN
INFY2025-06-101572.3022.401.45UP
INFY2025-06-111580.007.700.49UP
TCS2025-06-023410.00NULLNULL-
TCS2025-06-033398.40-11.60-0.34DOWN
TCS2025-06-043425.6027.200.80UP
TCS2025-06-053440.1014.500.42UP
TCS2025-06-063431.00-9.10-0.26DOWN
TCS2025-06-093460.8029.800.87UP
TCS2025-06-103455.25-5.55-0.16DOWN
... 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
SaleMonthRevenuePrevMonthRevenueMoMGrowthPct
2025-01-012609130.00NULLNULL
2025-02-011678350.002609130.00-35.67
2025-03-013100570.001678350.0084.74
2025-04-013223180.003100570.003.95
2025-05-011898440.003223180.00-41.10
2025-06-013124020.001898440.0064.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
OrderMonthRevenueSameMonthLastYear
2024-01-01851410.00NULL
2024-02-01757610.00NULL
2024-03-01885825.00NULL
2024-04-01903466.50NULL
2024-05-01895150.00NULL
2024-06-01953160.00NULL
2024-07-01808200.00NULL
2024-08-01709800.00NULL
2024-09-01508075.00NULL
2024-10-01584910.00NULL
2024-11-01446850.00NULL
2024-12-01388775.00NULL
2025-01-01757675.00851410.00
2025-02-01373550.00757610.00
2025-03-01655075.00885825.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
CustomerIDOrderIDOrderDatePrevOrderDateDaysSincePrev
112024-01-09NULLNULL
1102024-03-122024-01-0963
1132024-04-022024-03-1221
1262024-06-282024-04-0287
1392024-09-232024-06-2887
1462024-11-062024-09-2344
1522024-12-182024-11-0642
1642025-03-082024-12-1880
1712025-04-252025-03-0848
1732025-05-092025-04-2514
1752025-05-212025-05-0912
2142024-04-05NULLNULL
2202024-05-172024-04-0542
2212024-05-222024-05-175
2352024-08-272024-05-2297
... 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
CustomerIDOrdersAvgDaysBetweenOrders
10430.000000
12537.000000
31341.250000
5845.000000
11149.800000
2659.200000
9360.000000
8387.000000
15696.400000
113100.500000
174121.333333
44139.000000
72187.000000
133229.500000
162274.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
TicketIDStatusStartedAtEndedAtHoursInStatus
101Open2025-06-01 09:00:002025-06-01 11:30:002.500000
101In Progress2025-06-01 11:30:002025-06-02 10:00:0022.500000
101On Hold2025-06-02 10:00:002025-06-03 09:15:0023.250000
101In Progress2025-06-03 09:15:002025-06-03 17:45:008.500000
101Resolved2025-06-03 17:45:00NULLNULL
102Open2025-06-02 14:00:002025-06-02 14:20:000.333333
102In Progress2025-06-02 14:20:002025-06-02 16:00:001.666666
102Resolved2025-06-02 16:00:00NULLNULL
103Open2025-06-03 08:00:002025-06-04 12:00:0028.000000
103In Progress2025-06-04 12:00:002025-06-05 09:00:0021.000000
103Reopened2025-06-05 09:00:002025-06-06 18:30:0033.500000
103Resolved2025-06-06 18:30:00NULLNULL

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
TicketIDPrevStatusStatusChangedAt
101In ProgressOn Hold2025-06-02 10:00:00
103In ProgressReopened2025-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
SymbolTradeDateClosePriceFirstCloseLastClose_WRONGLastClose_CORRECTGainSinceStartPct
INFY2025-06-021520.501520.501520.501580.000.00
INFY2025-06-031534.201520.501534.201580.000.90
INFY2025-06-041528.001520.501528.001580.000.49
INFY2025-06-051550.751520.501550.751580.001.99
INFY2025-06-061561.101520.501561.101580.002.67
INFY2025-06-091549.901520.501549.901580.001.93
INFY2025-06-101572.301520.501572.301580.003.41
INFY2025-06-111580.001520.501580.001580.003.91
TCS2025-06-023410.003410.003410.003489.900.00
TCS2025-06-033398.403410.003398.403489.90-0.34
TCS2025-06-043425.603410.003425.603489.900.46
TCS2025-06-053440.103410.003440.103489.900.88
TCS2025-06-063431.003410.003431.003489.900.62
TCS2025-06-093460.803410.003460.803489.901.49
TCS2025-06-103455.253410.003455.253489.901.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
DepartmentIDFirstNameSalaryTopEarnerLowestEarner
1Rajesh250000.00RajeshRajesh
2Anita180000.00AnitaRahul
2Arjun100000.00AnitaRahul
2Sneha58000.00AnitaRahul
2Pooja55000.00AnitaRahul
2Ritu55000.00AnitaRahul
2Rahul52000.00AnitaRahul
3Kavya110000.00KavyaSwati
3Rohit50000.00KavyaSwati
3Swati42000.00KavyaSwati
4Vikram190000.00VikramAmit
4Kiran95000.00VikramAmit
4Sanjay90000.00VikramAmit
4Neha75000.00VikramAmit
4Divya70000.00VikramAmit
... showing 15 of 24 rows

Practice Questions

  1. For each stock find the day with the biggest single-day % increase.
  2. Find customers who have not ordered for more than 90 days between two orders.
  3. Show each employee and the next person hired after them (LEAD on HireDate).
  4. For each region, compare each month's revenue with the NEXT month (LEAD).
  5. Find stocks that went UP for 3 consecutive days.