売上0件の日も表示するSQL:再帰CTEで連続した日付を作る

a calendar with red push buttons pinned to it SQL

日別の売上を GROUP BY で集計すると、注文が1件もない日は結果に出てきません。グラフにすると日付が飛んでしまい、「0件だった」のか「集計漏れ」なのかも見分けがつかなくなります。この記事では、再帰CTE(WITH RECURSIVE)で連続した日付を作り、LEFT JOINで実データとつないで0件の日も表示する方法を紹介します。あわせて、日付を前もって入れておくカレンダーテーブルとの使い分けと、COUNTやWHERE句で起きやすい落とし穴も整理します。

このシナリオで考える

題材は、ネットショップの注文テーブル orders です。注文日時は DATETIME で持っています。2026年10月1日〜7日の1週間で、3日と5日は注文がありませんでした。

CREATE TABLE orders (
  id         INT PRIMARY KEY AUTO_INCREMENT,
  ordered_at DATETIME NOT NULL,
  amount     INT NOT NULL,
  INDEX idx_ordered_at (ordered_at)
);

INSERT INTO orders (ordered_at, amount) VALUES
  ('2026-10-01 10:15:00', 1200),
  ('2026-10-01 14:30:00',  800),
  ('2026-10-02 09:00:00', 1500),
  ('2026-10-04 18:20:00', 2000),
  ('2026-10-06 11:45:00',  500),
  ('2026-10-06 20:10:00',  700),
  ('2026-10-07 13:00:00', 1000);

まずは素直に日別で集計してみます。

SELECT DATE(ordered_at) AS d, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY DATE(ordered_at)
ORDER BY d;
+------------+-----+-------+
| d          | cnt | total |
+------------+-----+-------+
| 2026-10-01 |   2 |  2000 |
| 2026-10-02 |   1 |  1500 |
| 2026-10-04 |   1 |  2000 |
| 2026-10-06 |   2 |  1200 |
| 2026-10-07 |   1 |  1000 |
+------------+-----+-------+

3日と5日の行がありません。GROUP BY は「存在する行」をまとめる処理です。存在しない日の行を作り出すことはできません。そこで、日付の一覧を別に用意して、そこに注文データをつなぐ発想に切り替えます。

再帰CTEで連続した日付を作る

再帰CTEを使うと、テーブルがなくても連続した日付を作れます。再帰CTEは、自分自身を参照するWITH句です。MySQLでは 8.0 から使えます。

WITH RECURSIVE calendar AS (
  SELECT DATE '2026-10-01' AS d          -- 起点(1回だけ実行)
  UNION ALL
  SELECT d + INTERVAL 1 DAY FROM calendar -- 前の行に1日足す
  WHERE d < '2026-10-07'                 -- 終点で止める
)
SELECT d FROM calendar;
+------------+
| d          |
+------------+
| 2026-10-01 |
| 2026-10-02 |
| 2026-10-03 |
| 2026-10-04 |
| 2026-10-05 |
| 2026-10-06 |
| 2026-10-07 |
+------------+

動きは次の3ステップで理解できます。

  1. UNION ALL より上の部分(非再帰部分)が1回だけ実行され、10月1日の行ができる
  2. 下の部分(再帰部分)が、直前にできた行をもとに「1日後」の行を作る
  3. WHERE 条件を満たさなくなり、新しい行が0件になった時点で終わる

WHERE 句は無限ループを防ぐブレーキ役です。書き忘れると止まりません。とはいえMySQLには安全装置があり、既定では1000回を超えた時点でエラーになります(詳しくは落とし穴の節で触れます)。

期間を固定値で書きたくない場合は、起点と終点をテーブルから取ることもできます。公式リファレンスにも、MIN(date) から MAX(date) までの日付を作る例が載っています。

LEFT JOINで0件の日を埋める

日付の一覧ができたら、それを左側に置いて注文データを LEFT JOIN します。LEFT JOIN は左側の行をすべて残すので、注文がない日も1行として残ります。

WITH RECURSIVE calendar AS (
  SELECT DATE '2026-10-01' AS d
  UNION ALL
  SELECT d + INTERVAL 1 DAY FROM calendar
  WHERE d < '2026-10-07'
)
SELECT
  c.d,
  COUNT(o.id)                AS cnt,
  COALESCE(SUM(o.amount), 0) AS total
FROM calendar AS c
LEFT JOIN orders AS o
  ON  o.ordered_at >= c.d
  AND o.ordered_at <  c.d + INTERVAL 1 DAY
