- SQL
SQLのLAG・LEADと累計の書き方|前月比・移動平均のコピペ集
前の行の値は LAG(列) OVER (ORDER BY 日付)、次の行の値は LEAD(列) OVER (ORDER BY 日付) で取れます。累計は SUM(列) OVER (ORDER BY 日付)、移動平均は AVG(列) OVER (ORDER BY 日付 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) です。 どれもウィンドウ関数(行をまとめずに、前後の行や範囲の行を見て計算する関数)で、PostgreSQL・MySQL 8.0・Oracle・SQL Server で同じ形で書けます。
SELECT store, ym, amount,
LAG(amount) OVER w AS prev_month, -- 前月の値
ROUND(amount * 100.0 / NULLIF(LAG(amount) OVER w, 0), 1) AS mom_pct, -- 前月比(%)
SUM(amount) OVER w AS running_total, -- 累計
ROUND(AVG(amount) OVER (w ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS ma3 -- 3か月移動平均
FROM sales
WINDOW w AS (PARTITION BY store ORDER BY ym)
ORDER BY store, ym;
| やりたいこと | 書き方(OVER の中は例) | 見出し |
|---|---|---|
| 前の行・次の行 | LAG(x)・LEAD(x) |
LAG と LEAD の基本 |
| 2行前・無いときは0 | LAG(x, 2)・LAG(x, 1, 0) |
LAG と LEAD の基本 |
| 前月比・増減 | x - LAG(x)・x * 100.0 / NULLIF(LAG(x), 0) |
前月比 |
| 月が抜けていても正しい前月 | RANGE BETWEEN INTERVAL '1 month' PRECEDING AND ... |
月の抜け |
| 前年同月比 | LAG(x, 12) または RANGE ... '12 months' |
前年比 |
| 累計・年ごとの累計 | SUM(x) OVER (PARTITION BY 年 ORDER BY 月) |
累計 |
| 移動平均(行数) | ROWS BETWEEN 2 PRECEDING AND CURRENT ROW |
移動平均 |
| 移動平均(日付の幅) | RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW |
RANGE と ROWS |
| 最初の値・最後の値 | FIRST_VALUE(x)・LAST_VALUE(x)+フレーム指定 |
FIRST_VALUE・LAST_VALUE |
| NULL を直前の値で埋める | COUNT(x) OVER でグループ化して MAX |
NULL の扱い |
確認環境:PostgreSQL 16.15・MySQL 8.0.46(Ubuntu 24.04、apt)。この記事の SQL は両方で実行し、出力はそのときの実際の結果です。2つの DB で書き方が違う所は、両方の書き方を載せています。Oracle と SQL Server は公式ドキュメント(Oracle Database SQL Language Reference、Microsoft Learn の T-SQL リファレンス)で確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQLのLAG・LEADと累計の書き方|前月比・移動平均のコピペ集」
サンプルのテーブル
この記事の SQL は、次の月次売上の表で動かしています。 店 A は2024年1月〜2025年3月の15か月そろっています。店 B は「4月が無い」「2月が0」「6月が NULL(未集計)」という、実データで起きやすい形にしました。
CREATE TABLE sales (
store VARCHAR(10) NOT NULL,
ym DATE NOT NULL, -- 月初の日付
amount INT, -- 売上(千円)。NULL は未集計
PRIMARY KEY (store, ym)
);
INSERT INTO sales (store, ym, amount) VALUES
('A','2024-01-01',100),('A','2024-02-01',120),('A','2024-03-01',90),
('A','2024-04-01',150),('A','2024-05-01',130),('A','2024-06-01',160),
('A','2024-07-01',140),('A','2024-08-01',170),('A','2024-09-01',155),
('A','2024-10-01',180),('A','2024-11-01',175),('A','2024-12-01',210),
('A','2025-01-01',110),('A','2025-02-01',150),('A','2025-03-01',99),
('B','2024-01-01',50),('B','2024-02-01',0),('B','2024-03-01',60),
('B','2024-05-01',80),('B','2024-06-01',NULL),('B','2024-07-01',70);
この CREATE と INSERT は PostgreSQL と MySQL のどちらでもそのまま通ります。
LAG と LEAD の基本
LAG(値, 何行前, 無いときの値)、LEAD(値, 何行後, 無いときの値) の形です。 2つ目と3つ目は省略でき、省略すると「1行」と「NULL」になります。どの順で前後を決めるかは OVER (ORDER BY ...) で指定します。
SELECT ym, amount,
LAG(amount) OVER (ORDER BY ym) AS prev_1,
LAG(amount, 2) OVER (ORDER BY ym) AS prev_2,
LAG(amount, 1, 0) OVER (ORDER BY ym) AS prev_or_0,
LEAD(amount) OVER (ORDER BY ym) AS next_1
FROM sales WHERE store = 'A' AND ym < '2024-07-01'
ORDER BY ym;
ym | amount | prev_1 | prev_2 | prev_or_0 | next_1
------------+--------+--------+--------+-----------+--------
2024-01-01 | 100 | | | 0 | 120
2024-02-01 | 120 | 100 | | 100 | 90
2024-03-01 | 90 | 120 | 100 | 120 | 150
2024-04-01 | 150 | 90 | 120 | 90 | 130
2024-05-01 | 130 | 150 | 90 | 150 | 160
2024-06-01 | 160 | 130 | 150 | 130 |
(PostgreSQL の出力。MySQL も同じ値で、空欄は NULL と表示されます。)
見ておきたい点が2つあります。
- 6月の
next_1が NULL です。 7月の行はありますが、WHERE ym < '2024-07-01'で先に消えています。WHEREはウィンドウ関数より先に実行されるので、絞り込んだ後の行の中で前後を見ます。7月の値を参照したまま6月までを表示したいときは、サブクエリの外で絞り込みます(後述の「WHERE で絞る」)。 - 3つ目の引数(
0)は「行が無いとき」だけ使われます。 前の行があって値が NULL のときは NULL のままです(NULL の扱いの節)。
店ごとに分ける:PARTITION BY
SELECT store, ym, amount,
LAG(amount) OVER (PARTITION BY store ORDER BY ym) AS prev
FROM sales;
PARTITION BY store を付けると、店が変わるところで前後の関係が切れます。付け忘れると、店 B の1月の「前の行」が店 A の最後の月になります。
同じ OVER を何度も書かない:WINDOW 句
SELECT store, ym, amount,
LAG(amount) OVER w AS prev,
LEAD(amount) OVER w AS next
FROM sales
WINDOW w AS (PARTITION BY store ORDER BY ym);
WINDOW 句は PostgreSQL と MySQL 8.0 の両方で動きました。OVER (w ROWS BETWEEN ...) のように、名前付きのウィンドウにフレームを足すこともできます(冒頭のコード)。SQL Server では 2022 以降で、データベースの互換性レベル 160 以上が条件です。
前月比・増減の出し方
増減は amount - LAG(amount) OVER w、前月比(%)は amount * 100.0 / NULLIF(LAG(amount) OVER w, 0) です。
SELECT store, ym, amount,
LAG(amount) OVER w AS prev,
amount - LAG(amount) OVER w AS diff,
ROUND(amount * 100.0 / NULLIF(LAG(amount) OVER w, 0), 1) AS mom_pct
FROM sales
WHERE ym < '2024-08-01'
WINDOW w AS (PARTITION BY store ORDER BY ym)
ORDER BY store, ym;
+-------+------------+--------+------+------+---------+
| store | ym | amount | prev | diff | mom_pct |
+-------+------------+--------+------+------+---------+
| A | 2024-01-01 | 100 | NULL | NULL | NULL |
| A | 2024-02-01 | 120 | 100 | 20 | 120.0 |
| A | 2024-03-01 | 90 | 120 | -30 | 75.0 |
| A | 2024-04-01 | 150 | 90 | 60 | 166.7 |
| A | 2024-05-01 | 130 | 150 | -20 | 86.7 |
| A | 2024-06-01 | 160 | 130 | 30 | 123.1 |
| A | 2024-07-01 | 140 | 160 | -20 | 87.5 |
| B | 2024-01-01 | 50 | NULL | NULL | NULL |
| B | 2024-02-01 | 0 | 50 | -50 | 0.0 |
| B | 2024-03-01 | 60 | 0 | 60 | NULL |
| B | 2024-05-01 | 80 | 60 | 20 | 133.3 |
| B | 2024-06-01 | NULL | 80 | NULL | NULL |
| B | 2024-07-01 | 70 | NULL | NULL | NULL |
+-------+------------+--------+------+------+---------+
(MySQL の出力。PostgreSQL も同じ値でした。)
前年同月比との差を「%ポイント」で出すなら、mom_pct - 100 のように引き算します。
割り算で起きる2つの違い
NULLIF を外し、amount / LAG(amount) OVER w と書いたときの結果です。
| 書き方 | PostgreSQL 16 | MySQL 8.0 |
|---|---|---|
前月が0の行で x / 0 |
ERROR: division by zero(クエリ全体が失敗) |
NULL(警告 1365 Division by 0) |
120 / 100(整数どうし) |
1(整数の割り算) |
1.2000 |
120 * 100.0 / 100 |
120.0 |
120.0 |
PostgreSQL では整数どうしの / は小数が切り捨てられます。* 100.0 を先に掛けて小数にし、NULLIF(前月, 0) で0を NULL に変える書き方なら、どちらの DB でも同じ結果になりました。
月が抜けているときの前月比
LAG は「1つ前の行」を返すので、月が抜けていると前月ではなく前々月の値を返します。 上の結果では、店 B の 2024-05 の prev が 60(3月の値)になっています。4月の行が無いためです。対策は3つあります。
方法1:前の行の日付が本当に前月か確かめる
-- PostgreSQL
SELECT ym, amount,
LAG(ym) OVER w AS prev_ym,
CASE WHEN LAG(ym) OVER w = ym - INTERVAL '1 month'
THEN LAG(amount) OVER w END AS prev_month_amount
FROM sales WHERE store = 'B'
WINDOW w AS (ORDER BY ym) ORDER BY ym;
-- MySQL は INTERVAL の書き方だけ違う
-- CASE WHEN LAG(ym) OVER w = ym - INTERVAL 1 MONTH ...
ym | amount | prev_ym | prev_month_amount
------------+--------+------------+-------------------
2024-01-01 | 50 | |
2024-02-01 | 0 | 2024-01-01 | 50
2024-03-01 | 60 | 2024-02-01 | 0
2024-05-01 | 80 | 2024-03-01 |
2024-06-01 | | 2024-05-01 | 80
2024-07-01 | 70 | 2024-06-01 |
5月の前月の値が NULL(前月のデータなし)になりました。
方法2:RANGE で「ちょうど1か月前」の行だけを見る
-- PostgreSQL
SELECT ym, amount,
MAX(amount) OVER (ORDER BY ym
RANGE BETWEEN INTERVAL '1 month' PRECEDING
AND INTERVAL '1 month' PRECEDING) AS prev_month_amount
FROM sales WHERE store = 'B' ORDER BY ym;
-- MySQL
SELECT ym, amount,
MAX(amount) OVER (ORDER BY ym
RANGE BETWEEN INTERVAL 1 MONTH PRECEDING
AND INTERVAL 1 MONTH PRECEDING) AS prev_month_amount
FROM sales WHERE store = 'B' ORDER BY ym;
RANGE は「行数」ではなく「ORDER BY の列の値の差」で範囲を決めます(後の節で詳しく)。始まりと終わりをどちらも「1か月前」にすると、その日付の行だけが枠に入ります。行が無ければ枠が空になり、MAX は NULL を返します。結果は方法1と同じで、PostgreSQL と MySQL の両方で動きました。 1行に1つの月という前提(主キーが store, ym)があるので MAX で値を1つ取り出せます。
方法3:カレンダーの表で穴を埋めてから LAG
-- PostgreSQL:generate_series で月の一覧を作る
WITH cal AS (
SELECT generate_series(DATE '2024-01-01', DATE '2024-07-01', INTERVAL '1 month')::date AS ym
)
SELECT c.ym, s.amount,
LAG(s.amount) OVER (ORDER BY c.ym) AS prev_month
FROM cal c LEFT JOIN sales s ON s.ym = c.ym AND s.store = 'B'
ORDER BY c.ym;
-- MySQL:再帰 CTE で月の一覧を作る
WITH RECURSIVE cal AS (
SELECT DATE '2024-01-01' AS ym
UNION ALL
SELECT ym + INTERVAL 1 MONTH FROM cal WHERE ym < DATE '2024-07-01'
)
SELECT c.ym, s.amount,
LAG(s.amount) OVER (ORDER BY c.ym) AS prev_month
FROM cal c LEFT JOIN sales s ON s.ym = c.ym AND s.store = 'B'
ORDER BY c.ym;
+------------+--------+------------+
| ym | amount | prev_month |
+------------+--------+------------+
| 2024-01-01 | 50 | NULL |
| 2024-02-01 | 0 | 50 |
| 2024-03-01 | 60 | 0 |
| 2024-04-01 | NULL | 60 |
| 2024-05-01 | 80 | NULL |
| 2024-06-01 | NULL | 80 |
| 2024-07-01 | 70 | NULL |
+------------+--------+------------+
4月の行が現れ、5月の前月が NULL になりました。グラフ用に「売上0の月も行として出したい」ときは、この方法で COALESCE(s.amount, 0) にします。月の一覧の作り方は、関連記事「PostgreSQL generate_series使い方|連番・日付生成」と関連記事「SQL WITH句の使い方|CTEと再帰クエリをコピペSQLと実行結果で解説」で扱っています。
| 方法 | 向いている場面 | 注意 |
|---|---|---|
| 1. 日付を確かめる | 既存のクエリに足すだけで済ませたい | 抜けた月の行は出ない |
| 2. RANGE で1か月前 | 前年同月など、離れた月も同じ形で書きたい | ORDER BY の列は1つで、日付型 |
| 3. カレンダーと結合 | 抜けた月も0として表示したい | 期間の始まりと終わりを指定する |
前年比(前年同月比)
月次で1か月に1行そろっているなら LAG(amount, 12) が前年同月です。 抜けがあり得るなら、方法2の RANGE を「12か月」にします。
SELECT * FROM (
SELECT ym, amount,
LAG(amount, 12) OVER (ORDER BY ym) AS prev_year,
ROUND(amount * 100.0 / NULLIF(LAG(amount, 12) OVER (ORDER BY ym), 0), 1) AS yoy_pct
FROM sales WHERE store = 'A'
) t
WHERE ym >= '2025-01-01';
+------------+--------+-----------+---------+
| ym | amount | prev_year | yoy_pct |
+------------+--------+-----------+---------+
| 2025-01-01 | 110 | 100 | 110.0 |
| 2025-02-01 | 150 | 120 | 125.0 |
| 2025-03-01 | 99 | 90 | 110.0 |
+------------+--------+-----------+---------+
-- 抜けがあっても「ちょうど12か月前」を見る(PostgreSQL)
MAX(amount) OVER (PARTITION BY store ORDER BY ym
RANGE BETWEEN INTERVAL '12 months' PRECEDING AND INTERVAL '12 months' PRECEDING)
-- MySQL は INTERVAL 12 MONTH
WHERE で絞るときはサブクエリの外で
上の SQL は、2025年の行だけを表示するのにサブクエリを使っています。内側で WHERE ym >= '2025-01-01' と書くと、2024年の行が先に消え、prev_year がすべて NULL になります。ウィンドウ関数の結果で絞り込みたいときも同じで、WHERE に直接は書けません。
-- PostgreSQL
ERROR: window functions are not allowed in WHERE
-- MySQL
ERROR 3593 (HY000): You cannot use the window function 'lag' in this context.'
サブクエリか CTE で包み、外側の WHERE で絞ります。Snowflake などにある QUALIFY 句は、PostgreSQL 16 と MySQL 8.0 では構文エラー(syntax error at or near "ROW_NUMBER"・ERROR 1064)でした。SQL の句が実行される順番は、関連記事「SQLの実行順序|書く順番と違う理由と、エラーになる書き方」で扱っています。
累計(ランニングトータル)の書き方
累計は SUM(列) OVER (ORDER BY 並び順) です。 PARTITION BY に年を入れると年ごとに累計がリセットされます(年初来の累計、YTD)。
-- PostgreSQL・MySQL のどちらでも動く(MySQL は EXTRACT の代わりに YEAR(ym) でも可)
SELECT * FROM (
SELECT store, ym, amount,
SUM(amount) OVER (PARTITION BY store, EXTRACT(YEAR FROM ym) ORDER BY ym) AS ytd
FROM sales WHERE store = 'A'
) t
WHERE ym >= '2024-10-01';
store | ym | amount | ytd
-------+------------+--------+------
A | 2024-10-01 | 180 | 1395
A | 2024-11-01 | 175 | 1570
A | 2024-12-01 | 210 | 1780
A | 2025-01-01 | 110 | 110
A | 2025-02-01 | 150 | 260
A | 2025-03-01 | 99 | 359
2024年10月の 1395 は、1月からの累計です。2025年1月で累計が 110 に戻っています。表示を10月以降に絞る WHERE をサブクエリの内側に書くと、1〜9月の行が先に消え、10月の累計は 180 から始まります(2つの DB で確かめました)。会計年度(4月始まりなど)で区切るなら、EXTRACT(YEAR FROM ym - INTERVAL '3 months') のように月をずらした年で分けます(PostgreSQL で、2024年4月に累計が 150 に戻ることを確かめました)。
同じ日付の行があると累計が飛ぶ
ORDER BY だけを書いたときの既定のフレームは RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW です。 RANGE では、並び順の値が同じ行(ピア)をまとめて扱うので、同じ日付の行はどちらも「その日の終わりまでの累計」になります。注文明細の表で確かめました。
CREATE TABLE orders (id INT PRIMARY KEY, order_date DATE NOT NULL, amount INT NOT NULL);
INSERT INTO orders VALUES
(1,'2024-04-01',100),(2,'2024-04-02',200),(3,'2024-04-02',50),
(4,'2024-04-03',300),(5,'2024-04-05',150),(6,'2024-04-06',120);
SELECT id, order_date, amount,
SUM(amount) OVER (ORDER BY order_date) AS sum_default,
SUM(amount) OVER (ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_rows,
SUM(amount) OVER (ORDER BY order_date, id) AS sum_unique,
SUM(amount) OVER () AS total
FROM orders ORDER BY order_date, id;
id | order_date | amount | sum_default | sum_rows | sum_unique | total
----+------------+--------+-------------+----------+------------+-------
1 | 2024-04-01 | 100 | 100 | 100 | 100 | 920
2 | 2024-04-02 | 200 | 350 | 300 | 300 | 920
3 | 2024-04-02 | 50 | 350 | 350 | 350 | 920
4 | 2024-04-03 | 300 | 650 | 650 | 650 | 920
5 | 2024-04-05 | 150 | 800 | 800 | 800 | 920
6 | 2024-04-06 | 120 | 920 | 920 | 920 | 920
(PostgreSQL と MySQL で同じ値でした。)
| 書き方 | 同じ日付の2行の累計 | 使いどころ |
|---|---|---|
ORDER BY order_date(既定) |
どちらも 350 | 日ごとの累計を各行に付けたい |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
300 → 350 | 行ごとに増える累計。ただし同じ日付の中の順は決まらない |
ORDER BY order_date, id |
300 → 350 | 行ごとに増え、順番も毎回同じ |
OVER () |
どちらも 920 | 全体の合計(構成比の分母) |
行ごとの累計が欲しいときは、ORDER BY に主キーなどを足して並び順を一意にするのが確実です。ROWS だけでは、同じ日付の行のどちらが先かは DB に任されます。
累計の構成比(ABC 分析の形)
SELECT store,
SUM(amount) AS total,
ROUND(SUM(SUM(amount)) OVER (ORDER BY SUM(amount) DESC) * 100.0
/ SUM(SUM(amount)) OVER (), 1) AS cum_share
FROM sales GROUP BY store ORDER BY total DESC;
store | total | cum_share
-------+-------+-----------
A | 2139 | 89.2
B | 260 | 100.0
SUM(SUM(amount)) OVER は「GROUP BY で集計した値を、さらにウィンドウで足す」書き方です。ウィンドウ関数は GROUP BY の後に計算されるので、集計関数を中に入れられます。SUM(amount) は NULL を飛ばして足すので、店 B の合計は NULL の6月を除いた 260 です。
日次の明細から月次の前月比を出す
-- PostgreSQL
WITH monthly AS (
SELECT date_trunc('month', order_date)::date AS ym, SUM(amount) AS amount
FROM orders GROUP BY 1
)
SELECT ym, amount,
LAG(amount) OVER (ORDER BY ym) AS prev,
ROUND(amount * 100.0 / NULLIF(LAG(amount) OVER (ORDER BY ym), 0), 1) AS mom_pct
FROM monthly ORDER BY ym;
-- MySQL は月の求め方だけ替える
-- SELECT DATE_FORMAT(order_date, '%Y-%m-01') AS ym, SUM(amount) AS amount FROM orders GROUP BY ym
5月・6月の注文を足して動かした結果です(2つの DB で同じ値)。
ym | amount | prev | mom_pct
------------+--------+------+---------
2024-04-01 | 920 | |
2024-05-01 | 780 | 920 | 84.8
2024-06-01 | 900 | 780 | 115.4
CTE で月次に集計してから LAG をかけると、読みやすく、どちらの DB でも同じ形で書けます。PostgreSQL で GROUP BY 1(::date を付けた式)と、OVER (ORDER BY date_trunc('month', order_date))(::date なし)を1つのクエリに混ぜると、式が一致しないため column "orders.order_date" must appear in the GROUP BY clause になりました。月初・月末の求め方は、関連記事「PostgreSQL date_truncとintervalで月初・月末・差分」で扱っています。
移動平均(ROWS BETWEEN)の書き方
3か月移動平均は AVG(列) OVER (ORDER BY 月 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) です。 「2行前から今の行まで」の3行の平均になります。
SELECT store, ym, amount,
ROUND(AVG(amount) OVER (PARTITION BY store ORDER BY ym
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS ma3,
COUNT(amount) OVER (PARTITION BY store ORDER BY ym
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS n
FROM sales;
| store | ym | amount | ma3 | n |
|---|---|---|---|---|
| A | 2024-01-01 | 100 | 100.0 | 1 |
| A | 2024-02-01 | 120 | 110.0 | 2 |
| A | 2024-03-01 | 90 | 103.3 | 3 |
| A | 2024-04-01 | 150 | 120.0 | 3 |
| B | 2024-01-01 | 50 | 50.0 | 1 |
| B | 2024-02-01 | 0 | 25.0 | 2 |
| B | 2024-03-01 | 60 | 36.7 | 3 |
| B | 2024-05-01 | 80 | 46.7 | 3 |
| B | 2024-06-01 | NULL | 70.0 | 2 |
| B | 2024-07-01 | 70 | 75.0 | 2 |
(全21行のうち、A の1〜4月と B の全行を抜き出しています。PostgreSQL と MySQL で同じ値でした。)
ここから読み取れる注意点です。
- 最初の2行は、3行そろっていない平均です。 A の1月は1行、2月は2行の平均になっています。「3行そろった月だけ出したい」ときは、
COUNT(*) OVER (同じフレーム) = 3の行だけ表示するか、CASE WHEN COUNT(*) OVER (...) = 3 THEN AVG(...) ENDにします。 AVGは NULL を飛ばします。 店 B の6月の枠は「3月60・5月80・6月NULL」の3行で、NULL を除いた2つ(60 と 80)の平均の 70.0 になりました。COUNT(amount)が 2 になっているのはそのためです。NULL を0として平均したいならAVG(COALESCE(amount, 0))にします。ROWSは行数なので、月の抜けを考えません。 店 B の5月の ma3 は「3月・4月・5月」ではなく、「2月・3月・5月」の平均(46.7)です。
よく使うフレームの書き方
| フレーム | 意味 | 用途 |
|---|---|---|
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW |
2行前〜今の行 | 後方3期間の移動平均 |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING |
前後1行ずつ | 中心移動平均 |
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW |
7行 | 日次の7日移動平均(日が抜けない場合) |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
最初〜今の行 | 行ごとの累計 |
ROWS BETWEEN 1 FOLLOWING AND UNBOUNDED FOLLOWING |
次の行〜最後 | 残りの合計 |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
全部 | パーティション全体(LAST_VALUE 用) |
ROWS 2 PRECEDING のように BETWEEN を省くと、終わりは CURRENT ROW になります。
RANGE と ROWS の違い
ROWS は「何行前」、RANGE は「ORDER BY の値がどれだけ離れているか」で範囲を決めます。 PostgreSQL にはさらに GROUPS(同じ値の行のまとまりを1つと数える)があります。注文明細(4月4日が無く、4月2日が2行)で3つを比べました。
-- PostgreSQL
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rows_3,
SUM(amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW) AS range_3days,
SUM(amount) OVER (ORDER BY order_date GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW) AS groups_3
FROM orders ORDER BY order_date, id;
-- MySQL の RANGE は INTERVAL 2 DAY。GROUPS はエラー
order_date | amount | rows_3 | range_3days | groups_3
------------+--------+--------+-------------+----------
2024-04-01 | 100 | 100 | 100 | 100
2024-04-02 | 200 | 300 | 350 | 350
2024-04-02 | 50 | 350 | 350 | 350
2024-04-03 | 300 | 550 | 650 | 650
2024-04-05 | 150 | 500 | 450 | 700
2024-04-06 | 120 | 570 | 270 | 570
4月5日の行で違いがはっきり出ます。
| 単位 | 4月5日の行の枠に入る行 | 合計 |
|---|---|---|
ROWS 2 PRECEDING |
4/2(50)・4/3・4/5 の3行 | 500 |
RANGE '2 days' PRECEDING |
4/3〜4/5 の日付の行(4/4 は無い) | 450 |
GROUPS 2 PRECEDING |
4/2 の2行・4/3・4/5(日付3種類) | 700 |
「直近3日間」「直近7日間」のように期間で区切るなら RANGE、「直近3件」なら ROWS です。RANGE の結果は日付に抜けがあっても期間どおりになり、ROWS の結果は件数どおりになります。MySQL の RANGE と INTERVAL の結果は PostgreSQL と同じでした。MySQL で GROUPS を書くと ERROR 1235 (42000): This version of MySQL doesn't yet support 'GROUPS' になりました。
RANGE に値(2 PRECEDING・INTERVAL ...)を書くときは、ORDER BY の列を1つにする必要があります(PostgreSQL・MySQL・Oracle のドキュメント)。SQL Server は RANGE に値を書けず、UNBOUNDED と CURRENT ROW だけです。
FIRST_VALUE・LAST_VALUE の落とし穴(既定のフレーム)
LAST_VALUE(x) OVER (ORDER BY ...) は、パーティションの最後の値ではなく「今の行の値」を返します。 既定のフレームが「最初〜今の行(と同じ値の行)」で終わっているからです。
SELECT ym, amount,
FIRST_VALUE(amount) OVER (ORDER BY ym) AS first_v,
LAST_VALUE(amount) OVER (ORDER BY ym) AS last_default,
LAST_VALUE(amount) OVER (ORDER BY ym
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_full,
NTH_VALUE(amount, 2) OVER (ORDER BY ym) AS second_v
FROM sales WHERE store = 'A' AND ym < '2024-05-01' ORDER BY ym;
ym | amount | first_v | last_default | last_full | second_v
------------+--------+---------+--------------+-----------+----------
2024-01-01 | 100 | 100 | 100 | 150 |
2024-02-01 | 120 | 100 | 120 | 150 | 120
2024-03-01 | 90 | 100 | 90 | 150 | 120
2024-04-01 | 150 | 100 | 150 | 150 | 120
(PostgreSQL と MySQL で同じ値でした。)
last_defaultは毎行amountと同じ値です。最後の値は、フレームをUNBOUNDED FOLLOWINGまで広げたlast_fullで取れます。NTH_VALUE(amount, 2)の1行目は NULL です。1行目の時点では、フレームに2行目が入っていないためです。FIRST_VALUEは既定のフレームでも先頭から始まるので、期待どおりに動きます。
最後の値は、並び順を逆にした FIRST_VALUE(amount) OVER (ORDER BY ym DESC) でも取れます。こちらはフレームを書かずに済みます。「期首の値と比べた伸び率」は amount * 100.0 / FIRST_VALUE(amount) OVER (PARTITION BY store ORDER BY ym) です。
NULL の扱い
LAG の3つ目の引数は「行が無いとき」だけ使われ、前の行の値が NULL のときは NULL が返ります。 NULL まわりの書き方をまとめます。
| 状況 | 結果 | 書き方 |
|---|---|---|
| 前の行が無い(先頭) | NULL | LAG(x, 1, 0) で0にできる |
| 前の行の値が NULL | NULL(第3引数は使われない) | COALESCE(LAG(x) OVER w, 0) |
x - NULL |
NULL | COALESCE(x, 0) - COALESCE(LAG(x) OVER w, 0) |
SUM・AVG の枠に NULL |
NULL を飛ばして計算 | 0 として数えるなら AVG(COALESCE(x, 0)) |
| 前の「NULL でない」値がほしい | IGNORE NULLS は PostgreSQL・MySQL で不可 |
下の COUNT の書き方 |
NULL を直前の値で埋める(IGNORE NULLS の代わり)
LAG(amount) IGNORE NULLS OVER (...) は、PostgreSQL 16 では syntax error at or near "NULLS"、MySQL 8.0 では ERROR 1235 (42000): This version of MySQL doesn't yet support 'IGNORE NULLS' でした。代わりに、「NULL でない値の個数」の累計でグループを作ります。
SELECT ym, amount, grp,
MAX(amount) OVER (PARTITION BY grp) AS filled
FROM (
SELECT ym, amount, COUNT(amount) OVER (ORDER BY ym) AS grp
FROM sales WHERE store = 'B'
) t ORDER BY ym;
+------------+--------+-----+--------+
| ym | amount | grp | filled |
+------------+--------+-----+--------+
| 2024-01-01 | 50 | 1 | 50 |
| 2024-02-01 | 0 | 2 | 0 |
| 2024-03-01 | 60 | 3 | 60 |
| 2024-05-01 | 80 | 4 | 80 |
| 2024-06-01 | NULL | 4 | 80 |
| 2024-07-01 | 70 | 5 | 70 |
+------------+--------+-----+--------+
COUNT(amount) は NULL を数えないので、NULL の行は直前の行と同じ grp になります。そのグループの中で NULL でない値は1つだけなので、MAX でそれを配ります。PostgreSQL でも同じ結果でした。Oracle と SQL Server 2022 以降なら LAG(amount) IGNORE NULLS OVER (...) と書けます(各ドキュメント)。
NULL 全般の落とし穴は、関連記事「SQLのNULLの扱い|COALESCE・NULLIFと落とし穴12選」で扱っています。
DB ごとの違い
PostgreSQL 16・MySQL 8.0 は実行して、Oracle・SQL Server はドキュメントで確かめた内容です。
| 項目 | PostgreSQL 16 | MySQL 8.0 | Oracle | SQL Server |
|---|---|---|---|---|
| LAG・LEAD・FIRST_VALUE など | ○ | ○(8.0 から) | ○ | ○(2012 から) |
| LAG の offset に式 | ○(行ごとに評価) | 整数リテラル・?・変数のみ |
0より大きい整数 | 式・サブクエリ可(負は不可) |
| LAG の offset に負の値 | ○(LAG(x,-1) が LEAD と同じ) |
構文エラー | 不可 | 不可 |
| IGNORE NULLS | × | ×(解析はされるがエラー) | ○ | ○(2022 から) |
| ROWS / RANGE | ○ / ○ | ○ / ○ | ○ / ○ | ○ / ○ |
| RANGE に値・INTERVAL | ○ | ○ | ○ | ×(UNBOUNDED と CURRENT ROW のみ) |
| GROUPS | ○ | ×(エラー) | ○ | ドキュメントの構文に無し |
| EXCLUDE | ○ | ×(エラー) | ○ | ドキュメントの構文に無し |
| WINDOW 句 | ○ | ○ | 26ai のリファレンスに記載 | ○(2022・互換性レベル160) |
| QUALIFY | × | × | ○(26ai) | × |
| 既定のフレーム(ORDER BY あり) | RANGE … CURRENT ROW | RANGE … CURRENT ROW | RANGE … CURRENT ROW | RANGE … CURRENT ROW |
MySQL の offset は、8.0.22 以降は「符号なし整数リテラル・?・ユーザー変数・ストアドルーチンのローカル変数」です(SET @n = 12; のあと LAG(amount, @n) は動きました)。LAG(amount, 1+1) と書くと構文エラーでした。PostgreSQL では LAG(amount, (SELECT 1)) のような式も通りました。
割り算は、SQL Server もドキュメントで「整数どうしの割り算は小数部分を切り捨てた整数」とされています。前月比は、どの DB でも * 100.0 と NULLIF(前月, 0) を付けた形で書くと、同じ考え方で扱えます。
日付の書き方の違いもよく出ます。
| 処理 | PostgreSQL | MySQL | Oracle | SQL Server |
|---|---|---|---|---|
| 1か月前 | ym - INTERVAL '1 month' |
ym - INTERVAL 1 MONTH |
ADD_MONTHS(ym, -1) |
DATEADD(month, -1, ym) |
| 月初に丸める | date_trunc('month', d) |
DATE_FORMAT(d, '%Y-%m-01') |
TRUNC(d, 'MM') |
DATETRUNC(month, d)(2022) |
| 年を取り出す | EXTRACT(YEAR FROM d) |
YEAR(d) |
EXTRACT(YEAR FROM d) |
YEAR(d) |
日付関数の一覧は、関連記事「SQL 日付関数 一覧|4DB比較表とコピペSQL・実行結果つき」で扱っています。
よくある質問
Q. 自己結合(ON b.ym = a.ym - 1か月)と LAG はどちらがよいですか。
A. 1つ前・1年前といった「決まった距離の行」なら、どちらでも同じ値が出ます。LAG は表を1回読むだけで書け、店ごとの区切りも PARTITION BY で済みます。月の抜けを自然に NULL にしたいときは、自己結合や RANGE の方が素直です。自己結合の書き方は、関連記事「SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較」で扱っています。
Q. ROW_NUMBER との違いは何ですか。 A. ROW_NUMBER は順位(番号)を付ける関数で、前後の行の「値」は取りません。上位 n 件の取り出しは、関連記事「SQL ROW_NUMBER・RANK・DENSE_RANKの違いと上位n件取得」で扱っています。ウィンドウ関数全体の入門は、関連記事「SQLウィンドウ関数の使い方|累計・前月比・上位N件を実行結果で解説」にあります。
Q. ウィンドウ関数の結果で UPDATE できますか。 A. ウィンドウ関数を UPDATE の SET に直接は書けません(MySQL のドキュメントにも制限として記載)。サブクエリや CTE で計算し、その結果と結合して UPDATE します。
Q. 累計が急に重くなりました。
A. ウィンドウ関数は PARTITION BY と ORDER BY の順に並べ替えてから計算します。並べ替えの量が大きいときは、その順の複合インデックス(この記事なら主キーの (store, ym))があるか、EXPLAIN で確かめます。インデックスの考え方は、関連記事「SQLインデックスの作成方法と仕組み|CREATE INDEXを実行結果で解説」で扱っています。
まとめ
- 前後の行は
LAG・LEAD。店ごとならPARTITION BYを忘れない - 前月比は
x * 100.0 / NULLIF(LAG(x) OVER w, 0)。PostgreSQL は0除算でエラー、整数どうしは切り捨て - 月が抜けると LAG は前々月を返す。日付を確かめる・RANGE で「ちょうど1か月前」・カレンダー結合のどれかで直す
- 絞り込みは サブクエリの外の WHERE で。内側で絞ると前の行が消える
- 累計は
SUM() OVER (ORDER BY ...)。既定は RANGE なので、同じ値の行は同じ累計になる。行ごとなら ORDER BY を一意に - 移動平均は
ROWS BETWEEN n PRECEDING AND CURRENT ROW(件数)かRANGE ... INTERVAL(期間) LAST_VALUEは既定のフレームだと今の行の値。UNBOUNDED FOLLOWINGまで広げるIGNORE NULLSは PostgreSQL・MySQL では使えない。COUNT(x) OVERでグループを作って埋める
参考・出典
確認日はいずれも 2026年10月13日です。
- PostgreSQL 16 Documentation「9.22. Window Functions」:https://www.postgresql.org/docs/16/functions-window.html
- PostgreSQL 16 Documentation「4.2.8. Window Function Calls」:https://www.postgresql.org/docs/16/sql-expressions.html
- MySQL 8.0 Reference Manual「Window Function Descriptions」:https://dev.mysql.com/doc/refman/8.0/en/window-function-descriptions.html
- MySQL 8.0 Reference Manual「Window Function Frame Specification」:https://dev.mysql.com/doc/refman/8.0/en/window-functions-frames.html
- MySQL 8.0 Reference Manual「Window Function Restrictions」:https://dev.mysql.com/doc/refman/8.0/en/window-function-restrictions.html
- Oracle Database SQL Language Reference「Analytic Functions」:https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/Analytic-Functions.html
- Oracle Database SQL Language Reference「LAG」:https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/LAG.html
- ORACLE-BASE「QUALIFY Clause in Oracle AI Database 26ai」:https://oracle-base.com/articles/26/qualify-clause-26
- Microsoft Learn「SELECT - OVER clause (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/queries/select-over-clause-transact-sql
- Microsoft Learn「LAG (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/functions/lag-transact-sql
- Microsoft Learn「SELECT - WINDOW clause (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/queries/select-window-transact-sql
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQLのLAG・LEADと累計の書き方|前月比・移動平均のコピペ集」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。