Phase 5 · Window functions: ranking, running totals and comparing rows · Topic 5.3

LAG and LEAD: comparing with the previous or next row

Must know

LAG(column) returns the value from the previous row and LEAD(column) the value from the next row, in the order set by OVER (… ORDER BY …), so a row can be compared with its neighbour.

Not started

Step 1 Understand it

Why you need it. "How much did revenue change since last month?", "how many days between a customer's orders?", "did the price go up?" — each compares a with the one before it. Without , that means a tricky on "the previous date". With LAG it is one line.

Watch it happen

AnimationLAG looks at the previous row; PARTITION BY stops it at the group boundary
Read the animation as text
  1. One row per month. To compare each month with the one before, a row needs to see its neighbour.
  2. LAG(revenue) looks one row back, in the order set by OVER (ORDER BY month). January has no row before it, so it gets NULL.
  3. Now the change is just revenue − prev: February fell by 235, March by 72. January's change is NULL, because NULL − anything is NULL.

Picture it

flowchart LR
  J["2024-01<br/>445"] -->|"LAG for Feb"| F["2024-02<br/>210<br/>previous: 445"]
  F -->|"LAG for Mar"| M["2024-03<br/>138<br/>previous: 210"]
In month order, LAG hands each row the value from the row before it. The first row has nothing before it, so it gets .

The idea

LAG(revenue) OVER (ORDER BY month) looks one row back in month order. The first row has no previous row, so it gets NULL (or a default you give as the third argument: LAG(revenue, 1, 0)). LEAD looks forward instead. The second argument says how far: LAG(revenue, 12) is the same month last year in monthly data. Add customer_id and the looking-back stops at each customer's boundary, so one customer's first order never looks back into another customer's orders.

The shape of it

sql
LAG(column, offset, default)  OVER (PARTITION BY g ORDER BY d)
LEAD(column, offset, default) OVER (PARTITION BY g ORDER BY d)

Words in lower case are the parts you fill in.

How it works, step by step

  1. A makes one row per month: January 445, February 210, March 138.
  2. LAG(revenue) OVER (ORDER BY month): January has no previous row, so NULL; February gets 445; March gets 210.
  3. revenue − LAG(revenue) gives the change: NULL, −235, −72.
  4. With PARTITION BY customer_id, each customer's first order gets NULL from LAG.
  5. LEAD works the same way, looking forward: the last row gets NULL.

Analogy

Reading a bank statement line by line with your finger on the line above. LAG is that line above; the difference between them is the change.

Where the analogy breaks

On a statement the line above is always the day before. LAG gives the previous row, not the previous day: if a month has no orders, it has no row, and LAG silently compares March with January. Fill the gaps first when it matters.

Worked examples

Change from the previous month

sql
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, SUM(amount) AS revenue
  FROM orders
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
       revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly
ORDER BY month;

3 rows

monthrevenueprev_revenuechange
2024-01445NULLNULL
2024-02210445-235
2024-03138210-72

Days between a customer's orders

