【SQL】日付の範囲指定完全ガイド|BETWEEN・>= AND <=・月別/四半期/年度パターン・期間集計・インデックスまで解説

「今月のデータだけを集計したい」「キャンペーン期間中の注文を取り出したい」「会計年度ごとに売上を分けたい」——SQLの日付範囲指定は実務で毎日使う操作です。

しかし BETWEEN を DATETIME 型の列に使うと最終日が抜ける・>= AND <= と BETWEEN のどちらを使うべきか分からない・月初〜月末の境界値をどう書けばよいか迷うといった声をよく聞きます。

この記事では BETWEEN の内部動作から、月・四半期・会計年度の完全なコードパターン、期間別集計、複合インデックスの活用まで体系的に解説します。

日付の「比較演算子(= / > / <)の基本」や「SARGable 条件・タイムゾーン対応」については【SQL】日付の比較完全ガイドで詳しく解説しています。この記事は 範囲指定のパターン実装 に特化した内容です。
サンプルデータ(以降の例で使用)
-- orders テーブル(注文)
-- id | customer_id | amount | status   | ordered_at
--  1 |           1 |  12000 | shipped  | 2024-01-15 09:30:00
--  2 |           2 |   3500 | pending  | 2024-02-01 15:45:00
--  3 |           3 |  85000 | shipped  | 2024-02-20 11:00:00
--  4 |           1 |   2800 | shipped  | 2024-03-05 08:00:00
--  5 |           4 |  45000 | pending  | 2024-03-10 17:30:00
--  6 |           2 |   9500 | canceled | 2024-04-01 10:00:00
--  7 |           5 |  32000 | shipped  | 2024-04-15 14:00:00
--  8 |           3 |   7800 | shipped  | 2024-05-20 09:00:00
--  9 |           1 |  55000 | shipped  | 2024-06-30 16:30:00

-- campaigns テーブル(キャンペーン期間)
-- id | name              | start_date  | end_date
--  1 | 春のセール        | 2024-03-01  | 2024-03-31
--  2 | ゴールデンウィーク| 2024-04-27  | 2024-05-06
--  3 | 夏のボーナスセール| 2024-06-01  | 2024-06-30
スポンサーリンク

BETWEEN 演算子の基本と閉区間の特性

BETWEEN A AND B>= A AND <= B と等価です。両端(A と B)を含む閉区間であることが重要なポイントです。

書き方 意味 端点の扱い
BETWEEN '2024-01-01' AND '2024-03-31' 1月1日以上 かつ 3月31日以下 両端を含む
>= '2024-01-01' AND <= '2024-03-31' 同上(完全に等価) 両端を含む
>= '2024-01-01' AND < '2024-04-01' 1月1日以上 かつ 4月1日より前 開始を含む・終端を含まない
NOT BETWEEN '2024-01-01' AND '2024-03-31' 1月1日より前 または 3月31日より後 両端を含まない
BETWEEN の基本的な使い方(DATE 型)
-- 2024年1月〜3月の注文(DATE列に BETWEEN:両端を含む)
SELECT id, customer_id, amount, ordered_at
FROM orders
WHERE ordered_at BETWEEN '2024-01-01' AND '2024-03-31';
-- → id 1〜5 が該当(3月31日 23:59:59 のデータも含まれるか?→後述)

-- 上と等価な書き方
SELECT id, customer_id, amount, ordered_at
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <= '2024-03-31';
BETWEEN は必ず「A が小さく・B が大きい」順で書く:
BETWEEN '2024-03-31' AND '2024-01-01' のように大小が逆だと常に 0 件になります。値の大小比較は内部で col >= '2024-03-31' AND col <= '2024-01-01' となり、絶対に真にならないためです。

>= AND <= と BETWEEN の使い分け

内部的には等価ですが、実務では以下の基準で使い分けるのが一般的です。

