MySQL LAG Function Explanation

Overview

The LAG function in MySQL is a window function that allows you to access data from a previous row within the same result set. It is particularly useful for calculating differences between sequential rows, such as in time series analysis.

SQL Query Explanation

SELECT ROUND(AVG(daily_increase), 4) AS avg_daily_increase
FROM (
    SELECT dt1, (price - LAG(price) OVER (ORDER BY dt1)) AS daily_increase
    FROM binance.klines_1d
    WHERE dt1 >= CURDATE() - INTERVAL %d DAY
) AS price_changes
WHERE daily_increase IS NOT NULL;

1. Inner Query

2. Filtering Dates

3. Outer Query

How LAG Works

LAG(price) OVER (ORDER BY dt1) on Four Days price100104101107 LAG(price)NULL100104101 daily_increaseNULL4-36 Each row reads the previous row in the window order. The first row has no predecessor, so LAG returns NULL (or the default argument). AVG over 4, -3, 6 = 2.3333.

The full signature is LAG(expr [, offset [, default]]) OVER (window_spec), available in MySQL 8.0 and later. offset defaults to 1 (the immediately preceding row) and default is returned when no such row exists; without it the result is NULL. The window specification decides what "previous" means: ORDER BY sets the sequence, and an optional PARTITION BY restarts the sequence for each group, so the first row of every partition gets the default. Its mirror image, LEAD, reads following rows instead. Unlike a self-join on date - 1, LAG does not care about gaps in the dates: it simply returns whatever row precedes the current one in sort order.

Logical Query Order

Where Window Functions Run in a Query FROM / WHEREfilter rows GROUP BY / HAVINGoptional Window functionsLAG, ROW_NUMBER SELECT listexpressions ORDER BY / LIMITfinal sort Window functions see rows after WHERE and GROUP BY, which is why you cannot filter on a LAG result in the same query level: wrap it in a subquery or CTE and filter outside, as the example above does.

Understanding evaluation order explains the subquery in the original example. The inner query computes daily_increase for each row; the outer query can then filter out the NULL from the first row and aggregate. Note that the WHERE dt1 >= ... filter runs before LAG, so the first day inside the date range has no predecessor even though older data exists in the table. If you want that first day's change too, compute LAG over a slightly wider range and filter afterwards.

More Examples

-- Per-symbol day-over-day change and percentage change
SELECT symbol,
       dt1,
       price,
       price - LAG(price) OVER w                     AS change_abs,
       ROUND(100 * (price / LAG(price) OVER w - 1), 2) AS change_pct
FROM   binance.klines_1d
WINDOW w AS (PARTITION BY symbol ORDER BY dt1);

-- Compare with the value 7 rows earlier, defaulting to the current price
SELECT dt1, price,
       price - LAG(price, 7, price) OVER (ORDER BY dt1) AS week_change
FROM   binance.klines_1d;

-- Detect gaps in a daily series
SELECT dt1,
       DATEDIFF(dt1, LAG(dt1) OVER (ORDER BY dt1)) AS days_since_prev
FROM   binance.klines_1d
HAVING days_since_prev > 1;

The named WINDOW clause avoids repeating the same specification. The gap-detection query uses MySQL's permission to reference a select alias in HAVING; in portable SQL wrap it in a CTE instead. The original example's %d is a placeholder filled in by application code (a Python format string); prefer bound parameters such as INTERVAL %s DAY with a driver argument to avoid SQL injection.

LAG is one of several value window functions. FIRST_VALUE and LAST_VALUE return the boundary rows of the window frame, NTH_VALUE returns an arbitrary position, and ranking functions such as ROW_NUMBER and RANK number the rows. Combining LAG with a running SUM() OVER is a common way to build sessionisation logic: flag a row as a new session when the gap since the previous event exceeds a threshold, then take the cumulative sum of the flags as a session id.

LAG vs Alternatives

ApproachWorks onNotes
LAG / LEADMySQL 8.0+, PostgreSQL, SQL Server, Snowflakesingle pass over sorted rows; clearest
Self-join on previous keyall versionsneeds a contiguous key; gaps break it
Correlated subquery (MAX(dt) < current)all versionsoften O(n²) without good indexes
User variables (@prev)MySQL 5.xevaluation order not guaranteed; deprecated pattern
pandas Series.shift(1)Pythonsame idea once data is in memory

Pitfalls