前の行の値は 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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?