状況 推奨 理由
DATE 型の列(時刻なし) BETWEEN 月末・年末が閉区間で自然に表現できる
DATETIME / TIMESTAMP 型の列 >= 開始日 AND < 翌日 BETWEEN だと終端の時刻が抜ける可能性がある(後述)
アプリから動的に日付を渡す場合 >= AND < 翌日 UI で選んだ「終了日の翌日」を渡すと BETWEEN より明確
可読性を優先したい場合 BETWEEN 「A から B まで」の意図が直感的に伝わる
境界値の加工が必要な場合 >= AND < 翌月初・翌年初などを計算して < に渡すほうが安全
DATE 型 vs DATETIME 型での書き方の違い
-- DATE型の列(時刻なし)→ BETWEEN が自然
-- memberships.expired_at が DATE 型の場合
SELECT * FROM memberships
WHERE expired_at BETWEEN '2024-01-01' AND '2024-12-31';
-- 12月31日のレコードを正確に含む

-- DATETIME型の列(時刻あり)→ 翌日 00:00:00 未満が安全
-- orders.ordered_at が DATETIME 型の場合

-- 非推奨:最終日の時刻部分が漏れる可能性
WHERE ordered_at BETWEEN '2024-01-01' AND '2024-03-31';
-- '2024-03-31 23:59:59'以降のデータは取得できない場合がある

-- 推奨:翌月初(2024-04-01 00:00:00)未満で指定
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2024-04-01';
-- 2024-03-31 のいかなる時刻のデータも完全に含む

DATETIME 型への BETWEEN 適用の落とし穴と対策

DATETIME 型の列に BETWEEN '2024-03-31' AND '2024-03-31' のように同じ日付を指定すると、'2024-03-31 00:00:00' の 1 点にしかマッチしません。終端に時刻を指定しないと当日データが取れないケースがあります。

DATETIME 型の BETWEEN 落とし穴と3つの対策
-- NG①: 終端が 00:00:00 扱いになり当日データが漏れる
SELECT * FROM orders
WHERE ordered_at BETWEEN '2024-03-31' AND '2024-03-31';
-- 結果: 2024-03-31 00:00:00 のみヒット(id=4 の 08:00:00 はマッチしない)

-- NG②: 23:59:59 指定でも 23:59:59.999... が漏れる場合がある
SELECT * FROM orders
WHERE ordered_at BETWEEN '2024-03-31' AND '2024-03-31 23:59:59';
-- マイクロ秒精度の場合 23:59:59.999999 などが漏れる

-- ★OK①: 翌日未満(最も推奨・インデックスも効く)
SELECT * FROM orders
WHERE ordered_at >= '2024-03-31'
  AND ordered_at <  '2024-04-01';

-- ★OK②: DATE() 関数で日付部分を切り出す(インデックスは効きにくい)
SELECT * FROM orders
WHERE DATE(ordered_at) = '2024-03-31';

-- ★OK③: MySQL で時刻を含む終端を自動計算
SELECT * FROM orders
WHERE ordered_at BETWEEN '2024-03-31 00:00:00' AND '2024-03-31 23:59:59.999999';
DATETIME 型の月・年範囲指定でも同じ罠があります:
BETWEEN '2024-01-01' AND '2024-03-31''2024-03-31 00:00:01' 以降のレコードを取りこぼします。月末の注文データが集計から漏れるバグの原因になるため、DATETIME 列には >= 開始日 AND < 翌月初 の形式を使ってください。時刻なし(DATE 型)の列との比較方法は時刻を無視して日付のみで比較する方法も参照してください。

月・四半期・年単位の範囲指定パターン

実務でよく使う固定期間の範囲指定を、RDBMS 別の完全なコードで示します。

今月・先月・特定月の範囲指定(MySQL)
-- ===== 今月のデータ =====

-- 推奨:月初〜翌月初(DATETIME 列でも安全)
SELECT * FROM orders
WHERE ordered_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
  AND ordered_at <  DATE_FORMAT(NOW(), '%Y-%m-01') + INTERVAL 1 MONTH;

-- ===== 先月のデータ =====
SELECT * FROM orders
WHERE ordered_at >= DATE_FORMAT(NOW() - INTERVAL 1 MONTH, '%Y-%m-01')
  AND ordered_at <  DATE_FORMAT(NOW(), '%Y-%m-01');

-- ===== 特定月のデータ(例:2024年3月)=====
SELECT * FROM orders
WHERE ordered_at >= '2024-03-01'
  AND ordered_at <  '2024-04-01';

