SQLの日付範囲検索:BETWEENで最終日が漏れる理由と直し方

壁にかかった月めくりのカレンダー SQL

MySQL で11月分の注文を出そうとして WHERE ordered_at BETWEEN '2026-11-01' AND '2026-11-30' と書くと、11月30日の朝や夜の注文が結果に入りません。DATETIME 型の列と比べるとき、日付だけの ‘2026-11-30’ は11月30日の0時0分0秒として扱われるからです。最終日が抜ける仕組みを例のデータで確かめたうえで、開始日以上・翌日未満で書く直し方を示します。ミリ秒まで持つ列で 23:59:59 を指定しても漏れる理由や、インデックスが使える書き方も扱います。

注文テーブルで試す

ネットショップの注文テーブルを使います。注文日時の ordered_at は DATETIME 型で、検索を速くするためのインデックスも付けておきます。11月30日には、0時ちょうど・朝・夜の3件の注文があります。

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

INSERT INTO orders (ordered_at, amount) VALUES
  ('2026-10-31 23:50:00', 1200),
  ('2026-11-01 00:00:00', 3000),
  ('2026-11-15 12:30:00', 4500),
  ('2026-11-30 00:00:00', 800),
  ('2026-11-30 09:15:00', 2000),
  ('2026-11-30 21:40:00', 1500),
  ('2026-12-01 00:00:00', 5000);

11月分として取りたいのは、id 2〜6 の5件です。10月31日の id 1 と、12月1日の id 7 は入れません。

BETWEENで日付だけ指定すると最終日が抜ける

BETWEEN は下限以上・上限以下の範囲を取ります。両端を含むので、11月1日から30日まで全部取れそうに見えます。

SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at BETWEEN '2026-11-01' AND '2026-11-30';
+----+---------------------+--------+
| id | ordered_at          | amount |
+----+---------------------+--------+
|  2 | 2026-11-01 00:00:00 |   3000 |
|  3 | 2026-11-15 12:30:00 |   4500 |
|  4 | 2026-11-30 00:00:00 |    800 |
+----+---------------------+--------+
3 rows in set

11月30日の注文で取れたのは、0時ちょうどの id 4 だけです。9時15分の id 5 と21時40分の id 6 は抜けました。

原因は、DATETIME の列と文字列を比べるときに、MySQL が文字列のほうを DATETIME に変えることにあります。時刻を書かなかった部分は 00:00:00 で埋まります。

SELECT CAST('2026-11-30' AS DATETIME) AS end_value;
+---------------------+
| end_value           |
+---------------------+
| 2026-11-30 00:00:00 |
+---------------------+
書いた値 MySQL が比べる値
‘2026-11-01’ 2026-11-01 00:00:00
‘2026-11-30’ 2026-11-30 00:00:00

先ほどの条件は、11月1日0時から11月30日0時までという意味になっていました。テスト用のデータの時刻がすべて 00:00:00 だと、この誤りには気づけません。本番で時刻つきのデータが入り、月末の売上が少ないことから見つかる場合がよくあります。PostgreSQL でも、timestamp の列に日付だけを渡すと 00:00:00 として比べるので、同じことが起きます。

開始日以上・翌日未満で書けば漏れない

上限を最終日の翌日の0時にして、等号を外せば直ります。開始は >=、終わりは < で書きます。

SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at >= '2026-11-01'
  AND ordered_at <  '2026-12-01';
+----+---------------------+--------+
| id | ordered_at          | amount |
+----+---------------------+--------+
|  2 | 2026-11-01 00:00:00 |   3000 |
|  3 | 2026-11-15 12:30:00 |   4500 |
|  4 | 2026-11-30 00:00:00 |    800 |
|  5 | 2026-11-30 09:15:00 |   2000 |
|  6 | 2026-11-30 21:40:00 |   1500 |
+----+---------------------+--------+
5 rows in set

id 2〜6 の5件がそろいました。12月1日0時ちょうどの id 7 は、< なので入りません。始まりを含んで終わりを含まない範囲なので、11月と12月を続けて集計しても、境目の行を二重に数えずにすみます。

BETWEEN の上限を翌日にするだけでは直りません。両端を含むため、12月1日0時ちょうどの注文まで入ってしまいます。

SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at BETWEEN '2026-11-01' AND '2026-12-01';
+----+---------------------+--------+
| id | ordered_at          | amount |
+----+---------------------+--------+
|  2 | 2026-11-01 00:00:00 |   3000 |
|  3 | 2026-11-15 12:30:00 |   4500 |
|  4 | 2026-11-30 00:00:00 |    800 |
|  5 | 2026-11-30 09:15:00 |   2000 |
|  6 | 2026-11-30 21:40:00 |   1500 |
|  7 | 2026-12-01 00:00:00 |   5000 |
+----+---------------------+--------+
6 rows in set

最終日を画面の入力などで受け取るときは、SQL の中で1日足しておくと、翌日を自分で計算する手間がなくなります。結果は上と同じ5件です。

SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at >= '2026-11-01'
  AND ordered_at <  '2026-11-30' + INTERVAL 1 DAY;
書き方 11/30 9:15 の行 12/1 0:00 の行 結果
BETWEEN ‘2026-11-01’ AND ‘2026-11-30’ 入らない 入らない 最終日が漏れる
BETWEEN ‘2026-11-01’ AND ‘2026-12-01’ 入る 入る 翌日0時の行が混ざる
>= ‘2026-11-01’ AND < ‘2026-12-01’ 入る 入らない 正しい

