- SQL
SQL BETWEENの使い方|日付の範囲指定と境界値・NOT BETWEEN
SQLのBETWEENとは、「値 BETWEEN 下限 AND 上限」と書いて、下限以上・上限以下の行を取り出す演算子です。 両端の値そのものも結果に含まれます。amount BETWEEN 1000 AND 3000 は amount >= 1000 AND amount <= 3000 と同じ意味です。
書き方は短くて読みやすい一方で、日時(時刻つき)の列に日付だけを指定すると、最終日のデータがほとんど取れないという、気づきにくい動きがあります。この記事では、数値・日付・文字列それぞれの境界値の動き、最終日が抜ける現象の再現と直し方、NOT BETWEENとNULLの関係、下限と上限を逆に書いたときの結果を、MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 で実際に実行した結果つきで説明します。SQL ServerとOracleの書き方も一覧表に入れました(この2つは今回の環境で動かせなかったため、表の中で「未実行」と明記しています)。
BETWEENの早見表
まず、よく使う書き方と結果を1枚にまとめます。
| やりたいこと | 書き方 | 境界の扱い |
|---|---|---|
| 数値の範囲(以上・以下) | amount BETWEEN 1000 AND 3000 |
1000 と 3000 を含む |
| 範囲の外側 | amount NOT BETWEEN 1000 AND 3000 |
1000 未満、または 3000 より大きい。NULL の行は出ない |
| 日付型(DATE)の範囲 | d BETWEEN '2026-09-01' AND '2026-09-30' |
9月1日と9月30日を含む |
| 日時型(DATETIME / TIMESTAMP)の1か月分 | t >= '2026-09-01' AND t < '2026-10-01' |
開始を含み、翌月1日を含まない(おすすめ) |
| 日時型の1日分 | t >= '2026-09-30' AND t < '2026-10-01' |
9月30日の 0:00:00 から 23:59:59.999… まで |
| 文字列の範囲 | name BETWEEN 'B' AND 'D' |
'B' 以上 'D' 以下。'Doi' は 'D' より大きいので含まれない |
| 以上・未満(上限を含めない) | x >= 下限 AND x < 上限 |
BETWEEN では書けないので比較演算子を使う |
覚えておきたい動きは次の4つです。
| 動き | 結果 |
|---|---|
| 両端の値 | 含まれる(以上・以下) |
| 下限と上限を逆に書く | エラーにならず 0 件 |
| 列の値が NULL | BETWEEN でも NOT BETWEEN でも結果に出ない |
| 日時列に日付だけを指定 | 上限は「その日の 0:00:00」として扱われ、最終日の日中のデータが出ない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL BETWEENの使い方|日付の範囲指定と境界値・NOT BETWEEN」
この記事で使うサンプルテーブル
以降の実行結果はすべて、次の orders(注文)テーブルで確認しています。境界の動きが見えるよう、月の変わり目の時刻と NULL をあえて入れてあります。
-- PostgreSQL 16 / SQLite 3.45 用(MySQL 8.0 では TIMESTAMP を DATETIME(3) に置き換える)
CREATE TABLE orders (
id INT PRIMARY KEY,
customer VARCHAR(20),
amount INT,
ordered_at TIMESTAMP
);
INSERT INTO orders VALUES
(1,'Aoki',500,'2026-08-31 23:59:59'),
(2,'Baba',1000,'2026-09-01 00:00:00'),
(3,'Chiba',1500,'2026-09-15 12:30:00'),
(4,'Doi',2000,'2026-09-30 00:00:00'),
(5,'Endo',2500,'2026-09-30 18:45:00'),
(6,'Fujii',3000,'2026-09-30 23:59:59.500'),
(7,'Goto',NULL,'2026-10-01 00:00:00'),
(8,'Hara',3500,NULL);
MySQL の DATETIME は、そのままだと秒より下を保存しません。id=6 の「.500」(0.5秒)を残すため、MySQL では DATETIME(3)(ミリ秒まで保存)で作成しました。
| id | customer | amount | ordered_at | この行を入れた理由 |
|---|---|---|---|---|
| 1 | Aoki | 500 | 2026-08-31 23:59:59 | 9月の直前 |
| 2 | Baba | 1000 | 2026-09-01 00:00:00 | 9月の最初の瞬間・金額の下限 |
| 3 | Chiba | 1500 | 2026-09-15 12:30:00 | 範囲の中ほど |
| 4 | Doi | 2000 | 2026-09-30 00:00:00 | 最終日の 0 時ちょうど |
| 5 | Endo | 2500 | 2026-09-30 18:45:00 | 最終日の夕方 |
| 6 | Fujii | 3000 | 2026-09-30 23:59:59.500 | 最終日の最後の1秒の途中・金額の上限 |
| 7 | Goto | NULL | 2026-10-01 00:00:00 | 翌月の最初の瞬間・金額が NULL |
| 8 | Hara | 3500 | NULL | 日時が NULL |
BETWEENの基本の書き方(数値で以上・以下を確認)
構文は次のとおりです。
SELECT 列 FROM テーブル
WHERE 列 BETWEEN 下限 AND 上限;
BETWEEN は両端を含みます。 金額が 1000 以上 3000 以下の注文を取り出してみます。
SELECT id, amount FROM orders
WHERE amount BETWEEN 1000 AND 3000
ORDER BY id;
実行結果(PostgreSQL 16.15。MySQL 8.0.46・SQLite 3.45.1 でも同じ5行):
id | amount
----+--------
2 | 1000
3 | 1500
4 | 2000
5 | 2500
6 | 3000
(5 rows)
下限の 1000(id=2)と上限の 3000(id=6)がどちらも入っています。次の2つは同じ意味です。
-- BETWEEN で書く
WHERE amount BETWEEN 1000 AND 3000
-- 比較演算子で書く(同じ結果)
WHERE amount >= 1000 AND amount <= 3000
「以上・未満」や「より大きい・以下」のように片方の端を含めたくないときは、BETWEEN ではなく比較演算子で書きます。
| 欲しい範囲 | 書き方 |
|---|---|
| 1000 以上 3000 以下 | amount BETWEEN 1000 AND 3000 |
| 1000 以上 3000 未満 | amount >= 1000 AND amount < 3000 |
| 1000 より大きく 3000 以下 | amount > 1000 AND amount <= 3000 |
| 1000 より大きく 3000 未満 | amount > 1000 AND amount < 3000 |
日付の範囲指定:日時列では最終日が抜ける
ここがこの記事でいちばん伝えたい部分です。「9月分の注文」を取り出すつもりで、次のように書いたとします。
-- 直す前:日時列に日付だけを指定
SELECT id, ordered_at FROM orders
WHERE ordered_at BETWEEN '2026-09-01' AND '2026-09-30'
ORDER BY id;
実行結果(MySQL 8.0.46):
+----+-------------------------+
| id | ordered_at |
+----+-------------------------+
| 2 | 2026-09-01 00:00:00.000 |
| 3 | 2026-09-15 12:30:00.000 |
| 4 | 2026-09-30 00:00:00.000 |
+----+-------------------------+
9月30日 18:45 の id=5 と、23:59:59.5 の id=6 が出てきません。 PostgreSQL 16.15 と SQLite 3.45.1 でも同じ3行でした。
なぜ最終日が抜けるのか
日時型の列と比べるとき、'2026-09-30' は「2026-09-30 00:00:00」として扱われます。つまり上の条件は、次の意味になっています。
WHERE ordered_at >= '2026-09-01 00:00:00'
AND ordered_at <= '2026-09-30 00:00:00'
上限が「9月30日の 0 時ちょうど」なので、9月30日のうち 0:00:00 の1点だけが含まれ、それ以降の時刻はすべて範囲の外になります。id=4(0時ちょうど)だけが残ったのはそのためです。
flowchart LR
A["9/1 00:00:00<br>下限・含む"] --> B["9/15 12:30<br>含む"]
B --> C["9/30 00:00:00<br>上限・含む"]
C --> D["9/30 18:45<br>範囲の外"]
D --> E["9/30 23:59:59.5<br>範囲の外"]
エラーも警告も出ないため、集計の件数が少し足りないことに後から気づく、という形で見つかることが多い動きです。
23:59:59 と書く方法と、その限界
上限を「その日の最後の時刻」にする書き方がよく紹介されています。
SELECT id, ordered_at FROM orders
WHERE ordered_at BETWEEN '2026-09-01 00:00:00' AND '2026-09-30 23:59:59'
ORDER BY id;
実行結果(PostgreSQL 16.15):
id | ordered_at
----+---------------------
2 | 2026-09-01 00:00:00
3 | 2026-09-15 12:30:00
4 | 2026-09-30 00:00:00
5 | 2026-09-30 18:45:00
(4 rows)
id=5 は取れるようになりましたが、23:59:59.500 の id=6 はまだ出ません。 23:59:59 ちょうどより後、0.5 秒ぶん範囲の外にあるからです。MySQL 8.0.46(DATETIME(3))でも同じ4行でした。
秒より細かい値を保存している列では、「.999」「.999999」と桁を増やして合わせる方法もありますが、列の精度(ミリ秒・マイクロ秒など)が変わるたびに書き直しが必要になります。
| 上限の書き方 | 秒までの列 | ミリ秒・マイクロ秒を持つ列 |
|---|---|---|
'2026-09-30' |
最終日は 0:00:00 だけ含む | 同じ |
'2026-09-30 23:59:59' |
最終日をすべて含む | 23:59:59 より後の端数が抜ける |
'2026-09-30 23:59:59.999' |
含む | マイクロ秒の列では端数が抜ける |
< '2026-10-01'(翌日未満) |
含む | 含む(精度に関係なく使える) |
おすすめ:「>= 開始 AND < 翌日」で書く
日時列の範囲は、BETWEEN ではなく「開始以上・翌日(翌月1日)未満」で書くと、列の精度に関係なく正しく取れます。
-- 直した後:開始以上・翌月1日未満
SELECT id, ordered_at FROM orders
WHERE ordered_at >= '2026-09-01'
AND ordered_at < '2026-10-01'
ORDER BY id;
実行結果(PostgreSQL 16.15。MySQL 8.0.46・SQLite 3.45.1 でも同じ5行):
id | ordered_at
----+-----------------------
2 | 2026-09-01 00:00:00
3 | 2026-09-15 12:30:00
4 | 2026-09-30 00:00:00
5 | 2026-09-30 18:45:00
6 | 2026-09-30 23:59:59.5
(5 rows)
9月のデータ5件がすべて取れ、8月31日 23:59:59 の id=1 と 10月1日 0:00:00 の id=7 は含まれていません。この書き方は「半開区間」(片方の端だけを含む区間)と呼ばれ、次の利点があります。
- 月末が何日かを考えなくてよい(28日・29日・30日・31日のどれでも「翌月1日未満」で済む)
- 秒の端数の桁数を気にしなくてよい
- 9月分と10月分を続けて集計しても、同じ行が両方に入ったり、どちらにも入らなかったりしない
1日分・1か月分をコピペで使う形
開始日だけを書き換えれば使える形にしておくと便利です。
-- MySQL 8.0:1日分(9月30日)
SELECT id FROM orders
WHERE ordered_at >= '2026-09-30'
AND ordered_at < DATE_ADD('2026-09-30', INTERVAL 1 DAY);
-- MySQL 8.0:1か月分(9月)
SELECT id FROM orders
WHERE ordered_at >= '2026-09-01'
AND ordered_at < '2026-09-01' + INTERVAL 1 MONTH;
-- PostgreSQL 16:1日分(9月30日)
SELECT id FROM orders
WHERE ordered_at >= '2026-09-30'
AND ordered_at < DATE '2026-09-30' + 1;
-- PostgreSQL 16:1か月分(9月)
SELECT id FROM orders
WHERE ordered_at >= DATE '2026-09-01'
AND ordered_at < DATE '2026-09-01' + INTERVAL '1 month';
どちらのDBでも、1日分は id が 4・5・6 の3行、1か月分は 2〜6 の5行になることを確認しました。
列を日付に変換してからBETWEENを使う方法
列のほうを日付型に変換すれば、BETWEEN でも最終日を含められます。
SELECT id, ordered_at FROM orders
WHERE CAST(ordered_at AS DATE) BETWEEN '2026-09-01' AND '2026-09-30'
ORDER BY id;
MySQL 8.0.46・PostgreSQL 16.15 とも、id が 2〜6 の5行になりました。読みやすい書き方ですが、列に関数や変換をかけると、その列の通常のインデックスを範囲検索に使いにくくなります。 行数が多いテーブルでは、列はそのままにして比較する値のほうを工夫する(半開区間で書く)のが向いています。インデックスとの関係は後の章で実行計画を載せます。
DATE型の列ならBETWEENでそのまま書ける
時刻を持たない DATE 型の列では、最終日が抜ける現象は起きません。1日が1つの値なので、両端を含む BETWEEN がそのまま「1日から30日まで」になります。
-- PostgreSQL 16.15 で確認
SELECT DATE '2026-09-10' BETWEEN DATE '2026-09-01' AND DATE '2026-09-10' AS d;
d
---
t
(1 row)
| 列の型 | 月の範囲のおすすめの書き方 |
|---|---|
| DATE(時刻なし) | d BETWEEN '2026-09-01' AND '2026-09-30' でよい |
| DATETIME / TIMESTAMP(時刻あり) | t >= '2026-09-01' AND t < '2026-10-01' |
| 型がわからない・将来変わるかもしれない | 半開区間で書いておくと、どちらの型でも同じ結果になる |
存在しない日付を書いたときの動き
「月末は31日」と決め打ちして、9月に '2026-09-31' と書いた場合の動きはDBによって違いました。
SELECT id FROM orders
WHERE ordered_at BETWEEN '2026-09-01' AND '2026-09-31';
PostgreSQL 16.15 はエラーで知らせてくれます。
ERROR: date/time field value out of range: "2026-09-31"
MySQL 8.0.46 はエラーにならず、結果が 0 件になり、警告が記録されました(SHOW WARNINGS で確認)。
Warning | 1292 | Incorrect datetime value: '2026-09-31' for column 'ordered_at' at row 1
「翌月1日未満」で書いておけば、月末の日付を書く必要がそもそもなくなります。
文字列でBETWEENを使う
文字列にも BETWEEN は使えます。比較は辞書順(文字の並び順)で行われます。
SELECT id, customer FROM orders
WHERE customer BETWEEN 'B' AND 'D'
ORDER BY id;
実行結果(PostgreSQL 16.15。MySQL 8.0.46・SQLite 3.45.1 でも同じ2行):
id | customer
----+----------
2 | Baba
3 | Chiba
(2 rows)
'Doi' が入っていない点に注目してください。辞書順では 'D' の後ろに文字が続く 'Doi' のほうが 'D' より大きいため、上限 'D' を超えています。D で始まる名前まで含めたいときは、次のどちらかにします。
-- 上限を実際の値にする(Baba / Chiba / Doi の3行)
WHERE customer BETWEEN 'Baba' AND 'Doi'
-- 「B以上・E未満」の半開区間にする
WHERE customer >= 'B' AND customer < 'E'
日付の「最終日が抜ける」現象と同じ構造です。上限に「短い値」を書くと、それより長い値は範囲の外になると覚えておくと、どちらにも応用できます。
また、大文字と小文字を区別するかどうかは、DBと照合順序(文字の比べ方の設定)によって変わります。MySQL 8.0.46 の既定の照合順序(utf8mb4_0900_ai_ci)では、BETWEEN 'b' AND 'd' と小文字で書いても Baba と Chiba の2行が返りました。
NOT BETWEENで範囲の外を取り出す
NOT BETWEEN は「下限より小さい、または上限より大きい」行を取り出します。
SELECT id, amount FROM orders
WHERE amount NOT BETWEEN 1000 AND 3000
ORDER BY id;
実行結果(MySQL 8.0.46。PostgreSQL 16.15・SQLite 3.45.1 でも同じ2行):
+----+--------+
| id | amount |
+----+--------+
| 1 | 500 |
| 8 | 3500 |
+----+--------+
次の3つの書き方はすべて同じ結果になることを MySQL 8.0.46 で確認しました。
WHERE amount NOT BETWEEN 1000 AND 3000
WHERE NOT (amount BETWEEN 1000 AND 3000)
WHERE amount < 1000 OR amount > 3000
境界の値(1000 と 3000)は BETWEEN 側に含まれるので、NOT BETWEEN 側には含まれません。
NULLがあるとBETWEENとNOT BETWEENのどちらにも出ない
サンプルには金額が NULL の行(id=7)があります。この行は BETWEEN の結果にも、NOT BETWEEN の結果にも出てきません。 件数を数えると、よくわかります。
SELECT COUNT(*) AS total,
SUM(CASE WHEN amount BETWEEN 1000 AND 3000 THEN 1 ELSE 0 END) AS in_range,
SUM(CASE WHEN amount NOT BETWEEN 1000 AND 3000 THEN 1 ELSE 0 END) AS out_range
FROM orders;
実行結果(PostgreSQL 16.15。MySQL 8.0.46・SQLite 3.45.1 でも同じ):
total | in_range | out_range
-------+----------+-----------
8 | 5 | 2
(1 row)
全体は 8 行なのに、範囲内 5 行と範囲外 2 行を足しても 7 行です。残りの1行が NULL の行です。
NULL は「値がわからない」ことを表すため、NULL と何かを比べた結果は真でも偽でもなく「不明(NULL)」になります。WHERE は結果が真の行だけを通すので、不明の行は NOT を付けても付けなくても通りません。
-- PostgreSQL 16.15
SELECT 5 BETWEEN 1 AND 5 AS a, 1 BETWEEN 1 AND 5 AS b, 6 BETWEEN 1 AND 5 AS c,
NULL BETWEEN 1 AND 5 AS d, 6 BETWEEN 1 AND NULL AS e, 0 BETWEEN 1 AND NULL AS f;
a | b | c | d | e | f
---+---+---+---+---+---
t | t | f | | | f
(1 row)
| 式 | 結果 | 理由 |
|---|---|---|
5 BETWEEN 1 AND 5 |
真 | 上限を含む |
1 BETWEEN 1 AND 5 |
真 | 下限を含む |
6 BETWEEN 1 AND 5 |
偽 | 上限より大きい |
NULL BETWEEN 1 AND 5 |
NULL(不明) | 値がわからない |
6 BETWEEN 1 AND NULL |
NULL(不明) | 下限は満たすが、上限がわからない |
0 BETWEEN 1 AND NULL |
偽 | 下限を満たさない時点で偽と決まる |
MySQL 8.0.46 でも同じ結果(真は 1、偽は 0、不明は NULL と表示)でした。
NULLの行も範囲外として取り出したいとき
「範囲に入っていないものを全部」取り出したいなら、IS NULL を足します。
SELECT id, amount FROM orders
WHERE amount NOT BETWEEN 1000 AND 3000
OR amount IS NULL
ORDER BY id;
id | amount
----+--------
1 | 500
7 |
8 | 3500
(3 rows)
これで 5 行+3 行=8 行となり、全体の件数と合います。
下限や上限にNULLが入るとき
検索画面の入力値をそのまま渡すような場面では、下限や上限が NULL になることがあります。amount BETWEEN 1000 AND NULL の件数は、MySQL・PostgreSQL・SQLite のいずれでも 0 件でした。片方だけ指定された検索にも対応したい場合は、条件を分けて書きます。
-- :min と :max は外から渡す値。NULL のときはその条件を使わない
SELECT id, amount FROM orders
WHERE (:min IS NULL OR amount >= :min)
AND (:max IS NULL OR amount <= :max);
下限と上限を逆に書くと0件になる
BETWEEN は「小さいほう AND 大きいほう」の順で書きます。逆に書いてもエラーにはならず、0 件が返ります。
SELECT COUNT(*) FROM orders
WHERE amount BETWEEN 3000 AND 1000;
実行結果(MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 のすべてで同じ):
count
-------
0
(1 row)
amount >= 3000 AND amount <= 1000 という、同時には満たせない条件になるためです。日付でも同じで、BETWEEN '2026-09-30' AND '2026-09-01' は 0 件になります。「データがあるはずなのに 0 件」のときは、まず順番を見直すと早く解決します。
PostgreSQL には、順番を自動で入れ替えてくれる BETWEEN SYMMETRIC があります。
-- PostgreSQL 16.15 で確認(5行が返る)
SELECT id, amount FROM orders
WHERE amount BETWEEN SYMMETRIC 3000 AND 1000
ORDER BY id;
開始日と終了日をユーザーが自由に入力する画面などで役立ちます。ほかのDBでは、アプリ側で小さいほうを先に渡すか、LEAST / GREATEST のような関数で並べ替えてから渡します。
DB別の日付リテラルと範囲指定の書き方一覧
日付の値(リテラル)の書き方と、「翌日」「翌月」の求め方はDBごとに少しずつ違います。MySQL・PostgreSQL・SQLite は今回実行して確認した書き方、SQL Server と Oracle は今回の環境で動かせなかったため未実行(一般に知られた仕様に沿った書き方)です。
| DB | 日付リテラル | 日時リテラル | 翌日・翌月の求め方 | 確認 |
|---|---|---|---|---|
| MySQL 8.0 | '2026-09-01' または DATE '2026-09-01' |
'2026-09-01 00:00:00' または TIMESTAMP '2026-09-01 00:00:00' |
DATE_ADD('2026-09-30', INTERVAL 1 DAY)、'2026-09-01' + INTERVAL 1 MONTH |
実行済み(8.0.46) |
| PostgreSQL 16 | DATE '2026-09-01' または '2026-09-01' |
TIMESTAMP '2026-09-01 00:00:00' |
DATE '2026-09-30' + 1、DATE '2026-09-01' + INTERVAL '1 month' |
実行済み(16.15) |
| SQLite 3 | '2026-09-01'(文字列として保存・比較) |
'2026-09-01 00:00:00' |
date('2026-09-30','+1 day')、date('2026-09-01','+1 month') |
実行済み(3.45.1) |
| SQL Server | '20260901'(区切りなしの8桁)や CAST('2026-09-01' AS date) |
'2026-09-01T00:00:00' |
DATEADD(day, 1, '20260930')、DATEADD(month, 1, '20260901') |
未実行 |
| Oracle Database | DATE '2026-09-01' または TO_DATE('2026-09-01','YYYY-MM-DD') |
TIMESTAMP '2026-09-01 00:00:00' |
DATE '2026-09-30' + 1、ADD_MONTHS(DATE '2026-09-01', 1) |
未実行 |
DBごとに知っておくと役立つ点を補足します。
| DB | 知っておきたい点 |
|---|---|
| MySQL | DATETIME は既定で秒まで。DATETIME(3) や DATETIME(6) にすると秒より下も保存され、23:59:59 指定では端数が抜ける(今回 DATETIME(3) で確認) |
| PostgreSQL | timestamp はマイクロ秒まで保存する。BETWEEN SYMMETRIC が使える。存在しない日付はエラーになる |
| SQLite | 専用の日付型がなく、文字列として比べる。書式(桁数・区切り)をそろえて保存すると、文字列の大小と日時の前後が一致する |
| SQL Server(未実行) | datetime 型は精度が約3ミリ秒単位で、上限を 23:59:59.999 と書くと翌日 0 時に丸められることが知られている。半開区間で書くとこの影響を受けない |
| Oracle(未実行) | DATE 型でも時刻(秒まで)を持つため、DATE 型の列でも最終日が抜ける現象が起こりうる。半開区間で書くのが定番 |
SQLite で「翌月」を求める関数は、次のように確認しました。
-- SQLite 3.45.1
SELECT date('2026-09-01','+1 month'), date('2026-09-30','+1 day');
2026-10-01|2026-10-01
どのDBでも共通して使えるのは「列 >= 開始 AND 列 < 翌日(翌月1日)」の形です。DBを移るときも、翌日を求める関数の部分だけを置き換えれば済みます。
BETWEENとインデックスの関係
BETWEEN は、列にインデックス(検索を速くするための索引)があれば範囲検索に利用されます。PostgreSQL 16.15 で、約20万行を追加して ordered_at にインデックスを作り、実行計画(DBがどう検索するかの計画)を確認しました。
CREATE INDEX idx_orders_at ON orders(ordered_at);
EXPLAIN SELECT * FROM orders
WHERE ordered_at BETWEEN '2020-02-01' AND '2020-02-02';
Index Scan using idx_orders_at on orders (cost=0.42..58.38 rows=1448 width=18)
Index Cond: ((ordered_at >= '2020-02-01 00:00:00'::timestamp without time zone)
AND (ordered_at <= '2020-02-02 00:00:00'::timestamp without time zone))
実行計画の中で、BETWEEN が >= と <= の2つの条件に置き換えられているのがわかります(表示は読みやすいよう改行しています)。半開区間で書いた場合も同じく Index Scan になりました。
Index Scan using idx_orders_at on orders (cost=0.42..111.04 rows=2831 width=18)
Index Cond: ((ordered_at >= '2020-02-01 00:00:00'::timestamp without time zone)
AND (ordered_at < '2020-02-03 00:00:00'::timestamp without time zone))
一方、列を日付に変換してから比べると、テーブル全体を読む計画になりました。
EXPLAIN SELECT * FROM orders
WHERE CAST(ordered_at AS DATE) BETWEEN '2020-02-01' AND '2020-02-02';
Gather (cost=1000.00..4725.87 rows=1000 width=18)
Workers Planned: 1
-> Parallel Seq Scan on orders (cost=0.00..3625.87 rows=588 width=18)
Filter: (((ordered_at)::date >= '2020-02-01'::date) AND ((ordered_at)::date <= '2020-02-02'::date))
| 書き方 | PostgreSQL 16.15 の実行計画 |
|---|---|
ordered_at BETWEEN a AND b |
Index Scan(インデックスで範囲を絞る) |
ordered_at >= a AND ordered_at < b |
Index Scan(同上) |
CAST(ordered_at AS DATE) BETWEEN a AND b |
Seq Scan(全体を読んで絞り込む) |
MySQL 8.0.46 でも、8行のサンプルで EXPLAIN を見ると、半開区間は type: range(範囲検索)、DATE(ordered_at) BETWEEN … は type: index(インデックス全体を読む)と表示されました。「列はそのまま、比べる値のほうを整える」と覚えておくと、速さと正確さの両方を保てます。
BETWEENと比較演算子の使い分け
| 場面 | おすすめ | 理由 |
|---|---|---|
| 整数の範囲(1〜10、年齢、数量など) | BETWEEN | 両端を含む意味がそのまま読める |
| DATE 型の期間 | BETWEEN | 1日が1つの値なので最終日も含まれる |
| DATETIME / TIMESTAMP 型の期間 | >= と < |
最終日の時刻と秒の端数を取りこぼさない |
| 小数の区間を連続して区切る(0以上10未満、10以上20未満…) | >= と < |
境界の値が両方の区間に入るのを避けられる |
| 文字列の頭文字の範囲 | >= と < |
上限より長い文字列を取りこぼさない |
| 範囲の外側 | NOT BETWEEN(必要なら OR 列 IS NULL) |
NULL の行を含めるかを明示できる |
連続する区間を BETWEEN で区切ると、境界の値が両方に数えられます。たとえば「1000〜2000」と「2000〜3000」をどちらも BETWEEN で書くと、2000 の行は両方に入ります。区間を並べるときは半開区間が向いています。
-- 金額帯ごとの件数(境界が重ならない書き方)
SELECT
SUM(CASE WHEN amount >= 0 AND amount < 1000 THEN 1 ELSE 0 END) AS under_1000,
SUM(CASE WHEN amount >= 1000 AND amount < 2000 THEN 1 ELSE 0 END) AS from_1000,
SUM(CASE WHEN amount >= 2000 AND amount < 3000 THEN 1 ELSE 0 END) AS from_2000,
SUM(CASE WHEN amount >= 3000 THEN 1 ELSE 0 END) AS from_3000
FROM orders;
結果が合わないときのチェックリスト
| 症状 | 確認すること | 直し方 |
|---|---|---|
| 最終日のデータが少ない・出ない | 列が日時型で、上限に日付だけを書いていないか | 列 < 翌日 に変える |
| 最終日の最後の1秒が出ない | 列が秒より下の精度を持っていないか | 23:59:59 指定をやめて 列 < 翌日 にする |
| 0 件になる | 下限と上限の順番が逆になっていないか | 小さいほうを先に書く |
| MySQL で 0 件+警告 1292 | 9月31日など存在しない日付を書いていないか | 「翌月1日未満」で書く |
| 範囲内と範囲外を足しても全件にならない | 列に NULL がないか | OR 列 IS NULL を足す、または件数を別に数える |
| 頭文字の範囲で最後の文字のデータが出ない | 上限が 'D' のように短くないか | 列 < 'E' のように次の文字未満で書く |
| 検索に時間がかかる | 列に関数や CAST をかけていないか | 列はそのままにして、比べる値を整える |
| 同じ行が2つの区間に数えられる | 連続する区間を BETWEEN で書いていないか | >= と < で区切る |
よくある質問
Q. BETWEENは境界の値を含みますか?
A. 含みます。x BETWEEN 1 AND 5 は 1 以上 5 以下で、1 と 5 のどちらも結果に入ります。MySQL 8.0.46・PostgreSQL 16.15・SQLite 3.45.1 で確認しました。片方の端を含めたくないときは >= と < を組み合わせて書きます。
Q. 日付の範囲指定で、最終日のデータが取れないのはなぜですか?
A. 列が日時型(時刻つき)の場合、'2026-09-30' は「9月30日 0:00:00」として比べられるためです。0 時ちょうどより後のデータは上限を超えます。列 >= '2026-09-01' AND 列 < '2026-10-01' のように、翌日(翌月1日)未満で書くと最終日のすべての時刻が含まれます。
Q. NOT BETWEENでNULLの行は取れますか?
A. 取れません。NULL との比較は「不明」になり、NOT を付けても不明のままなので WHERE を通りません。NULL の行も含めたいときは OR 列 IS NULL を足します。
Q. BETWEENと「>= AND <=」では、どちらが速いですか?
A. 同じ意味の条件として扱われます。PostgreSQL 16.15 の実行計画では、BETWEEN が >= と <= の条件に置き換えられてインデックス検索に使われていました。速さよりも「境界を含めたいかどうか」で書き方を選ぶのがおすすめです。
まとめ
- BETWEEN は両端を含む(以上・以下)。
x BETWEEN a AND bはx >= a AND x <= bと同じ - 日時列に日付だけを指定すると、上限は 0:00:00 扱いになり最終日が抜ける(3つのDBで再現)
- 日時の範囲は「>= 開始 AND < 翌日」で書くと、秒の端数や月末の日数に左右されない
- NOT BETWEEN は NULL の行を返さない。必要なら
OR 列 IS NULLを足す - 下限と上限を逆に書くとエラーにならず 0 件。PostgreSQL には
BETWEEN SYMMETRICがある - 文字列でも、上限より長い値は範囲の外。頭文字の範囲は「次の文字未満」で書く
- 列に関数をかけず、比べる値のほうを整えると、インデックスを使った範囲検索になりやすい
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL BETWEENの使い方|日付の範囲指定と境界値・NOT BETWEEN」
株式会社RJCでは、SQLの範囲指定や境界値の扱いを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。