-- ===== 特定月(YEAR+MONTH 関数)=====
-- ※ 列に関数を使うためインデックスが効かない場合あり(大量データに注意)
SELECT * FROM orders
WHERE YEAR(ordered_at) = 2024
  AND MONTH(ordered_at) = 3;
月・年範囲指定(PostgreSQL / SQL Server / Oracle)
-- ===== PostgreSQL =====
-- 今月
WHERE ordered_at >= DATE_TRUNC('month', NOW())
  AND ordered_at <  DATE_TRUNC('month', NOW()) + INTERVAL '1 month';

-- 先月
WHERE ordered_at >= DATE_TRUNC('month', NOW() - INTERVAL '1 month')
  AND ordered_at <  DATE_TRUNC('month', NOW());

-- ===== SQL Server =====
-- 今月
WHERE ordered_at >= DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)
  AND ordered_at <  DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()) + 1, 0);

-- ===== Oracle =====
-- 今月
WHERE ordered_at >= TRUNC(SYSDATE, 'MM')
  AND ordered_at <  ADD_MONTHS(TRUNC(SYSDATE, 'MM'), 1);

-- 先月
WHERE ordered_at >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -1)
  AND ordered_at <  TRUNC(SYSDATE, 'MM');
四半期の範囲指定
-- ===== Q1(1〜3月)=====
SELECT * FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2024-04-01';

-- ===== Q2(4〜6月)=====
WHERE ordered_at >= '2024-04-01'
  AND ordered_at <  '2024-07-01';

-- ===== MySQL: 動的に現在の四半期を計算 =====
SELECT * FROM orders
WHERE ordered_at >= MAKEDATE(YEAR(NOW()), 1)
                  + INTERVAL (QUARTER(NOW()) - 1) * 3 MONTH
  AND ordered_at <  MAKEDATE(YEAR(NOW()), 1)
                  + INTERVAL QUARTER(NOW()) * 3 MONTH;

-- ===== QUARTER 関数で絞り込む(インデックス非効率だが記述が簡潔)=====
SELECT * FROM orders
WHERE YEAR(ordered_at) = 2024
  AND QUARTER(ordered_at) = 1;

-- ===== PostgreSQL: 現在の四半期 =====
WHERE ordered_at >= DATE_TRUNC('quarter', NOW())
  AND ordered_at <  DATE_TRUNC('quarter', NOW()) + INTERVAL '3 months';
今年・去年の範囲指定
-- ===== 今年のデータ(インデックスに優しい書き方)=====
SELECT * FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2025-01-01';

-- ===== MySQL: 動的に今年を計算 =====
SELECT * FROM orders
WHERE ordered_at >= DATE_FORMAT(NOW(), '%Y-01-01')
  AND ordered_at <  DATE_FORMAT(NOW() + INTERVAL 1 YEAR, '%Y-01-01');

-- ===== 去年のデータ =====
SELECT * FROM orders
WHERE ordered_at >= DATE_FORMAT(NOW() - INTERVAL 1 YEAR, '%Y-01-01')
  AND ordered_at <  DATE_FORMAT(NOW(), '%Y-01-01');

-- ===== PostgreSQL =====
WHERE ordered_at >= DATE_TRUNC('year', NOW())
  AND ordered_at <  DATE_TRUNC('year', NOW()) + INTERVAL '1 year';
期間 MySQL(月初) PostgreSQL Oracle
今月初 DATE_FORMAT(NOW(), '%Y-%m-01') DATE_TRUNC('month', NOW()) TRUNC(SYSDATE, 'MM')
今年初 DATE_FORMAT(NOW(), '%Y-01-01') DATE_TRUNC('year', NOW()) TRUNC(SYSDATE, 'YYYY')
今四半期初 MAKEDATE(YEAR(NOW()),1) + INTERVAL (QUARTER(NOW())-1)*3 MONTH DATE_TRUNC('quarter', NOW()) TRUNC(SYSDATE, 'Q')
翌月初 DATE_FORMAT(NOW(), '%Y-%m-01') + INTERVAL 1 MONTH DATE_TRUNC('month', NOW()) + INTERVAL '1 month' ADD_MONTHS(TRUNC(SYSDATE, 'MM'), 1)

