SQLab Hub
Lesson 42 · LAG & LEAD
Lesson 42 · More Topics

SQL LAG() and LEAD(): Compare a Row With the Previous or Next

LAG() returns a value from an earlier row and LEAD() returns a value from a later row, in the order you define. They let you compare each row with the one before or after it, for example this month's revenue against last month's, without joining the table to itself.

⇅ Previous and next row ▶ Runnable Playground examples 🧪 4 practice exercises 📝 5-question quiz ⏱ ~18 min

What Are LAG() and LEAD()?

Start with revenue per month for the second half of 2024, then add two columns: the previous month's revenue with LAG and the next month's with LEAD.

SQLite · runs in the Playground
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         ROUND(SUM(total), 2) AS revenue
  FROM orders
  WHERE order_date >= '2024-07-01'
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       LAG(revenue)  OVER (ORDER BY month) AS previous_month,
       LEAD(revenue) OVER (ORDER BY month) AS next_month
FROM monthly
ORDER BY month;

▶ Run this query in the SQL Playground

Monthly revenue with the previous and next month alongside
monthrevenueprevious_monthnext_month
2024-0710296.67NULL25429.09
2024-0825429.0910296.674319.46
2024-094319.4625429.097996.72
2024-107996.724319.4617380.72
2024-1117380.727996.7210621.36
2024-1210621.3617380.72NULL
✓ 6 rows · real output from the Playground database

Each row now carries its neighbours' values. The first month has no previous row, so previous_month is NULL; the last month has no next row, so next_month is NULL.

About strftime. The Playground runs SQLite, where strftime('%Y-%m', date) extracts the year and month. In PostgreSQL use TO_CHAR(order_date, 'YYYY-MM'), in MySQL DATE_FORMAT(order_date, '%Y-%m') and in SQL Server FORMAT(order_date, 'yyyy-MM'). LAG and LEAD themselves are written the same way in all of them. See SQL date functions.

Syntax

SQL · syntax
LAG(column [, offset [, default]])  OVER ([PARTITION BY group_column] ORDER BY sort_column)
LEAD(column [, offset [, default]]) OVER ([PARTITION BY group_column] ORDER BY sort_column)
ArgumentMeaningIf omitted
columnThe value to fetch from the other rowRequired
offsetHow many rows back (LAG) or forward (LEAD)1
defaultWhat to return when there is no such rowNULL

The ORDER BY inside OVER defines what "previous" and "next" mean. LAG and LEAD are window functions, available in PostgreSQL, SQL Server, Oracle, MySQL 8.0+ and SQLite 3.25+.

Month-over-Month Change

Once the previous value is on the same row, the change is ordinary arithmetic. This calculates the difference and the percentage change from the month before:

SQLite · runs in the Playground
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         ROUND(SUM(total), 2) AS revenue
  FROM orders
  WHERE order_date >= '2024-07-01'
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
             / LAG(revenue) OVER (ORDER BY month), 1) AS change_pct
FROM monthly
ORDER BY month;

▶ Run this query in the SQL Playground

Monthly revenue with the change from the previous month
monthrevenuechangechange_pct
2024-0710296.67NULLNULL
2024-0825429.0915132.42147
2024-094319.46-21109.63-83
2024-107996.723677.2685.1
2024-1117380.729384117.3
2024-1210621.36-6759.36-38.9
✓ 6 rows · real output from the Playground database

The first row shows NULL for both calculations, because any arithmetic with NULL gives NULL. That is the correct answer: there is no earlier month to compare with.

Previous Row per Customer

With PARTITION BY, "previous" means the previous row for the same customer. This shows each order next to that customer's previous order date and the number of days between them:

SQLite · runs in the Playground
SELECT user_id, order_date,
       LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS previous_order,
       CAST(julianday(order_date)
            - julianday(LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id)) AS INTEGER) AS days_since_previous
FROM orders
WHERE user_id IN (37, 77)
ORDER BY user_id, order_date, id;

▶ Run this query in the SQL Playground

Each order with the same customer's previous order date
user_idorder_dateprevious_orderdays_since_previous
372023-01-03NULLNULL
372023-02-172023-01-0345
372023-05-042023-02-1776
372023-06-132023-05-0440
372024-01-062023-06-13207
772022-12-15NULLNULL
772023-03-182022-12-1593
772023-06-032023-03-1877
772024-01-112023-06-03222
772024-08-162024-01-11218
✓ 10 rows · real output from the Playground database

A customer's first order has no previous order, so both extra columns are NULL, and the comparison never reaches across to another customer. (julianday is SQLite's way of subtracting dates; other databases use DATEDIFF or plain subtraction.)

Offset and Default Value

The second argument reaches further back, and the third replaces the NULL when no row exists. Here LAG(revenue, 2, 0) looks two months back and returns 0 when there is nothing there:

SQLite · runs in the Playground
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         ROUND(SUM(total), 2) AS revenue
  FROM orders
  WHERE order_date >= '2024-07-01'
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       LAG(revenue, 2, 0) OVER (ORDER BY month) AS two_months_ago
FROM monthly
ORDER BY month;

▶ Run this query in the SQL Playground

Monthly revenue with the value from two months earlier
monthrevenuetwo_months_ago
2024-0710296.670
2024-0825429.090
2024-094319.4610296.67
2024-107996.7225429.09
2024-1117380.724319.46
2024-1210621.367996.72
✓ 6 rows · real output from the Playground database

With monthly data, an offset of 12 compares each month with the same month a year earlier.

Common LAG and LEAD Mistakes

Three errors and how to avoid them.

1. Forgetting PARTITION BY