sql
SELECT customer_id, order_id, order_date,
       LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS previous_order,
       julianday(order_date)
         - julianday(LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS days_since
FROM orders
WHERE customer_id IN (1, 2)
ORDER BY customer_id, order_date;

7 rows

customer_idorder_idorder_dateprevious_orderdays_since
11012024-01-06NULLNULL
11022024-01-072024-01-061
11032024-01-082024-01-071
11052024-02-022024-01-0825
11092024-03-102024-02-0237
21042024-01-21NULLNULL
21082024-03-012024-01-2140

PARTITION BY customer_id stops Tom's first order looking back at Priya's last one.

LEAD: who was hired next

sql
SELECT name, hire_date,
       LEAD(name) OVER (ORDER BY hire_date) AS next_hire
FROM employees
ORDER BY hire_date;

8 rows

namehire_datenext_hire
Asha2019-03-01Dara
Dara2020-02-20Ben
Ben2020-06-15Chen
Chen2021-01-10Fatima
Fatima2021-09-12Eli
Eli2022-07-01Hana
Hana2022-11-30Gus
Gus2023-04-03NULL

Must-know points

  • LAG = previous row's value; LEAD = next row's value, in the 's .
  • The first row (LAG) or last row (LEAD) of each partition gets NULL, or the default you pass.
  • LAG(x, n) looks n rows back — 12 for "same month last year" in monthly data.
  • PARTITION BY keeps each group's inside the group.
  • LAG looks at the previous row, not the previous date: fill missing dates first if gaps matter.

Common mistakes

  • Forgetting PARTITION BY, so one customer's first order is compared with another customer's last.
  • Assuming the previous row is the previous day or month when there are gaps.
  • Dividing by LAG without guarding against NULL or zero.

In other databases

  • Date differences: order_date - LAG(order_date) OVER (…) gives days directly for ; uses ; uses DATEDIFF(day, …).

Step 2 Go further (free, optional)

Optional. The notes above are written to be enough on their own. These free resources give a second explanation and more practice.

Step 3 Practise

Try each one yourself first: out loud, or written and run in the practice box below, which saves every query you run. Then open the answer and compare.

  1. ExplainSay what it does and when to use it, in plain words, with no notes.

    Explain what LAG returns for the first row of each partition, and why.

    Show answer

    NULL. LAG looks one row back inside the same partition, in the order given by (… ORDER BY …). The first row of a partition has no row before it, so there is nothing to return. LAG never reaches into another partition: the first order of customer 2 doesn't see customer 1's last order.

    If you want something else there, give a default as the third argument: LAG(amount, 1, 0) returns 0 for the first row.

  2. Writefrom a blank page, then run it

    For each of Priya's orders, show the amount and the difference from her previous order's amount.

    Show answer

    Keep Priya's orders, then take each amount minus LAG(amount) in date order. Her first order has nothing to compare with, so its change is NULL.

    The runs before the window function, so LAG only ever sees Priya's orders. That is exactly what you want here.

    sql
    SELECT order_id, order_date, amount,
           amount - LAG(amount) OVER (ORDER BY order_date) AS change_from_previous
    FROM orders
    WHERE customer_id = 1
    ORDER BY order_date;

    5 rows

    order_idorder_dateamountchange_from_previous
    1012024-01-06120NULL
    1022024-01-0780-40
    1032024-01-0845-35
    1052024-02-02150105
    1092024-03-1030-120
  3. Predictsay it before you run it

    In the first example, what would LEAD(revenue) OVER (ORDER BY month) give for each month?

    Show answer

    Each month gets the next month's revenue: January 210 (February's), February 138 (March's), and March NULL, because there is no month after it. LEAD is LAG looking forward.

    sql
    WITH monthly AS (
      SELECT strftime('%Y-%m', order_date) AS month, SUM(amount) AS revenue
      FROM orders
      GROUP BY strftime('%Y-%m', order_date)
    )
    SELECT month, revenue,
           LEAD(revenue) OVER (ORDER BY month) AS next_revenue
    FROM monthly
    ORDER BY month;

    3 rows

    monthrevenuenext_revenue
    2024-01445210
    2024-02210138
    2024-03138NULL
  4. Edge casesSay what happens with NULLs, ties, duplicates or no rows at all, and fix it.

    If February had no orders at all, what would LAG return for March in the first example, and how would you fix it?

    Show answer

    January's revenue, 445. With no February orders, the monthly CTE has no February row at all. LAG just takes the row before, which is now January, so March is compared with January but labelled "previous month". No error, just a wrong comparison.

    Fix it by making the list of months complete before using LAG. Generate every month (a , or a calendar ), the orders onto it, and empty months to 0. Then February exists with 0, and March is compared with it.

    Below, the two DELETEs remove February (its item lines first, because of the ), and the fixed still shows February.

    sql
    DELETE FROM order_items WHERE order_id IN (105, 107);
    DELETE FROM orders WHERE order_date LIKE '2024-02%';
    
    WITH RECURSIVE months(month) AS (
      SELECT '2024-01'
      UNION ALL
      SELECT strftime('%Y-%m', date(month || '-01', '+1 month'))
      FROM months
      WHERE month < '2024-03'
    ),
    monthly AS (
      SELECT m.month, COALESCE(SUM(o.amount), 0) AS revenue
      FROM months m
      LEFT JOIN orders o ON strftime('%Y-%m', o.order_date) = m.month
      GROUP BY m.month
    )
    SELECT month, revenue,
           LAG(revenue) OVER (ORDER BY month) AS prev_revenue
    FROM monthly
    ORDER BY month;

    3 rows

    monthrevenueprev_revenue
    2024-01445NULL
    2024-020445
    2024-031380

Practice box: write and run your answers here

Ctrl/⌘ + Enter runs it. Select part of the query to run only that part.

Step 4 Interview-ready, then prove it

Your interview answer

About 20 to 40 seconds, said out loud.

LAG and LEAD return a value from a preceding or following row within the window, in the order given by the OVER clause's ORDER BY. I use them for period-over-period changes — revenue minus LAG of revenue — and gaps between events per user with PARTITION BY user. I watch out for the first row returning NULL and for missing periods, since LAG goes to the previous row, which isn't necessarily the previous calendar month.

Follow-up questions

How would you compute month-over-month growth as a percentage?

ROUND(100.0 * (revenue - LAG(revenue) OVER w) / LAG(revenue) OVER w, 1), guarding with in case last month was 0.

How do you get a value from two rows back?

Pass an offset: LAG(column, 2) OVER (…).

Tick each check when you can do it without notes

A topic is finished when you pass all four checks, not when you have read it. Be strict: if you had to look at the notes, it is not ticked yet.

  • Explain Say what it does and when to use it, in plain words, with no notes.
  • Write Write the query from a blank page, then run it to check.
  • Predict Read a query and say which rows it returns before you run it.
  • Edge cases Say what happens with NULLs, ties, duplicates or no rows at all, and fix it.

0 of 4 checks ticked.

Saved in this browser. Back it up or move it to another device.