会計年度・カスタム開始月の範囲指定

4月始まりの会計年度や、任意の月を起点とした期間の計算パターンです。

4月始まり会計年度(MySQL)
-- ===== 2024年度(2024/4/1〜2025/3/31)=====
SELECT * FROM orders
WHERE ordered_at >= '2024-04-01'
  AND ordered_at <  '2025-04-01';

-- ===== MySQL: 現在の会計年度を動的に計算 =====
-- 4月以降なら今年度、3月以前なら昨年度を開始年とする
SET @fy_start = IF(
    MONTH(NOW()) >= 4,
    DATE_FORMAT(NOW(), '%Y-04-01'),
    DATE_FORMAT(NOW() - INTERVAL 1 YEAR, '%Y-04-01')
);

SELECT * FROM orders
WHERE ordered_at >= @fy_start
  AND ordered_at <  DATE_ADD(@fy_start, INTERVAL 1 YEAR);

-- ===== 会計年度ラベルを付けて集計 =====
SELECT
    CASE
        WHEN MONTH(ordered_at) >= 4
        THEN CONCAT(YEAR(ordered_at), '年度')
        ELSE CONCAT(YEAR(ordered_at) - 1, '年度')
    END AS fiscal_year,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
GROUP BY fiscal_year
ORDER BY fiscal_year;
キャンペーン期間内のデータを取得
-- campaigns テーブルの期間内にある注文を取得(JOIN + BETWEEN)
SELECT
    c.name AS campaign_name,
    o.id,
    o.customer_id,
    o.amount,
    o.ordered_at
FROM campaigns c
JOIN orders o
  ON o.ordered_at BETWEEN c.start_date AND c.end_date + INTERVAL 1 DAY - INTERVAL 1 SECOND
ORDER BY c.name, o.ordered_at;
-- ↑ campaigns.end_date が DATE 型なので、終端を 23:59:59 まで拡張

-- より明確な書き方(翌日未満で指定)
SELECT c.name, o.id, o.amount
FROM campaigns c
JOIN orders o
  ON o.ordered_at >= c.start_date
 AND o.ordered_at <  c.end_date + INTERVAL 1 DAY;

NOT BETWEEN で範囲外のデータを取得する

NOT BETWEEN A AND B< A OR > B と等価です。「指定期間外」「メンテナンス時間帯を除く」などの絞り込みに使います。

NOT BETWEEN の使い方
-- キャンペーン期間外の注文
SELECT * FROM orders
WHERE ordered_at NOT BETWEEN '2024-03-01' AND '2024-04-30 23:59:59';

-- 上と等価
SELECT * FROM orders
WHERE ordered_at < '2024-03-01'
   OR ordered_at >= '2024-05-01';

-- メンテナンス時間帯(毎日 02:00〜03:00)を除く
-- ※ TIME 型の比較
SELECT * FROM orders
WHERE TIME(ordered_at) NOT BETWEEN '02:00:00' AND '02:59:59';

-- NULL の扱い:NOT BETWEEN でも NULL は除外される
-- ordered_at が NULL の行は NOT BETWEEN でもヒットしない
-- → NULL チェックが必要な場合は OR IS NULL を追加
SELECT * FROM orders
WHERE (ordered_at NOT BETWEEN '2024-03-01' AND '2024-03-31 23:59:59')
   OR ordered_at IS NULL;
NOT BETWEEN と NULL の注意点:
NULL NOT BETWEEN A AND B の評価は UNKNOWN(≒ FALSE)になるため、NULL の行は結果から除外されます。NULL の日付を「期間外」として扱いたい場合はOR 列 IS NULL を明示的に追加してください。IN句や NULL の詳細な挙動についてはIN句・OR・BETWEEN・EXISTSの完全ガイドも参照してください。

GROUP BY と組み合わせた期間別集計

日付範囲の絞り込みと GROUP BY を組み合わせることで、月別・週別・四半期別の集計が可能です。

月別売上集計(MySQL)
-- 2024年の月別注文数・売上合計
SELECT
    DATE_FORMAT(ordered_at, '%Y-%m') AS year_month,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount,
    AVG(amount) AS avg_amount
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2025-01-01'
GROUP BY DATE_FORMAT(ordered_at, '%Y-%m')
ORDER BY year_month;