GROUP BY c.d
ORDER BY c.d;
+------------+-----+-------+
| d          | cnt | total |
+------------+-----+-------+
| 2026-10-01 |   2 |  2000 |
| 2026-10-02 |   1 |  1500 |
| 2026-10-03 |   0 |     0 |
| 2026-10-04 |   1 |  2000 |
| 2026-10-05 |   0 |     0 |
| 2026-10-06 |   2 |  1200 |
| 2026-10-07 |   1 |  1000 |
+------------+-----+-------+

3日と5日が0件として表示されました。ポイントは3つあります。

書き方 理由
FROM calendar LEFT JOIN orders 日付の一覧を主役にして、全日付を残す
COUNT(o.id) 注文がない日は o.id が NULL なので 0 になる
COALESCE(SUM(o.amount), 0) SUM は対象がないと NULL を返すので 0 に置き換える

結合条件を DATE(o.ordered_at) = c.d ではなく範囲で書いている点にも注目してください。列を関数で包むと、ordered_at のインデックスが使えなくなります。範囲で書けば、データが増えても速さを保てます。

カレンダーテーブルを用意する方法

同じ処理をあちこちで書くなら、日付だけを入れた「カレンダーテーブル」を作っておく方法もあります。祝日や営業日のフラグを持たせられるのが大きな利点です。

CREATE TABLE calendar (
  cal_date   DATE PRIMARY KEY,
  is_holiday TINYINT NOT NULL DEFAULT 0
);

-- 10年分(約3650日)を入れるので再帰の上限を引き上げる
SET SESSION cte_max_recursion_depth = 5000;

INSERT INTO calendar (cal_date)
WITH RECURSIVE seq AS (
  SELECT DATE '2026-01-01' AS d
  UNION ALL
  SELECT d + INTERVAL 1 DAY FROM seq
  WHERE d < '2035-12-31'
)
SELECT d FROM seq;

あとは集計クエリの FROM を calendar テーブルに変え、WHERE cal_date BETWEEN '2026-10-01' AND '2026-10-07' で期間を絞るだけです。2つの方法は次のように使い分けます。

観点 再帰CTE カレンダーテーブル
準備 不要(クエリだけで完結) テーブル作成とデータ投入が必要
祝日・営業日 扱いにくい フラグ列で簡単に扱える
長い期間 再帰の上限に注意 上限を気にせず使える
向いている場面 単発の分析、権限がない環境 定期レポート、ダッシュボード

月単位で埋めたいときも考え方は同じです。再帰部分を d + INTERVAL 1 MONTH に変えれば、月初日の一覧が作れます。PostgreSQLでは generate_series('2026-10-01'::date, '2026-10-07'::date, interval '1 day') で同じ一覧が1行で作れます。

よくある落とし穴 / 注意点

  • COUNT(*) だと0件の日が「1」になる:LEFT JOIN で残った行は、注文側の列が NULL の1行として存在します。COUNT(*) はその行も数えます。必ず COUNT(o.id) のように右側テーブルの列を数えてください
  • 右側テーブルの条件を WHERE に書くと日付が消える:WHERE o.amount >= 1000 と書くと、NULL の行が落ちて INNER JOIN と同じ結果になります。今回のデータなら3日・5日に加え、6日も消えてしまいます。右側の条件は ON ... AND o.amount >= 1000 のように ON 句へ入れましょう
  • 再帰の上限は既定で1000回:cte_max_recursion_depth の既定値は1000です。3年分の日付を作ろうとすると上限を超えてエラーになります。セッション単位で引き上げるか、カレンダーテーブルに切り替えてください
  • 起点は DATE 型で書く:再帰CTEの列の型は、非再帰部分だけで決まります。SELECT '2026-10-01' と文字列で書くと列が文字列型になり、比較や結合で思わぬ型変換が起きます。DATE '2026-10-01' や CAST(... AS DATE) で型を明示すると安全です

まとめ

GROUP BY だけでは、データが存在しない日は表に出せません。再帰CTEかカレンダーテーブルで「日付の一覧」を用意し、それを左に置いて LEFT JOIN するのが定番の解き方です。集計では COUNT(右側の列) と COALESCE(SUM(...), 0) を使い、右側の条件は ON 句に書くと覚えてください。単発の分析なら再帰CTE、定期レポートならカレンダーテーブルがおすすめです。

参考リンク

アイキャッチ画像: Photo by Towfiqu barbhuiya on Unsplash

タイトルとURLをコピーしました