❌ Compares with another customer's order
SQL
LAG(order_date) OVER (ORDER BY order_date)
✅ Stays within one customer
SQL
LAG(order_date) OVER (
  PARTITION BY user_id
  ORDER BY order_date
)

Without PARTITION BY the previous row is simply the previous row in the whole table, which usually belongs to someone else.

2. Using a default that distorts the result

❌ First month shows a huge "increase"
SQL
revenue - LAG(revenue, 1, 0) OVER (ORDER BY month)
✅ Leave it NULL
SQL
revenue - LAG(revenue) OVER (ORDER BY month)

A default of 0 makes the first row look as if revenue grew from nothing. For a change calculation, NULL ("no comparison available") is the honest value.

3. Filtering before the window is calculated

❌ The earlier row is filtered away
SQL
SELECT month, revenue,
       LAG(revenue) OVER (ORDER BY month) AS prev
FROM monthly
WHERE month = '2024-12';
✅ Calculate first, filter after
SQL
WITH with_prev AS (
  SELECT month, revenue,
         LAG(revenue) OVER (ORDER BY month) AS prev
  FROM monthly
)
SELECT month, revenue, prev
FROM with_prev
WHERE month = '2024-12';

WHERE removes rows before window functions run. If only December is left, there is no previous row and LAG returns NULL. Compute LAG in a CTE, then filter.

Practice LAG & LEAD Queries

4 exercises on the real Playground tables.

Write each query yourself first. Every solution has been run against the Playground database.

  1. 1Easy Show monthly order counts for 2024 with the previous month's count beside each. Table: orders

    Show solution
    SQLite · runs in the Playground
    WITH monthly AS (
      SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
      FROM orders
      WHERE order_date >= '2024-01-01'
      GROUP BY strftime('%Y-%m', order_date)
    )
    SELECT month, orders,
           LAG(orders) OVER (ORDER BY month) AS previous_month
    FROM monthly
    ORDER BY month;

    ▶ Run this query in the SQL Playground

    One row per month, 12 in total; the first has NULL because nothing comes before it.

  2. 2Medium For customers 37, 54 and 77, show each order with the date of that customer's next order. Table: orders

    Show solution
    SQLite · runs in the Playground
    SELECT user_id, order_date,
           LEAD(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS next_order
    FROM orders
    WHERE user_id IN (37, 54, 77)
    ORDER BY user_id, order_date, id;

    ▶ Run this query in the SQL Playground

    LEAD looks forward. Each customer's last order has NULL in next_order.

  3. 3Medium Show yearly revenue and the change from the previous year. Table: orders

    Show solution
    SQLite · runs in the Playground
    WITH yearly AS (
      SELECT strftime('%Y', order_date) AS year,
             ROUND(SUM(total), 2) AS revenue
      FROM orders
      GROUP BY strftime('%Y', order_date)
    )
    SELECT year, revenue,
           ROUND(revenue - LAG(revenue) OVER (ORDER BY year), 2) AS change
    FROM yearly
    ORDER BY year;

    ▶ Run this query in the SQL Playground

    The same pattern as month-over-month, grouped by year instead.

  4. 4Challenging Find the five longest gaps, in days, between two consecutive orders of the same customer. Table: orders

    Show solution
    SQLite · runs in the Playground
    WITH gaps AS (
      SELECT user_id, order_date,
             LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS previous_order
      FROM orders
    )
    SELECT user_id, previous_order, order_date,
           CAST(julianday(order_date) - julianday(previous_order) AS INTEGER) AS gap_days
    FROM gaps
    WHERE previous_order IS NOT NULL
    ORDER BY gap_days DESC, user_id
    LIMIT 5;

    ▶ Run this query in the SQL Playground

    LAG is calculated in the CTE, then the outer query removes first orders and sorts by the size of the gap.

📝 Quiz — LAG & LEAD

Test your understanding · 0/5 answered

Question 1 of 5

What does LAG(sales) OVER (ORDER BY month) return?

Question 2 of 5

What does LAG return for the first row of a partition by default?

Question 3 of 5

What does the 2 mean in LEAD(price, 2)?

Question 4 of 5

You want each customer's previous order date. What must the OVER clause contain?

Question 5 of 5

Which query design do LAG and LEAD most often replace?

LAG & LEAD Interview Questions

Q1. What is the difference between LAG() and LEAD()?

LAG() reads a value from a row before the current one and LEAD() from a row after it, both in the order given by the OVER clause. LAG(x) with ascending order returns the same values as LEAD(x) with descending order.

Q2. How do you calculate month-over-month growth in SQL?

Aggregate to one row per month, use LAG(revenue) OVER (ORDER BY month) to bring the previous month onto the row, then compute (revenue - previous) / previous.

Q3. How would you find customers whose two consecutive orders were more than 30 days apart?

Use LAG(order_date) OVER (PARTITION BY customer ORDER BY order_date) in a CTE, subtract it from the current order date, and filter the outer query for gaps above 30 days.

Frequently Asked Questions

What is the LAG function in SQL?

LAG() is a window function that returns a value from a previous row in the result, based on the ORDER BY in its OVER clause. It is commonly used to compare a row with the one before it.

What is the difference between LAG and LEAD?

LAG() looks backwards to an earlier row and LEAD() looks forwards to a later row. They take the same arguments: the column, an optional offset and an optional default value.

Why does LAG return NULL?

LAG returns NULL when there is no earlier row, which happens on the first row of the result or of each partition. Pass a third argument to return a different value instead.

Do LAG and LEAD work in MySQL?

Yes, from MySQL 8.0. They are also supported in PostgreSQL, SQL Server, Oracle and SQLite 3.25 or later.