-- 結果例:
-- year_month | orders_count | total_amount | avg_amount
-- 2024-01    |            1 |        12000 |  12000.00
-- 2024-02    |            2 |        88500 |  44250.00
-- 2024-03    |            2 |        47800 |  23900.00
週別集計(MySQL / PostgreSQL)
-- ===== MySQL: 週別集計 =====
SELECT
    YEARWEEK(ordered_at, 1) AS year_week,  -- ISO 週番号(月曜始まり)
    MIN(DATE(ordered_at)) AS week_start,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2024-04-01'
GROUP BY YEARWEEK(ordered_at, 1)
ORDER BY year_week;

-- ===== PostgreSQL: 週別集計 =====
SELECT
    DATE_TRUNC('week', ordered_at)::date AS week_start,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2024-04-01'
GROUP BY DATE_TRUNC('week', ordered_at)
ORDER BY week_start;
四半期別集計
-- MySQL: 四半期別売上
SELECT
    YEAR(ordered_at) AS year,
    QUARTER(ordered_at) AS quarter,
    CONCAT(YEAR(ordered_at), '/Q', QUARTER(ordered_at)) AS label,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2025-01-01'
GROUP BY YEAR(ordered_at), QUARTER(ordered_at)
ORDER BY year, quarter;

-- PostgreSQL: 四半期別
SELECT
    DATE_TRUNC('quarter', ordered_at)::date AS quarter_start,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2025-01-01'
GROUP BY DATE_TRUNC('quarter', ordered_at)
ORDER BY quarter_start;
累計・移動集計(ウィンドウ関数)
-- 月別売上 + 累計売上
SELECT
    DATE_FORMAT(ordered_at, '%Y-%m') AS year_month,
    SUM(amount) AS monthly_total,
    SUM(SUM(amount)) OVER (ORDER BY DATE_FORMAT(ordered_at, '%Y-%m')) AS running_total
FROM orders
WHERE ordered_at >= '2024-01-01'
  AND ordered_at <  '2025-01-01'
GROUP BY DATE_FORMAT(ordered_at, '%Y-%m')
ORDER BY year_month;

-- 前月比較(LAG を使った月次集計)
WITH monthly AS (
    SELECT
        DATE_FORMAT(ordered_at, '%Y-%m') AS ym,
        SUM(amount) AS total
    FROM orders
    WHERE ordered_at >= '2024-01-01'
      AND ordered_at <  '2025-01-01'
    GROUP BY DATE_FORMAT(ordered_at, '%Y-%m')
)
SELECT
    ym,
    total,
    LAG(total) OVER (ORDER BY ym) AS prev_month,
    ROUND((total - LAG(total) OVER (ORDER BY ym))
          / LAG(total) OVER (ORDER BY ym) * 100, 1) AS growth_pct
FROM monthly;

アプリケーションからの動的な日付範囲指定

検索フォームやレポート機能で、ユーザーが入力した日付範囲を SQL に渡すケースの実装パターンです。

プレースホルダーを使った安全な範囲指定
-- ===== MySQL(Python + PyMySQL)=====
-- ユーザーの入力をそのまま連結するのは SQL インジェクションの原因
-- BAD: 文字列連結(インジェクション危険)
query = f"WHERE ordered_at >= '{start_date}' AND ordered_at < '{end_date}'"

-- GOOD: プレースホルダーを使う(パラメーター化クエリ)
query = """
    SELECT id, customer_id, amount, ordered_at
    FROM orders
    WHERE ordered_at >= %s
      AND ordered_at < %s
    ORDER BY ordered_at
"""
cursor.execute(query, (start_date, end_date))  -- タプルで渡す

-- ===== PHP(PDO)=====
$stmt = $pdo->prepare(
    "SELECT * FROM orders
     WHERE ordered_at >= :start AND ordered_at < :end"
);
$stmt->execute([
    ':start' => $startDate,
    ':end'   => $endDate,
]);
終了日の翌日を計算してアプリ側で渡す
-- Python の例:ユーザーが選んだ終了日の翌日を計算して渡す
from datetime import date, timedelta