23:59:59を指定してもミリ秒の行が漏れる

上限を ‘2026-11-30 23:59:59’ にする書き方もよく見かけます。秒までの DATETIME なら取れますが、ミリ秒まで持つ DATETIME(3) の列では、最後の1秒の中の行が抜けます。

CREATE TABLE logs (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  logged_at DATETIME(3) NOT NULL
);

INSERT INTO logs (logged_at) VALUES
  ('2026-11-30 23:59:58.000'),
  ('2026-11-30 23:59:59.000'),
  ('2026-11-30 23:59:59.500');

SELECT id, logged_at
FROM logs
WHERE logged_at BETWEEN '2026-11-01 00:00:00' AND '2026-11-30 23:59:59';
+----+-------------------------+
| id | logged_at               |
+----+-------------------------+
|  1 | 2026-11-30 23:59:58.000 |
|  2 | 2026-11-30 23:59:59.000 |
+----+-------------------------+
2 rows in set

23:59:59.500 の id 3 は、上限の 23:59:59.000 より後なので範囲の外です。翌日未満の書き方なら、列の精度に関係なく3件とも取れます。

SELECT id, logged_at
FROM logs
WHERE logged_at >= '2026-11-01'
  AND logged_at <  '2026-12-01';
+----+-------------------------+
| id | logged_at               |
+----+-------------------------+
|  1 | 2026-11-30 23:59:58.000 |
|  2 | 2026-11-30 23:59:59.000 |
|  3 | 2026-11-30 23:59:59.500 |
+----+-------------------------+
3 rows in set

列の型は、あとから DATETIME(3) や DATETIME(6) に変わることもあります。翌日未満で書いておけば、そのときに SQL を直さずにすみます。

DATE()で囲むとインデックスで絞れない

時刻を切り捨てる DATE() 関数で列を囲めば、BETWEEN のままでも最終日は抜けません。結果は id 2〜6 の5件になります。

SELECT id, ordered_at, amount
FROM orders
WHERE DATE(ordered_at) BETWEEN '2026-11-01' AND '2026-11-30';

結果は正しいものの、列を関数で囲むと、ordered_at のインデックスで範囲を絞れなくなります。MySQL は全部の行に DATE() をかけてから比べるので、注文が何百万件もあるテーブルでは全件を読むことになります。

EXPLAIN(実行計画を見るコマンド)で2つを比べると、行の多いテーブルでは違いが type 列に出ます。DATE() で囲んだほうは ALL(全件を読む)、翌日未満のほうは range(インデックスで範囲だけを読む)です。

-- 全件を読む(type: ALL)
EXPLAIN SELECT id, ordered_at, amount
FROM orders
WHERE DATE(ordered_at) BETWEEN '2026-11-01' AND '2026-11-30';

-- インデックスで範囲を読む(type: range)
EXPLAIN SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at >= '2026-11-01'
  AND ordered_at <  '2026-12-01';

関数のほかにも、インデックスが使われない書き方は「インデックスが効かない7つのパターンと直し方(MySQL)」で説明しています。

よくある落とし穴

BETWEEN の下限と上限を逆に書くと、エラーにならずに0件が返ります。BETWEEN a AND b は >= a AND <= b と同じなので、11月30日以上かつ11月1日以下の行を探すことになるからです。

SELECT id, ordered_at, amount
FROM orders
WHERE ordered_at BETWEEN '2026-11-30' AND '2026-11-01';
Empty set

時刻を持たない DATE 型の列なら、BETWEEN ‘2026-11-01’ AND ‘2026-11-30’ で11月30日も入ります。漏れが起きるのは DATETIME・TIMESTAMP の列だけです。ただ、列の型を見て書き分けるより、日付の範囲はいつも翌日未満で書くと決めておくほうが間違えません。

日ごとに集計するときも、WHERE では列をそのまま比べ、DATE() は SELECT と GROUP BY の側で使います。こうすれば範囲はインデックスで絞れます。

SELECT DATE(ordered_at) AS day, SUM(amount) AS total
FROM orders
WHERE ordered_at >= '2026-11-01'
  AND ordered_at <  '2026-12-01'
GROUP BY DATE(ordered_at)
ORDER BY day;
+------------+-------+
| day        | total |
+------------+-------+
| 2026-11-01 |  3000 |
| 2026-11-15 |  4500 |
| 2026-11-30 |  4300 |
+------------+-------+
3 rows in set

注文のない日も0円として並べたいときは、日付の一覧を作ってから結合します。やり方は「売上0件の日も表示するSQL:再帰CTEで連続した日付を作る」で説明しています。

まとめ

DATETIME の列を BETWEEN で日付だけ指定すると、上限が最終日の0時になり、その日の残りの行が抜けます。日付の範囲は開始日以上・翌日未満で書けば、時刻やミリ秒の有無に関係なく、漏れも二重の数えもありません。列を DATE() で囲む書き方は結果こそ合うものの、インデックスで範囲を絞れなくなります。WHERE では列をそのまま比べ、関数は SELECT と GROUP BY の側で使うと覚えておいてください。

もっと学ぶなら【PR】

  • MySQL徹底入門 第5版:MySQL の機能を広く解説した本。日付関数などの細かい動きを調べたいときに

※Amazonのアソシエイトとして、当サイトは適格販売により収入を得ています。

参考リンク

アイキャッチ画像: Photo by Kelly Brito on Unsplash

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