売上レポートで「昨日と比べてどれだけ増えたか」を出したい場面はよくあります。以前は自己結合やサブクエリで1日前の行を探していました。MySQL 8.0からは、ウィンドウ関数のLAGとLEADを使うと前後の行の値を1行で取り出せます。この記事では、LAG・LEADの基本構文から、前日比・前月比・増減率の計算、店舗ごとの比較までを順に説明します。後半では、データの抜けた日に比較がずれる問題やゼロ除算など、実務でつまずきやすい点と対処法もまとめました。ウィンドウ関数そのものが初めての方は、ウィンドウ関数入門の記事から読むと理解しやすくなります。
このシナリオで考える
2つの店舗(shibuya と shinjuku)の日別売上を記録したテーブルを使います。渋谷店は10月4日が休業日で、行がありません。この「抜け」は後半の落とし穴の説明で使います。
CREATE TABLE daily_sales (
sale_date DATE NOT NULL,
store VARCHAR(20) NOT NULL,
amount INT NOT NULL,
PRIMARY KEY (store, sale_date)
);
INSERT INTO daily_sales (sale_date, store, amount) VALUES
('2026-10-01', 'shibuya', 120000),
('2026-10-02', 'shibuya', 96000),
('2026-10-03', 'shibuya', 132000),
('2026-10-05', 'shibuya', 150000), -- 10/04は休業で行なし
('2026-10-01', 'shinjuku', 80000),
('2026-10-02', 'shinjuku', 88000),
('2026-10-03', 'shinjuku', 66000);
LAG・LEADの基本構文
LAGは「前の行」、LEADは「後ろの行」の値を返す関数です。どちらも同じ形で書けます。
LAG(式 [, N [, 既定値]]) OVER (PARTITION BY ... ORDER BY ...)
LEAD(式 [, N [, 既定値]]) OVER (PARTITION BY ... ORDER BY ...)
| 引数 | 意味 | 省略したとき |
|---|---|---|
| 式 | 取り出したい列や計算式 | 省略不可 |
| N | 何行前(後ろ)を見るか | 1 |
| 既定値 | 該当する行がないときに返す値 | NULL |
まずは渋谷店だけで、前日の売上と翌日の売上を並べてみます。
SELECT
sale_date,
amount,
LAG(amount) OVER (ORDER BY sale_date) AS prev_amount,
LEAD(amount) OVER (ORDER BY sale_date) AS next_amount
FROM daily_sales
WHERE store = 'shibuya'
ORDER BY sale_date;
+------------+--------+-------------+-------------+
| sale_date | amount | prev_amount | next_amount |
+------------+--------+-------------+-------------+
| 2026-10-01 | 120000 | NULL | 96000 |
| 2026-10-02 | 96000 | 120000 | 132000 |
| 2026-10-03 | 132000 | 96000 | 150000 |
| 2026-10-05 | 150000 | 132000 | NULL |
+------------+--------+-------------+-------------+
最初の行には前の行がないので、prev_amount はNULLになります。同じ理由で、最後の行の next_amount もNULLです。OVER句の ORDER BY は「どの順番で前後を決めるか」を指定する部分です。書かなくてもエラーにはなりません。ただし並び順が保証されず、結果が実行のたびに変わるおそれがあります。LAG・LEADでは必ず書くと覚えてください。
前日比と増減率を計算する
LAGで取った値は普通の列と同じように計算に使えます。差額と増減率(%)を出してみましょう。同じOVER句を何度も書く場合は、WINDOW句で名前を付けるとすっきりします。
SELECT
sale_date,
amount,
amount - LAG(amount) OVER w AS diff,
ROUND(
(amount - LAG(amount) OVER w) * 100
/ NULLIF(LAG(amount) OVER w, 0),
1
) AS rate_pct
FROM daily_sales
WHERE store = 'shibuya'
WINDOW w AS (ORDER BY sale_date)
ORDER BY sale_date;
+------------+--------+--------+----------+
| sale_date | amount | diff | rate_pct |
+------------+--------+--------+----------+
| 2026-10-01 | 120000 | NULL | NULL |
| 2026-10-02 | 96000 | -24000 | -20.0 |
| 2026-10-03 | 132000 | 36000 | 37.5 |
| 2026-10-05 | 150000 | 18000 | 13.6 |
+------------+--------+--------+----------+
増減率の分母を NULLIF(…, 0) で包んでいるのがポイントです。前日の売上が0だと割り算ができません。NULLIFで0をNULLに変えておくと、結果は安全にNULLになります。
既定値を指定する場合
3番目の引数を使うと、前の行がないときの値を決められます。たとえば LAG(amount, 1, 0) とすると、初日の前日売上が0として扱われます。ただし差額が当日の売上そのものになり、増減率も計算できなくなります。比較の基準がないことを正しく表すには、NULLのままにしておく方が無難でしょう。
PARTITION BYで店舗ごとに比べる
店舗が複数あるときは PARTITION BY を付けます。店舗ごとに区切った中で前後の行を探すため、別の店舗の値が混ざりません。
SELECT
store,
sale_date,
amount,
LAG(amount) OVER (
PARTITION BY store
ORDER BY sale_date
) AS prev_amount
FROM daily_sales
ORDER BY store, sale_date;
+----------+------------+--------+-------------+
| store | sale_date | amount | prev_amount |
+----------+------------+--------+-------------+
| shibuya | 2026-10-01 | 120000 | NULL |
| shibuya | 2026-10-02 | 96000 | 120000 |
| shibuya | 2026-10-03 | 132000 | 96000 |
| shibuya | 2026-10-05 | 150000 | 132000 |
| shinjuku | 2026-10-01 | 80000 | NULL |
| shinjuku | 2026-10-02 | 88000 | 80000 |
| shinjuku | 2026-10-03 | 66000 | 88000 |
+----------+------------+--------+-------------+
新宿店の初日が、渋谷店の最終日ではなくNULLになっている点に注目してください。PARTITION BYを忘れると、店舗の境目で別店舗の売上と比べてしまいます。
前月比・前年同月比を出す
月単位で比べるときは、先に月ごとに集計してからLAGを使います。CTE(WITH句)で集計を分けると読みやすくなります。
WITH monthly AS (
SELECT
DATE_FORMAT(sale_date, '%Y-%m') AS ym,
SUM(amount) AS total
FROM daily_sales
GROUP BY ym
)
SELECT
ym,
total,
LAG(total) OVER (ORDER BY ym) AS prev_month,
LAG(total, 12) OVER (ORDER BY ym) AS same_month_last_year
FROM monthly
ORDER BY ym;
Nに12を渡すと、12行前、つまり前年同月の値が取れます。同じ考え方で、日別データに LAG(amount, 7) を使えば前週の同じ曜日と比べられます。なお MySQLでは LAG(SUM(amount)) OVER (ORDER BY ym) のように、集約関数をそのまま中に書くこともできます。
| 比較したいもの | データの単位 | 書き方 |
|---|---|---|
| 前日比 | 日別 | LAG(amount) OVER (ORDER BY 日付) |
| 前週同曜日比 | 日別 | LAG(amount, 7) OVER (ORDER BY 日付) |
| 前月比 | 月別 | LAG(total) OVER (ORDER BY 年月) |
| 前年同月比 | 月別 | LAG(total, 12) OVER (ORDER BY 年月) |
| 翌日との差 | 日別 | LEAD(amount) OVER (ORDER BY 日付) |
よくある落とし穴
1. 行が抜けると「前日」ではなくなる
LAGが見るのは「1日前」ではなく「1行前」です。渋谷店の10月5日は、休業日の4日を飛ばして3日と比べられていました。LAG(amount, 7) で前週比を出す場合も、途中に抜けがあると別の日と比べてしまいます。対策は2つあります。
- カレンダー用のテーブルとLEFT JOINして、抜けた日を0件の行として補う
- 前の行の日付も取り出して、ちょうど1日前のときだけ比較する
2つ目の方法は次のように書けます。
SELECT
sale_date,
amount,
CASE
WHEN DATEDIFF(sale_date, LAG(sale_date) OVER w) = 1
THEN amount - LAG(amount) OVER w
END AS diff_from_yesterday
FROM daily_sales
WHERE store = 'shibuya'
WINDOW w AS (ORDER BY sale_date);
+------------+--------+---------------------+
| sale_date | amount | diff_from_yesterday |
+------------+--------+---------------------+
| 2026-10-01 | 120000 | NULL |
| 2026-10-02 | 96000 | -24000 |
| 2026-10-03 | 132000 | 36000 |
| 2026-10-05 | 150000 | NULL |
+------------+--------+---------------------+
2. WHEREで絞ると前の行が消える
ウィンドウ関数はWHEREで絞り込んだ後の行に対して計算されます。WHERE sale_date >= ‘2026-10-02’ と書くと、10月2日の前日売上はNULLになります。期間を絞りたいときは、全期間でLAGを計算したCTEやサブクエリを作り、その外側で絞り込みましょう。WHERE句にLAGを直接書けないため、結果で絞るときもこの形になります。
3. その他の注意点
- ORDER BYの値が同じ行が複数あると、どちらが「前」になるかは決まりません。並び順が一意になる列まで指定します
- 前の行の値がNULLの場合も、LAGはそのままNULLを返します。既定値が使われるのは「前の行がない」ときだけです
- MySQLでは IGNORE NULLS(NULLを飛ばして前の値を探す指定)は使えず、書くとエラーになります
- Nには負の数を指定できません。後ろの行を見たいときはLEADを使います
まとめ
LAGは前の行、LEADは後ろの行の値を取り出す関数です。差額や増減率は、取り出した値と当日の値を計算するだけで出せます。店舗などのグループごとに比べるなら PARTITION BY、前週・前年と比べるなら Nの指定を使います。ただしLAGが見るのはあくまで「1行前」です。抜けた日がないか、WHEREで比較元を消していないかを確かめてから集計に使ってください。