start_date = date(2024, 3, 1)   # ユーザーが選んだ開始日
end_date   = date(2024, 3, 31)  # ユーザーが選んだ終了日

# DATETIME 列に BETWEEN を使うと 23:59:59 以降が漏れる可能性があるため
# アプリ側で「翌日」に変換して "<" で渡す
end_exclusive = end_date + timedelta(days=1)  # 2024-04-01

query = """
    SELECT * FROM orders
    WHERE ordered_at >= %s
      AND ordered_at < %s
"""
cursor.execute(query, (start_date, end_exclusive))
-- → 2024-03-31 のすべての時刻のデータを正確に取得

日付範囲とインデックスの活用

日付列に対するインデックス設計は、大量データでのレンジスキャン効率を大きく左右します。

単一インデックス vs 複合インデックス
-- ===== 単一の日付インデックス =====
CREATE INDEX idx_orders_ordered_at ON orders (ordered_at);

-- 有効(rangeスキャン)
EXPLAIN SELECT * FROM orders
WHERE ordered_at >= '2024-01-01' AND ordered_at < '2024-04-01';
-- type: range, key: idx_orders_ordered_at

-- ===== 複合インデックス(status + ordered_at)=====
-- 「shipped のみを月別に集計」などの絞り込みが速くなる
CREATE INDEX idx_orders_status_ordered_at ON orders (status, ordered_at);

-- status の等値絞り込み後に ordered_at のレンジスキャン(効率的)
SELECT SUM(amount) FROM orders
WHERE status = 'shipped'
  AND ordered_at >= '2024-01-01'
  AND ordered_at <  '2024-04-01';

