「先月より増えた」は本当に良いことか
月次レポートで「今月の売上は先月より10%増えました」という報告を見たとき、素直に喜んでいいのだろうか。
実は通販業界には強い季節性がある。年末に向けてギフト需要が高まり、1月は反動で落ちる。夏に日焼け止めが売れ、冬に保湿クリームが売れる。前月比だけを見ていると、この季節変動を「施策の成果」と誤認してしまう。
だから売上分析では前月比と前年同月比の2つを常にセットで見るのが鉄則だ。前月比は「今月の動き」を、前年同月比は「季節変動を排除した成長率」を教えてくれる。
この記事では、LAG関数を使って前月・前年同月の数値を同じ行に並べ、変化率まで一発で計算するクエリを作る。月次レポートの定番クエリとして、そのまま使い回せる内容にした。
使用するテーブル
今回は orders テーブルを使う。
-- orders テーブル
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日(DATE型)
-- total_amount : 注文金額
-- status : 'completed' / 'cancelled' などSTEP 1 ― 月次売上を集計する
まず月ごとの売上・注文件数・購買顧客数を集計する。これが比較の土台になる。
-- STEP1: 月次売上の集計
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) AS sales_month,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT customer_id) AS customer_count,
SUM(total_amount) AS total_sales,
ROUND(AVG(total_amount), 0) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT *
FROM monthly_sales
ORDER BY sales_month;
出力イメージ
| sales_month | order_count | customer_count | total_sales | avg_order_value |
|---|---|---|---|---|
| 2023-01-01 | 1,241 | 1,089 | 18,420,000 | 14,843 |
| 2023-02-01 | 1,198 | 1,052 | 17,280,000 | 14,424 |
| … | … | … | … | … |
| 2024-01-01 | 1,389 | 1,214 | 21,340,000 | 15,364 |
| 2024-02-01 | 1,312 | 1,148 | 19,860,000 | 15,137 |
月ごとに1行のデータが揃った。次はこの「縦に並んだ時系列」を横に展開して、前月・前年同月を同じ行に持ってくる。
STEP 2 ― LAG関数で前月・前年同月の値を取得する
LAG関数は「現在の行から N 行前の値を取得する」ウィンドウ関数だ。
- 前月の値:
LAG(col, 1) OVER (ORDER BY sales_month)← 1行前 - 前年同月の値:
LAG(col, 12) OVER (ORDER BY sales_month)← 12行前(月次データなので12ヶ月前)
-- STEP2: LAGで前月・前年同月の値を同じ行に並べる
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) AS sales_month,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT customer_id) AS customer_count,
SUM(total_amount) AS total_sales,
ROUND(AVG(total_amount), 0) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)
),
with_lag AS (
SELECT
sales_month,
order_count,
customer_count,
total_sales,
avg_order_value,
-- 前月の値
LAG(total_sales, 1) OVER (ORDER BY sales_month) AS prev_month_sales,
LAG(order_count, 1) OVER (ORDER BY sales_month) AS prev_month_orders,
LAG(customer_count, 1) OVER (ORDER BY sales_month) AS prev_month_customers,
-- 前年同月の値(12行前)
LAG(total_sales, 12) OVER (ORDER BY sales_month) AS prev_year_sales,
LAG(order_count, 12) OVER (ORDER BY sales_month) AS prev_year_orders,
LAG(customer_count, 12) OVER (ORDER BY sales_month) AS prev_year_customers
FROM monthly_sales
)
SELECT *
FROM with_lag
ORDER BY sales_month;出力イメージ(一部)
| sales_month | total_sales | prev_month_sales | prev_year_sales |
|---|---|---|---|
| 2023-01-01 | 18,420,000 | NULL | NULL |
| 2023-02-01 | 17,280,000 | 18,420,000 | NULL |
| … | … | … | … |
| 2024-01-01 | 21,340,000 | 19,120,000 | 18,420,000 |
| 2024-02-01 | 19,860,000 | 21,340,000 | 17,280,000 |
2024年1月には「前月(2023年12月)の売上」と「前年同月(2023年1月)の売上」が同じ行に並んでいる。LAGの行数(1行前・12行前)がデータの月数に依存するため、データが12ヶ月以上揃っている必要がある点に注意しよう。
STEP 3 ― 変化率を計算する(完成版)
前月・前年同月の売上が揃ったので、変化率(増減率)を計算して完成させる。
ゼロ除算を防ぐため、分母には NULLIF を使う。これはLTV編でも登場したテクニックだ。
-- STEP3: 前月比・前年同月比まで含めた完成版クエリ
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) AS sales_month,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT customer_id) AS customer_count,
SUM(total_amount) AS total_sales,
ROUND(AVG(total_amount), 0) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)
),
with_lag AS (
SELECT
sales_month,
order_count,
customer_count,
total_sales,
avg_order_value,
LAG(total_sales, 1) OVER (ORDER BY sales_month) AS prev_month_sales,
LAG(order_count, 1) OVER (ORDER BY sales_month) AS prev_month_orders,
LAG(customer_count, 1) OVER (ORDER BY sales_month) AS prev_month_customers,
LAG(total_sales, 12) OVER (ORDER BY sales_month) AS prev_year_sales,
LAG(order_count, 12) OVER (ORDER BY sales_month) AS prev_year_orders,
LAG(customer_count, 12) OVER (ORDER BY sales_month) AS prev_year_customers
FROM monthly_sales
)
SELECT
sales_month,
-- 今月の実績
total_sales,
order_count,
customer_count,
avg_order_value,
-- 前月比(売上)
prev_month_sales,
ROUND(
(total_sales - prev_month_sales) * 100.0
/ NULLIF(prev_month_sales, 0)
, 1) AS mom_sales_growth_pct, -- Month over Month
-- 前年同月比(売上)
prev_year_sales,
ROUND(
(total_sales - prev_year_sales) * 100.0
/ NULLIF(prev_year_sales, 0)
, 1) AS yoy_sales_growth_pct, -- Year over Year
-- 前月比(注文件数)
ROUND(
(order_count - prev_month_orders) * 100.0
/ NULLIF(prev_month_orders, 0)
, 1) AS mom_order_growth_pct,
-- 前年同月比(注文件数)
ROUND(
(order_count - prev_year_orders) * 100.0
/ NULLIF(prev_year_orders, 0)
, 1) AS yoy_order_growth_pct,
-- 前月比(購買顧客数)
ROUND(
(customer_count - prev_month_customers) * 100.0
/ NULLIF(prev_month_customers, 0)
, 1) AS mom_customer_growth_pct,
-- 前年同月比(購買顧客数)
ROUND(
(customer_count - prev_year_customers) * 100.0
/ NULLIF(prev_year_customers, 0)
, 1) AS yoy_customer_growth_pct
FROM with_lag
WHERE sales_month >= DATE '2024-01-01' -- 表示したい期間を絞る
ORDER BY sales_month;
完成した出力イメージ
| sales_month | total_sales | mom_sales_growth_pct | yoy_sales_growth_pct | mom_order_growth_pct | yoy_order_growth_pct |
|---|---|---|---|---|---|
| 2024-01-01 | 21,340,000 | +11.6 | +15.8 | +9.4 | +12.0 |
| 2024-02-01 | 19,860,000 | -7.0 | +15.0 | -6.1 | +9.6 |
| 2024-03-01 | 22,180,000 | +11.7 | +14.2 | +10.3 | +11.8 |
| 2024-04-01 | 20,940,000 | -5.6 | +12.9 | -4.8 | +10.4 |
| … | … | … | … | … | … |
2月の前月比はマイナス7.0%と落ち込んでいるが、前年同月比はプラス15.0%だ。「今月は先月より下がった」と焦る必要はない。2月は例年そういう月なのかもしれず、むしろ前年より15%成長しているなら健全な状態だと判断できる。
応用:商品カテゴリ別に前年同月比を出す
月次全体の比較だけでなく、カテゴリ別に前年同月比を並べると「成長しているカテゴリ」と「停滞しているカテゴリ」が一目で分かる。
PARTITION BY を追加するだけで、カテゴリを分けたままLAGを適用できる。
-- カテゴリ別 前年同月比
WITH monthly_category_sales AS (
SELECT
DATE_TRUNC('month', o.order_date) AS sales_month,
oi.category,
SUM(oi.quantity * oi.unit_price) AS total_sales
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status = 'completed'
GROUP BY
DATE_TRUNC('month', o.order_date),
oi.category
),
with_yoy AS (
SELECT
sales_month,
category,
total_sales,
-- PARTITION BY category で、カテゴリ内の12行前を取得
LAG(total_sales, 12) OVER (
PARTITION BY category
ORDER BY sales_month
) AS prev_year_sales
FROM monthly_category_sales
)
SELECT
sales_month,
category,
total_sales,
prev_year_sales,
ROUND(
(total_sales - prev_year_sales) * 100.0
/ NULLIF(prev_year_sales, 0)
, 1) AS yoy_growth_pct
FROM with_yoy
WHERE
sales_month >= DATE '2024-01-01'
AND prev_year_sales IS NOT NULL
ORDER BY sales_month, yoy_growth_pct DESC;
出力イメージ
| sales_month | category | total_sales | prev_year_sales | yoy_growth_pct |
|---|---|---|---|---|
| 2024-01-01 | スキンケア | 8,420,000 | 6,980,000 | +20.6 |
| 2024-01-01 | ヘアケア | 6,240,000 | 5,510,000 | +13.2 |
| 2024-01-01 | ボディケア | 4,180,000 | 3,920,000 | +6.6 |
| 2024-01-01 | メイク | 2,500,000 | 2,610,000 | -4.2 |
| … | … | … | … | … |
1月はスキンケアが前年比+20.6%と好調だが、メイクカテゴリは-4.2%と前年割れしている。全体の売上が成長していても、このカテゴリ別の凸凹が見えないと打ち手が定まらない。
実務での運用ヒント
① 変化率を「絶対額の差」も合わせて出す
変化率だけでは規模感が伝わらない。前年比+30%でも、絶対額の差が100万円と1億円では意味が全く違う。total_sales - prev_year_sales AS yoy_sales_diff を追加して両方出しておこう。
② データ欠損月(売上ゼロの月)の扱い
キャンペーン停止などで特定の月に売上がゼロになると、その月がCTEのGROUP BYで生成されない。すると LAG の「12行前」がズレて正しくない値を拾ってしまう。対策として、カレンダーテーブル(全月のマスタ)とLEFT JOINして売上がない月にも0を補完する方法がある。
-- カレンダーCTEで欠損月を補完するイメージ
WITH calendar AS (
SELECT DATE_TRUNC('month', dt) AS sales_month
FROM UNNEST(SEQUENCE(DATE '2023-01-01', DATE '2024-12-01', INTERVAL '1' MONTH)) AS t(dt)
)
SELECT
c.sales_month,
COALESCE(m.total_sales, 0) AS total_sales
FROM calendar c
LEFT JOIN monthly_sales m ON c.sales_month = m.sales_month
③ LAGは「行数」で数えることを常に意識する
LAG(col, 12) は「12行前の値」であり「12ヶ月前の値」ではない。月次データに1行ずつデータが揃っていれば一致するが、データが欠損していたり週次・日次データを集計している場合はズレが生じる。日次データから前年同日比を出したい場合は LAG(col, 365) ではなく、DATE_ADD('year', -1, date) を使って明示的に日付で対応する方法が安全だ。
まとめ
今回のクエリの骨格を振り返る。
GROUP BY DATE_TRUNC('month', ...)で月次集計を作るLAG(col, 1) OVER (ORDER BY sales_month)で前月の値を同じ行に引っ張るLAG(col, 12) OVER (ORDER BY sales_month)で前年同月の値を引っ張る(今月 - 前月) / NULLIF(前月, 0)で変化率を計算する- カテゴリ別比較には
PARTITION BYを加えるだけで対応できる
前月比と前年同月比を常にセットで見る習慣を持つだけで、売上の読み方が格段に深くなる。「季節変動に乗っているだけか」「本当に成長しているか」を数字で判断できることが、データドリブンなマーケターとそうでない人の分かれ目だ。