-- ※ 複合インデックスは左端のカラムから順に使われる
-- (ordered_at, status) の順では上のクエリで status が使われないことがある
MySQL のパーティションプルーニング(大量データ向け)
-- 月別パーティションの定義例(RANGE COLUMNS)
CREATE TABLE orders_partitioned (
    id INT,
    customer_id INT,
    amount DECIMAL(10,2),
    status VARCHAR(20),
    ordered_at DATETIME
)
PARTITION BY RANGE (TO_DAYS(ordered_at)) (
    PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
    PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION p202404 VALUES LESS THAN (TO_DAYS('2024-05-01')),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

-- EXPLAIN PARTITIONS でパーティションプルーニングを確認
EXPLAIN PARTITIONS
SELECT * FROM orders_partitioned
WHERE ordered_at >= '2024-02-01'
  AND ordered_at <  '2024-04-01';
-- partitions: p202402,p202403 のみスキャン → 大幅に I/O を削減
日付範囲インデックスのポイント:

  • BETWEEN>= AND < も同様にインデックスのレンジスキャンを使う
  • 列に DATE()YEAR()MONTH() を適用するとインデックスが使われない(SARGable でなくなる)
  • status + ordered_at のような複合インデックスは「等値 + 範囲」の組み合わせで高速になる
  • 大量データ(数千万行〜)ではパーティショニングが有効。日付カラムを RANGE パーティションのキーにする
  • EXPLAIN でアクセスタイプ(range vs ALL)を必ず確認する

実務でよく使う日付範囲指定パターン集

有効期限が30日以内に切れる会員の抽出
-- memberships.expired_at が DATE 型
SELECT id, name, expired_at,
       DATEDIFF(expired_at, CURDATE()) AS days_left
FROM memberships
WHERE expired_at BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
ORDER BY expired_at;
-- BETWEEN で「今日〜30日後」を簡潔に表現(DATE 型なので安全)
特定期間に注文していない休眠顧客の検出
-- 90〜180日前に注文した顧客(最近は注文なし)
SELECT DISTINCT customer_id
FROM orders
WHERE ordered_at >= DATE_SUB(CURDATE(), INTERVAL 180 DAY)
  AND ordered_at <  DATE_SUB(CURDATE(), INTERVAL 90 DAY)
  AND customer_id NOT IN (
      SELECT customer_id FROM orders
      WHERE ordered_at >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
  );
給与計算期間(毎月21日〜翌月20日)のデータ取得
-- 給与計算期間:2024年3月分(2024/2/21〜2024/3/20)
SELECT * FROM work_logs
WHERE worked_at >= '2024-02-21'
  AND worked_at <  '2024-03-21';

-- MySQL: 動的に今月分を計算
SET @payroll_start = DATE_FORMAT(NOW() - INTERVAL 1 MONTH, '%Y-%m-21');
SET @payroll_end   = DATE_FORMAT(NOW(), '%Y-%m-21');

SELECT * FROM work_logs
WHERE worked_at >= @payroll_start
  AND worked_at <  @payroll_end;
複数キャンペーン期間のいずれかに該当するデータ
-- 各キャンペーン期間に該当する注文を横断的に集計
SELECT
    c.name AS campaign_name,
    COUNT(o.id) AS orders_count,
    COALESCE(SUM(o.amount), 0) AS total_amount
FROM campaigns c
LEFT JOIN orders o
  ON o.ordered_at >= c.start_date
 AND o.ordered_at <  c.end_date + INTERVAL 1 DAY
GROUP BY c.id, c.name
ORDER BY c.start_date;

よくある質問(FAQ)

QBETWEEN と >= AND <= はパフォーマンスが違いますか?
A実行計画レベルでは完全に同等です。RDBMS はどちらも同じ実行計画(インデックスのレンジスキャン)で処理します。選択は可読性や DATETIME 型での安全性を基準に行ってください。
Q月末日を BETWEEN の終端に指定するときに 28・29・30・31 日の違いを毎回計算しなければなりませんか?
A日付型(DATE)の列なら月末日を終端にするか、代わりに >= 月初 AND < 翌月初 を使えば月末日の計算は不要です。MySQL なら DATE_FORMAT(NOW(), '%Y-%m-01') + INTERVAL 1 MONTH で翌月初を計算できます。
QBETWEEN を文字列型の列に使うとどうなりますか?
A文字列は文字コード順で比較されます。'2024-10-01' より '2024-09-30' が辞書順で大きいため、日付形式(YYYY-MM-DD)の文字列なら正しく比較できます。ただし '2024/10/01' 形式(スラッシュ区切り)では '2024/9/' < ‘2024/10/’ とはならず正しく並ばない場合があります。日付データは必ず DATE/DATETIME 型で管理してください。
QBETWEEN で NULL の行を扱うにはどうすればよいですか?
ANULL は BETWEEN でも NOT BETWEEN でも UNKNOWN(除外)になります。「NULL の日付を範囲内に含める」場合は WHERE (col BETWEEN A AND B) OR col IS NULL、「NULL = 無期限有効」として扱う場合は WHERE col IS NULL OR col >= CURDATE() のように書きます。
QBETWEEN は INDEX RANGE SCAN で動作しますか?
Aはい。BETWEEN A AND B>= A AND <= B と等価であり、通常インデックスのレンジスキャン(MySQL では type: range)で動作します。ただし列に関数を適用した場合(DATE(ordered_at) BETWEEN ...)は フルスキャンになる場合があります。列には関数を使わず定数側で計算するのが原則です。詳細は日付の比較完全ガイドの SARGable の節を参照してください。

まとめ

SQL の日付範囲指定を正しく使いこなすための要点をまとめます。

ポイント 内容
BETWEEN の閉区間 両端を含む(>= A AND <= B と等価)。A < B の順で書く
DATETIME 列の終端 BETWEEN ... AND '2024-03-31'00:00:00 扱い → >= 開始 AND < 翌日 が安全
月・四半期・年の範囲 月初の動的計算(DATE_FORMATDATE_TRUNC)と < 翌期初 を組み合わせる
会計年度 CASE WHEN で「4月以降→今年度・3月以前→昨年度」を判定して動的に計算
GROUP BY 集計 DATE_FORMAT(col, '%Y-%m') などでグループキーを生成し月別・四半期別集計
アプリ連携 必ずプレースホルダーを使う。終了日は「翌日」に変換して < で渡す
インデックス 列への関数適用を避け範囲比較を維持する(SARGable)。複合インデックスで「等値 + 範囲」を高速化
NULL BETWEEN / NOT BETWEEN は NULL 行を除外。必要なら OR IS NULL を追加