- SQL
SQL BETWEENの使い方|日付の範囲指定と境界値・NOT BETWEEN
SQL の BETWEEN は WHERE x BETWEEN a AND b と書き、a <= x AND x <= b(a 以上 b 以下、両端を含む)と同じ意味です。 日時列(DATETIME / TIMESTAMP)に BETWEEN '2026-09-01' AND '2026-09-30' と日付だけを書くと、上限が「9月30日 0:00:00」になり、30日の日中のデータが漏れます。日時列の期間は >= '2026-09-01' AND < '2026-10-01'(翌日・翌月1日未満)で書きます。
WHERE amount BETWEEN 1000 AND 3000 -- 1000 以上 3000 以下(両端を含む)
WHERE order_date BETWEEN '2026-09-01' AND '2026-09-30' -- DATE 型(時刻なし)の列ならこれで 9月分
WHERE ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01' -- DATETIME / TIMESTAMP 列の 9月分はこの形
BETWEENの早見表(やりたいこと→書き方)
例はこの記事の orders テーブル(amount:金額、order_date:DATE 型、ordered_at:日時型、customer:顧客名)です。
| やりたいこと | 書き方(推奨) | 注意点 |
|---|---|---|
| 1000 以上 3000 以下 | amount BETWEEN 1000 AND 3000 |
両端を含む。amount >= 1000 AND amount <= 3000 と同じ |
| 1000 以上 3000 未満 | amount >= 1000 AND amount < 3000 |
片方の端を外す形は BETWEEN では書けない |
| 範囲の外側 | amount NOT BETWEEN 1000 AND 3000 |
NULL の行はどちらにも出ない |
| DATE 型の列で 9月分 | order_date BETWEEN '2026-09-01' AND '2026-09-30' |
時刻が無いので最終日も入る |
| 日時型の列で 9月分 | ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01' |
BETWEEN ... AND '2026-09-30' は 30日の日中が漏れる |
| 日時型の列で 9月30日の1日分 | ordered_at >= '2026-09-30' AND ordered_at < '2026-10-01' |
23:59:59 を上限にすると秒の端数が漏れる |
| 基準日の月(月末を求めない) | PostgreSQL >= date_trunc('month', d) AND < date_trunc('month', d) + INTERVAL '1 month'、MySQL >= DATE_FORMAT(d, '%Y-%m-01') AND < DATE_FORMAT(d, '%Y-%m-01') + INTERVAL 1 MONTH |
28〜31日のどれでも同じ式で済む |
| 時刻帯(9時〜17時) | t BETWEEN TIME '09:00' AND TIME '17:00' |
17:00:00 は入り、17:00:01 は入らない(PostgreSQL・MySQL)。SQLite は文字列比較なので '09:00:00' と '17:00:00' に桁をそろえる |
| 日をまたぐ時刻帯(22時〜6時) | t >= TIME '22:00' OR t < TIME '06:00' |
BETWEEN では書けない(OR を使う) |
| 頭文字が B〜D の名前 | customer >= 'B' AND customer < 'E' |
BETWEEN 'B' AND 'D' は 'Doi' が入らない |
| 下限と上限が入れ替わるかもしれない | amount BETWEEN LEAST(a, b) AND GREATEST(a, b) |
逆順はエラーにならず 0 件。PostgreSQL は BETWEEN SYMMETRIC |
| 下限・上限が NULL のこともある検索 | (:min IS NULL OR amount >= :min) AND (:max IS NULL OR amount <= :max) |
BETWEEN :min AND :max は片方が NULL だと 0 件 |
| 期間テーブルと結合する | o.ordered_at >= c.start_date AND o.ordered_at < c.end_date + 1 |
終了日が DATE なら「翌日未満」にする |
| 同じ値 | amount BETWEEN 2000 AND 2000 |
amount = 2000 と同じ |
この6つの動きを押さえておくと、結果が合わないときに原因を切り分けられます。
| 動き | 結果(3つの DB で確認) |
|---|---|
| 両端の値 | 含まれる(以上・以下) |
| 下限と上限を逆に書く | エラーにならず 0 件 |
| 列の値が NULL | BETWEEN でも NOT BETWEEN でも出ない |
| 日時列に日付だけを指定 | 上限が「その日の 0:00:00」になり、最終日の日中が出ない(SQLite は 0:00:00 の行も出ない) |
| 文字列の上限に短い値 | 上限より長い値('D' に対する 'Doi')は出ない |
| MySQL で存在しない日付(9月31日) | エラーにならず、下限なら全件一致・上限なら 0 件(警告だけ出る) |
この記事と関連記事の分担
| 知りたいこと | 読む記事 |
|---|---|
| 日付関数(翌日・月初・月末)の DB 別の書き方 | SQL 日付関数 一覧|4DB比較表とコピペSQL・実行結果つき |
| PostgreSQL の date_trunc と interval | PostgreSQL date_truncとintervalで月初・月末・差分 |
| MySQL の DATETIME と TIMESTAMP の違い | MySQL DATETIMEとTIMESTAMPの違い|2038年・タイムゾーン |
| AND・OR・NOT の優先順位とかっこ | SQL WHEREの複数条件|AND・ORの優先順位とかっこ・NOTの書き方 |
| インデックスが使われない書き方の一覧 | SQLのインデックスが効かない10のパターンと確認方法 |
| NULL の比較・COALESCE | SQLのNULLの扱い|COALESCE・NULLIFと落とし穴12選 |
確認環境:PostgreSQL 16.15(Ubuntu 24.04、psql)、MySQL 8.0.46(mysql クライアント、sql_mode は初期値、照合順序 utf8mb4_0900_ai_ci)、SQLite 3.45.1(Python 3.11 の sqlite3 モジュール)で、この記事の SQL を実行し、出力をそのまま載せています。SQL Server と Oracle の構文・型の動きは、Microsoft Learn「BETWEEN (Transact-SQL)」「datetime (Transact-SQL)」と Oracle Database 23ai SQL Language Reference で確かめました(確認日 2026年10月16日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL BETWEENの使い方|日付の最終日が漏れる原因と直し方・DB別早見表」
この記事で使うテーブルとデータ(コピペ用)
注文テーブル orders と、キャンペーン期間テーブル campaigns です。境界の動きが見えるよう、月の変わり目の時刻・秒の端数・NULL・負の小数をあえて入れてあります。PostgreSQL・SQLite はこのまま、MySQL は TIMESTAMP を DATETIME(3) に変えて実行できます。
CREATE TABLE orders (
id INT PRIMARY KEY,
customer VARCHAR(20),
amount INT,
rate DECIMAL(5,2),
order_date DATE,
ordered_at TIMESTAMP -- MySQL は DATETIME(3)
);
INSERT INTO orders VALUES
(1, 'Aoki', 500, -1.50, '2026-08-31', '2026-08-31 23:59:59'),
(2, 'Baba', 1000, 0.00, '2026-09-01', '2026-09-01 00:00:00'),
(3, 'Chiba', 1500, 2.50, '2026-09-15', '2026-09-15 12:30:00'),
(4, 'Doi', 2000, 5.00, '2026-09-30', '2026-09-30 00:00:00'),
(5, 'Endo', 2500, 9.99, '2026-09-30', '2026-09-30 18:45:00'),
(6, 'Fujii', 3000, 10.00, '2026-09-30', '2026-09-30 23:59:59.500'),
(7, 'Goto', NULL, 10.01, '2026-10-01', '2026-10-01 00:00:00'),
(8, 'Hara', 3500, NULL, NULL, NULL);
CREATE TABLE campaigns (
name VARCHAR(20) PRIMARY KEY,
start_date DATE NOT NULL,
end_date DATE NOT NULL -- 最終日(この日を含む)
);
INSERT INTO campaigns VALUES
('autumn', '2026-09-01', '2026-09-30'),
('month_end', '2026-09-30', '2026-09-30');
PostgreSQL 16 で SELECT * FROM orders ORDER BY id; を実行した結果です。
id | customer | amount | rate | order_date | ordered_at
----+----------+--------+-------+------------+-----------------------
1 | Aoki | 500 | -1.50 | 2026-08-31 | 2026-08-31 23:59:59
2 | Baba | 1000 | 0.00 | 2026-09-01 | 2026-09-01 00:00:00
3 | Chiba | 1500 | 2.50 | 2026-09-15 | 2026-09-15 12:30:00
4 | Doi | 2000 | 5.00 | 2026-09-30 | 2026-09-30 00:00:00
5 | Endo | 2500 | 9.99 | 2026-09-30 | 2026-09-30 18:45:00
6 | Fujii | 3000 | 10.00 | 2026-09-30 | 2026-09-30 23:59:59.5
7 | Goto | | 10.01 | 2026-10-01 | 2026-10-01 00:00:00
8 | Hara | 3500 | | |
(8 rows)
| id | この行を入れた理由 |
|---|---|
| 1 | 9月の直前(8月31日 23:59:59)。金額は範囲の下 |
| 2 | 9月の最初の瞬間。金額の下限 1000 ちょうど |
| 3 | 範囲の中ほど |
| 4 | 最終日の 0 時ちょうど |
| 5 | 最終日の夕方。ここが漏れるかどうかが分かれ目 |
| 6 | 最終日の最後の1秒の途中(.500)。金額の上限 3000 ちょうど |
| 7 | 翌月の最初の瞬間。金額が NULL |
| 8 | 日付が NULL。金額は範囲の上 |
MySQL の DATETIME は初期設定では秒までしか保存しないため、id = 6 の「.500」を残すために DATETIME(3) で作成しています。
BETWEENの基本の書き方(以上・以下、両端を含む)
SELECT 列 FROM テーブル
WHERE 列 BETWEEN 下限 AND 上限;
金額が 1000 以上 3000 以下の注文です。PostgreSQL 16 の結果で、MySQL 8.0・SQLite 3.45 でも同じ5行でした。
SELECT id, amount FROM orders WHERE amount BETWEEN 1000 AND 3000 ORDER BY id;
id | amount
----+--------
2 | 1000
3 | 1500
4 | 2000
5 | 2500
6 | 3000
(5 rows)
| id | customer | amount | 結果 |
|---|---|---|---|
| 1 | Aoki | 500 | 出ない |
| 2 | Baba | 1000 | 出る(下限ちょうど) |
| 3 | Chiba | 1500 | 出る |
| 4 | Doi | 2000 | 出る |
| 5 | Endo | 2500 | 出る |
| 6 | Fujii | 3000 | 出る(上限ちょうど) |
| 7 | Goto | NULL | 出ない |
| 8 | Hara | 3500 | 出ない |
1000 と 3000 ちょうどの行も含めた5行が返る。
下限の 1000(id = 2)と上限の 3000(id = 6)の両方が入っています。PostgreSQL のドキュメントには a BETWEEN x AND y は a >= x AND a <= y と等価で「端点の値を範囲に含む」と、MySQL のドキュメントには「引数の型が同じなら (min <= expr AND expr <= max) と等価」と書かれています。SQLite のドキュメントは、比較演算子で書いた場合との違いとして「BETWEEN では x(左辺の式)が1回だけ評価される」点を挙げています。
片方の端を外したいときは、比較演算子で書きます。
| 欲しい範囲 | 書き方 |
|---|---|
| 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 |
BETWEEN の AND と、条件をつなぐ AND
BETWEEN の AND は構文の一部なので、後ろに別の条件を AND でつないでも区切りを誤りません。3つの DB で同じ結果です。
SELECT id FROM orders WHERE amount BETWEEN 1000 AND 3000 AND customer >= 'D' ORDER BY id;
id
----
4
5
6
(3 rows)
上限には 1000 + 1000 のような式も書けます。PostgreSQL のドキュメントは、この AND のあいまいさを避けるため「BETWEEN の2番目の引数に書ける式の種類は限られる。複雑な式はかっこで囲む」としています。BETWEEN 1000 AND (SELECT MAX(amount) FROM orders) のように、かっこを付けておくと確実です。AND・OR・NOT の優先順位は、関連記事「SQL WHEREの複数条件|AND・ORの優先順位とかっこ・NOTの書き方」で扱っています。
日付の範囲指定:日時列では最終日が漏れる(sql between 日付)
ここがこの記事でいちばん大事な部分です。「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(DATETIME(3))の結果です。
+----+-------------------------+
| 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 |
+----+-------------------------+
| id | ordered_at | 結果 |
|---|---|---|
| 1 | 2026-08-31 23:59:59.000 | 出ない(8月) |
| 2 | 2026-09-01 00:00:00.000 | 出る |
| 3 | 2026-09-15 12:30:00.000 | 出る |
| 4 | 2026-09-30 00:00:00.000 | 出る(上限ちょうど) |
| 5 | 2026-09-30 18:45:00.000 | 出ない(9月30日なのに漏れる) |
| 6 | 2026-09-30 23:59:59.500 | 出ない(9月30日なのに漏れる) |
| 7 | 2026-10-01 00:00:00.000 | 出ない(10月) |
| 8 | NULL | 出ない |
上限が 9月30日 0:00:00 になり、30日の日中の id 5・6 が結果から外れる。
9月30日 18:45 の id = 5 と、23:59:59.5 の id = 6 が出てきません。 PostgreSQL 16.15 も同じ3行でした。SQLite 3.45.1 は日時を文字列として比べるため、'2026-09-30 00:00:00' は '2026-09-30' より大きいと判定され、id = 4 も出ず2行になります。
日付だけを書いた上の SQL の結果を、DB ごとにまとめます。
| DB | 結果の行 |
|---|---|
| PostgreSQL 16.15(timestamp) | id 2・3・4 の3行 |
| MySQL 8.0.46(DATETIME(3)) | id 2・3・4 の3行 |
| SQLite 3.45.1(文字列) | id 2・3 の2行 |
なぜ最終日が漏れるのか
日時型の列と比べるとき、'2026-09-30' は「2026-09-30 00:00:00」に変換されます。PostgreSQL と MySQL で確かめた変換結果です。
-- PostgreSQL 16
SELECT '2026-09-30'::timestamp AS upper_bound;
upper_bound
---------------------
2026-09-30 00:00:00
-- MySQL 8.0
SELECT CAST('2026-09-30' AS DATETIME) AS upper_bound;
+---------------------+
| upper_bound |
+---------------------+
| 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 時ちょうど」なので、30日のうち 0:00:00 の1点だけが含まれ、それ以降の時刻はすべて範囲の外です。
flowchart TD
subgraph IN["BETWEEN の範囲内(9/1 0:00:00 から 9/30 0:00:00 まで)"]
R2["id 2 9/1 00:00:00 下限ちょうど"] --> R3["id 3 9/15 12:30"]
R3 --> R4["id 4 9/30 00:00:00 上限ちょうど"]
end
R4 --> R5["id 5 9/30 18:45 範囲外"]
R5 --> R6["id 6 9/30 23:59:59.5 範囲外"]
R6 --> R7["id 7 10/1 00:00:00 範囲外"]
エラーも警告も出ないため、「月次の集計が少し足りない」という形で後から見つかることが多い動きです。MySQL のドキュメントも「DATETIME 列と定数を比べるときは定数が timestamp に変換される。BETWEEN で日付や時刻を使うときは CAST() で型を明示する」と注意しています。
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;
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 はまだ出ません(PostgreSQL・MySQL・SQLite とも4行)。23:59:59 ちょうどより 0.5 秒後ろにあるからです。上限を '2026-09-30 23:59:59.999' にすると3つの DB とも5行になりますが、列の精度がマイクロ秒(PostgreSQL の timestamp、MySQL の DATETIME(6))なら .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 < 翌日(翌月1日)」で書く
日時列の期間は、BETWEEN ではなく「開始以上・翌日未満」で書くと、列の精度に関係なく正しく取れます。
SELECT id, ordered_at FROM orders
WHERE ordered_at >= '2026-09-01'
AND ordered_at < '2026-10-01'
ORDER BY id;
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)
| id | ordered_at | 結果 |
|---|---|---|
| 1 | 2026-08-31 23:59:59 | 出ない(8月) |
| 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 | 出る(BETWEEN では漏れた行) |
| 6 | 2026-09-30 23:59:59.5 | 出る(BETWEEN では漏れた行) |
| 7 | 2026-10-01 00:00:00 | 出ない(10月) |
| 8 | NULL | 出ない |
9月30日の日中と秒の端数を含む9月の5行がすべて返り、8月・10月の行は入らない。
9月の5件がすべて取れ、8月31日 23:59:59 の id = 1 と 10月1日 0:00:00 の id = 7 は入りません。MySQL 8.0・SQLite 3.45 でも同じ5行でした。この形は「半開区間」(片方の端だけを含む区間)と呼ばれ、次の利点があります。
- 月末が 28・29・30・31 日のどれでも「翌月1日未満」で済み、月末の日付を書かない
- 秒の端数の桁数を気にしない
- 9月分と10月分を続けて集計しても、同じ行が両方に入ったり、どちらにも入らなかったりしない
1日分・1か月分・基準日の月をコピペで使う形
| DB | 1日分(9月30日) | 1か月分(9月) |
|---|---|---|
| PostgreSQL 16 | ordered_at >= '2026-09-30' AND ordered_at < DATE '2026-09-30' + 1 |
ordered_at >= DATE '2026-09-01' AND ordered_at < DATE '2026-09-01' + INTERVAL '1 month' |
| MySQL 8.0 | ordered_at >= '2026-09-30' AND ordered_at < DATE_ADD('2026-09-30', INTERVAL 1 DAY) |
ordered_at >= '2026-09-01' AND ordered_at < '2026-09-01' + INTERVAL 1 MONTH |
| SQLite 3.45 | ordered_at >= '2026-09-30' AND ordered_at < date('2026-09-30', '+1 day') |
ordered_at >= '2026-09-01' AND ordered_at < date('2026-09-01', '+1 month') |
3つの DB とも、1日分は id 4・5・6 の3行、1か月分は id 2〜6 の5行でした。
「基準日が含まれる月」を取りたいときは、月初に切り捨ててから1か月を足します。月末を求める必要がなく、基準日を CURRENT_DATE に置き換えれば「今月分」になります。
-- PostgreSQL 16(基準日 2026-09-15 → 9/1 以上 10/1 未満)
SELECT id FROM orders
WHERE ordered_at >= date_trunc('month', TIMESTAMP '2026-09-15')
AND ordered_at < date_trunc('month', TIMESTAMP '2026-09-15') + INTERVAL '1 month';
-- MySQL 8.0
SELECT id FROM orders
WHERE ordered_at >= DATE_FORMAT('2026-09-15', '%Y-%m-01')
AND ordered_at < DATE_FORMAT('2026-09-15', '%Y-%m-01') + INTERVAL 1 MONTH;
-- SQLite 3.45
SELECT id FROM orders
WHERE ordered_at >= date('2026-09-15', 'start of month')
AND ordered_at < date('2026-09-15', 'start of month', '+1 month');
どれも id 2〜6 の5行です。境界の値を確かめた結果はこのとおりです。
-- PostgreSQL: date_trunc('month', TIMESTAMP '2026-09-15') と + INTERVAL '1 month'
month_start | next_month
---------------------+---------------------
2026-09-01 00:00:00 | 2026-10-01 00:00:00
-- MySQL: DATE_FORMAT / LAST_DAY
+-------------+------------+------------+-------------+
| month_start | next_month | last_day | next_month2 |
+-------------+------------+------------+-------------+
| 2026-09-01 | 2026-10-01 | 2026-09-30 | 2026-10-01 |
+-------------+------------+------------+-------------+
-- SQLite: date('2026-09-15', 'start of month') / +1 month
2026-09-01|2026-10-01
MySQL では LAST_DAY('2026-09-15') + INTERVAL 1 DAY でも翌月1日になります。date_trunc と interval の使い方は関連記事「PostgreSQL date_truncとintervalで月初・月末・差分」で、各 DB の日付関数は関連記事「SQL 日付関数 一覧|4DB比較表とコピペSQL・実行結果つき」で扱っています。
列を日付に変換してから BETWEEN を使う方法
列のほうを DATE 型にすれば、BETWEEN でも最終日を含められます。
-- PostgreSQL・SQL Server・Oracle
WHERE CAST(ordered_at AS DATE) BETWEEN '2026-09-01' AND '2026-09-30'
-- MySQL
WHERE DATE(ordered_at) BETWEEN '2026-09-01' AND '2026-09-30'
-- SQLite
WHERE date(ordered_at) BETWEEN '2026-09-01' AND '2026-09-30'
3つの DB とも id 2〜6 の5行になりました。読みやすい書き方ですが、列に関数や変換をかけると、その列のインデックスを範囲検索に使えなくなります(後の節で実行計画を載せます)。行数が多いテーブルでは、列はそのままにして比べる値のほうを整える(半開区間で書く)形が向いています。
DATE 型の列なら BETWEEN でそのまま書ける
同じ orders テーブルの DATE 型の列 order_date で、同じ日付を BETWEEN に書いた結果です。
SELECT id, order_date FROM orders
WHERE order_date BETWEEN '2026-09-01' AND '2026-09-30'
ORDER BY id;
id | order_date
----+------------
2 | 2026-09-01
3 | 2026-09-15
4 | 2026-09-30
5 | 2026-09-30
6 | 2026-09-30
(5 rows)
時刻を持たない DATE 型では1日が1つの値なので、両端を含む BETWEEN がそのまま「1日から30日まで」になります(MySQL・SQLite でも5行)。
| 列の型 | 9月分の書き方 |
|---|---|
| DATE(時刻なし) | order_date BETWEEN '2026-09-01' AND '2026-09-30' |
| DATETIME / TIMESTAMP(時刻あり) | ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01' |
| 型が分からない・将来変わるかもしれない | 半開区間で書いておくと、どちらの型でも同じ結果になる |
タイムゾーン付きの列(timestamptz・MySQL の TIMESTAMP)では境界がずれる
PostgreSQL の timestamp with time zone(timestamptz)は、タイムゾーンの無い文字列をセッションの TimeZone で解釈します(ドキュメントのとおり)。同じ SQL でも、接続のタイムゾーンによって「10月1日 0:00」がどの瞬間を指すかが変わります。日本時間の 9月30日 23:30、10月1日 0:30、10月1日 8:30 の3件で確かめました。
CREATE TABLE access_logs (id INT PRIMARY KEY, accessed_at TIMESTAMPTZ);
INSERT INTO access_logs VALUES
(1, '2026-09-30 23:30:00+09'),
(2, '2026-10-01 00:30:00+09'),
(3, '2026-10-01 08:30:00+09');
SET TimeZone = 'Asia/Tokyo';
SELECT id, accessed_at FROM access_logs WHERE accessed_at >= '2026-09-01' AND accessed_at < '2026-10-01' ORDER BY id;
id | accessed_at
----+------------------------
1 | 2026-09-30 23:30:00+09
(1 row)
SET TimeZone = 'UTC';
SELECT id, accessed_at FROM access_logs WHERE accessed_at >= '2026-09-01' AND accessed_at < '2026-10-01' ORDER BY id;
id | accessed_at
----+------------------------
1 | 2026-09-30 14:30:00+00
2 | 2026-09-30 15:30:00+00
3 | 2026-09-30 23:30:00+00
(3 rows)
同じデータ・同じ SQL で、1件と3件に分かれました。 UTC の接続では「10月1日 0:00 UTC」=日本時間の 9:00 が境界になるためです。アプリやバッチの接続タイムゾーンが決まっていないと、同じ月次集計が接続ごとに変わります。境界にタイムゾーンを明示すると、接続の設定に関係なく1件になりました。
WHERE accessed_at >= '2026-09-01 00:00:00+09' AND accessed_at < '2026-10-01 00:00:00+09'
-- または
WHERE accessed_at >= TIMESTAMPTZ '2026-09-01 00:00:00 Asia/Tokyo' AND accessed_at < TIMESTAMPTZ '2026-10-01 00:00:00 Asia/Tokyo'
MySQL の TIMESTAMP 型も同じ動きで、SET time_zone = '+09:00' では1件、'+00:00' では3件になりました(CONVERT_TZ('2026-10-01 00:00:00', '+09:00', '+00:00') のように境界を変換すると1件)。DATETIME 型はタイムゾーン変換をしないので、この影響を受けません。違いは関連記事「MySQL DATETIMEとTIMESTAMPの違い|2038年・タイムゾーン」で扱っています。
存在しない日付(9月31日)を書いたときの動きは DB で違う
「月末は31日」と決め打ちして '2026-09-31' と書いた場合です。
SELECT id FROM orders WHERE ordered_at BETWEEN '2026-09-01' AND '2026-09-31';
| DB | 結果 |
|---|---|
| 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 |
| SQLite 3.45.1 | 文字列として比べるので id 2〜6 の5行(たまたま期待どおりになる) |
MySQL は、存在しない日付が下限にあると結果が逆に全件一致になります。9月の条件なのに、8月の id = 1 と 10月の id = 7 まで出ています。
SELECT id FROM orders WHERE order_date BETWEEN '2026-09-31' AND '2026-10-31' ORDER BY id;
+----+
| id |
+----+
| 1 |
| 2 |
| 3 |
| 4 |
| 5 |
| 6 |
| 7 |
+----+
7 rows in set, 7 warnings
同じ値を >= や = で比べると、MySQL は ERROR 1525 (HY000): Incorrect DATE value: '2026-09-31' で止まります。BETWEEN だけが警告で通ってしまう点に注意が要ります。「翌月1日未満」で書いておけば、月末の日付を書く場面がそもそもなくなります。
数値のBETWEEN:小数・負数・連続する区間
小数(DECIMAL)と負の数でも、両端を含む動きは同じです。rate 列で確かめました。
SELECT id, rate FROM orders WHERE rate BETWEEN 0 AND 10 ORDER BY id;
id | rate
----+-------
2 | 0.00
3 | 2.50
4 | 5.00
5 | 9.99
6 | 10.00
(5 rows)
SELECT id, rate FROM orders WHERE rate BETWEEN -2 AND 0 ORDER BY id;
id | rate
----+-------
1 | -1.50
2 | 0.00
(2 rows)
10.00 は入り、10.01(id = 7)は入りません。整数の列に小数の境界を書いた amount BETWEEN 999.5 AND 1000.5 は、3つの DB とも id = 2(1000)だけが返りました。境界は丸められず、そのまま比較されます。
浮動小数点(FLOAT / DOUBLE)の列では、0.1 + 0.2 が 0.3 にならない誤差の問題が BETWEEN にもそのまま現れます。
-- PostgreSQL 16:numeric は一致、float8 は一致しない
SELECT 0.1 + 0.2 BETWEEN 0.3 AND 0.3 AS numeric_exact,
(0.1::float8 + 0.2::float8) BETWEEN 0.3 AND 0.3 AS float_exact;
numeric_exact | float_exact
---------------+-------------
t | f
MySQL 8.0(0.1e0 + 0.2e0 → 0)、SQLite 3.45(0.1 + 0.2 は浮動小数点なので 0)でも同じでした。金額や率のように境界で判定する列は DECIMAL / NUMERIC で持ちます。
連続する区間を BETWEEN で区切ると、境界が二重に数えられる
「1000〜2000」と「2000〜3000」をどちらも BETWEEN で書くと、2000 の行(id = 4)が両方に入ります。
SELECT
SUM(CASE WHEN amount BETWEEN 1000 AND 2000 THEN 1 ELSE 0 END) AS b_1000_2000,
SUM(CASE WHEN amount BETWEEN 2000 AND 3000 THEN 1 ELSE 0 END) AS b_2000_3000
FROM orders;
b_1000_2000 | b_2000_3000
-------------+-------------
3 | 3
範囲内の行は id 2〜6 の5行なのに、合計は 6 です。区間を並べるときは半開区間にします。
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;
under_1000 | from_1000 | from_2000 | from_3000
------------+-----------+-----------+-----------
1 | 2 | 2 | 2
合計 7 で、NULL の1行を除いた件数と合います。整数だけの列なら BETWEEN 1000 AND 1999 のように上限を1つ手前にする書き方も使えますが、小数が入る列では半開区間のほうが確実です。CASE 式の書き方は関連記事「SQL CASE式の使い方|用途別コピペ集とDB比較・実行結果つき」で扱っています。
文字列のBETWEEN:辞書順と照合順序
文字列にも BETWEEN は使えます。比較は辞書順(文字の並び順)です。
SELECT id, customer FROM orders WHERE customer BETWEEN 'B' AND 'D' ORDER BY id;
id | customer
----+----------
2 | Baba
3 | Chiba
(2 rows)
'Doi' が入っていません。 'D' の後ろに文字が続く 'Doi' は 'D' より大きいため、上限を超えています。'c1' BETWEEN 'a' AND 'c' も 3つの DB すべてで偽でした。D で始まる名前まで含めたいときは、次のどちらかにします。
-- 上限を「次の文字」未満にする(Baba / Chiba / Doi の3行)
WHERE customer >= 'B' AND customer < 'E'
-- 上限を実際の値にする(同じ3行)
WHERE customer BETWEEN 'Baba' AND 'Doi'
日付の「最終日が漏れる」現象と同じ構造で、上限に短い値を書くと、それより長い値は範囲の外になります。頭文字で絞るだけなら customer LIKE 'D%' のほうが意図が伝わります(関連記事「SQL LIKE部分一致とエスケープ|%・_の扱いをDB別にコピペ解説」で扱っています)。
大文字・小文字の区別は照合順序で決まる
小文字で BETWEEN 'b' AND 'd' と書いたときの結果は、DB と照合順序(文字の比べ方の設定)で変わります。
| DB・照合順序 | customer BETWEEN 'b' AND 'd' の結果 |
|---|---|
| MySQL 8.0 utf8mb4_0900_ai_ci(既定) | Baba・Chiba の2行(大文字小文字を区別しない) |
MySQL 8.0 COLLATE utf8mb4_bin |
0 行(大文字は小文字より前に並ぶ) |
| PostgreSQL 16 照合順序 C.UTF-8(DB 作成時の既定値) | 0 行(バイト順。大文字が先) |
PostgreSQL 16 COLLATE "en-x-icu" |
Baba・Chiba の2行 |
| SQLite 3.45 BINARY(既定) | 0 行 |
SQLite 3.45 COLLATE NOCASE |
Baba・Chiba の2行 |
照合順序に左右されない書き方は、列と境界の両方をそろえる lower(customer) BETWEEN 'b' AND 'd'(PostgreSQL で2行)ですが、列に関数をかけるのでインデックスは使われません。文字列の範囲をインデックスで引きたいなら、境界のほうを列の照合順序に合わせます。
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;
total | in_range | out_range
-------+----------+-----------
8 | 5 | 2
(1 row)
| id | amount | BETWEEN | NOT BETWEEN |
|---|---|---|---|
| 1 | 500 | 出ない | 出る |
| 2 | 1000 | 出る | 出ない |
| 3 | 1500 | 出る | 出ない |
| 4 | 2000 | 出る | 出ない |
| 5 | 2500 | 出る | 出ない |
| 6 | 3000 | 出る | 出ない |
| 7 | NULL | 出ない | 出ない |
| 8 | 3500 | 出ない | 出る |
BETWEEN 5行+NOT BETWEEN 2行で7行。amount が NULL の id 7 はどちらにも出ない。
全体は 8 行なのに、範囲内 5 行と範囲外 2 行を足すと 7 行です。残りの1行が NULL の行です(MySQL・SQLite でも同じ)。
NULL は「値が分からない」ことを表すため、NULL との比較結果は真でも偽でもなく「不明(NULL)」です。WHERE は結果が真の行だけを通すので、不明の行は NOT を付けても通りません。境界側が NULL の場合も含めて、PostgreSQL 16 で確かめた結果です。
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
| 式 | 結果 | 理由 |
|---|---|---|
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(真 1・偽 0・不明 NULL)と SQLite でも同じでした。Microsoft Learn の BETWEEN の説明も「>= と <= のどちらかが FALSE なら全体が FALSE、そうでなければ UNKNOWN」と、同じ決まり方を書いています。
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 の比較の落とし穴は、関連記事「SQLのNULLの扱い|COALESCE・NULLIFと落とし穴12選」で扱っています。
下限や上限に NULL が入るとき(検索画面の入力値)
検索条件の下限・上限を外から渡す場面では、片方が未入力(NULL)になることがあります。amount BETWEEN 1000 AND NULL の件数は、3つの DB とも 0 件でした。片方だけの指定にも対応するなら、条件を分けて書きます。
-- :min と :max は外から渡す値。NULL のときはその条件を使わない
SELECT id, amount FROM orders
WHERE (:min IS NULL OR amount >= :min)
AND (:max IS NULL OR amount <= :max)
ORDER BY id;
:min を NULL、:max を 2000 にして実行すると id 1〜4 の4行で、下限なしの検索になりました。COALESCE(:min, 0) のように既定値で埋める書き方は、関連記事「SQL COALESCEの使い方|IFNULL・NVL・ISNULL対応表と実行結果」で扱っています。
NOT BETWEENの使い方(範囲の外側)
NOT BETWEEN は「下限より小さい、または上限より大きい」行を取り出します。
SELECT id, amount FROM orders WHERE amount NOT BETWEEN 1000 AND 3000 ORDER BY id;
id | amount
----+--------
1 | 500
8 | 3500
(2 rows)
境界の値(1000 と 3000)は BETWEEN 側に含まれるので、NOT BETWEEN 側には入りません。次の3つは 3つの DB すべてで同じ2行になりました。
WHERE amount NOT BETWEEN 1000 AND 3000
WHERE NOT (amount BETWEEN 1000 AND 3000)
WHERE amount < 1000 OR amount > 3000
かっこを付けない WHERE NOT amount BETWEEN 1000 AND 3000 も、PostgreSQL・MySQL・SQLite とも NOT (amount BETWEEN ...) として扱われ、同じ2行でした。Oracle のドキュメントも expr1 NOT BETWEEN expr2 AND expr3 は NOT (expr1 BETWEEN expr2 AND expr3) の値だと定めています。読む人のために、かっこは付けておくと意図が伝わります。
下限と上限を逆に書くと0件(BETWEEN SYMMETRIC・LEAST/GREATEST)
BETWEEN は「小さいほう AND 大きいほう」の順で書きます。逆に書いてもエラーにならず、0 件が返ります(3つの DB で同じ)。
SELECT COUNT(*) FROM orders WHERE amount BETWEEN 3000 AND 1000;
count
-------
0
amount >= 3000 AND amount <= 1000 という、同時には満たせない条件になるためです。日付でも同じで、BETWEEN '2026-09-30' AND '2026-09-01' は 0 件でした。「データがあるはずなのに 0 件」のときは、まず順番を疑います。
PostgreSQL には、順番を自動で入れ替える BETWEEN SYMMETRIC があります(ドキュメント:「AND の左の引数が右の引数以下である必要がなく、そうでなければ2つの引数が自動で入れ替わる」)。
-- PostgreSQL 16(5行が返る)
SELECT id, amount FROM orders WHERE amount BETWEEN SYMMETRIC 3000 AND 1000 ORDER BY id;
MySQL 8.0 は ERROR 1064 (42000): You have an error in your SQL syntax; ... near '3000 AND 1000'、SQLite は near "3000": syntax error になります。どの DB でも使えるのは、小さいほうと大きいほうを関数で並べる書き方です。
-- PostgreSQL・MySQL(SQLite は min(a, b) と max(a, b))
SELECT id, amount FROM orders
WHERE amount BETWEEN LEAST(3000, 1000) AND GREATEST(3000, 1000)
ORDER BY id;
3つの DB とも id 2〜6 の5行になりました。開始日と終了日を自由に入力できる画面では、この形かアプリ側での並べ替えが向いています。
BETWEENとインデックス(EXPLAINの実物)
BETWEEN は、列にインデックスがあれば範囲検索(range scan)になります。1分ごとに1件、20万行の orders_big(ordered_at にインデックス)で確かめました。
PostgreSQL 16:BETWEEN は >= と <= に展開されて Index Scan
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders_big WHERE ordered_at BETWEEN '2026-02-01' AND '2026-02-02';
Index Scan using idx_orders_big_at on orders_big (actual rows=1441 loops=1)
Index Cond: ((ordered_at >= '2026-02-01 00:00:00'::timestamp without time zone) AND (ordered_at <= '2026-02-02 00:00:00'::timestamp without time zone))
Index Cond に、BETWEEN が >= と <= の2条件に置き換えられて出ています。半開区間でも同じ Index Scan です(< に変わるだけ)。
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders_big WHERE ordered_at >= '2026-02-01' AND ordered_at < '2026-02-03';
Index Scan using idx_orders_big_at on orders_big (actual rows=2880 loops=1)
Index Cond: ((ordered_at >= '2026-02-01 00:00:00'::timestamp without time zone) AND (ordered_at < '2026-02-03 00:00:00'::timestamp without time zone))
一方、列を DATE に変換してから比べると、20万行を全部読む Seq Scan になりました。
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM orders_big WHERE CAST(ordered_at AS DATE) BETWEEN '2026-02-01' AND '2026-02-02';
Seq Scan on orders_big (actual rows=2880 loops=1)
Filter: (((ordered_at)::date >= '2026-02-01'::date) AND ((ordered_at)::date <= '2026-02-02'::date))
Rows Removed by Filter: 197120
同じ 2,880 行を取る COUNT(*) の時間は、半開区間が 0.588 ms、CAST 版が 12.521 ms でした(psql の \timing)。EXTRACT(MONTH FROM ordered_at) = 2 のように関数をかけた条件も、全行を読む計画(Parallel Seq Scan)になりました。CAST 版をどうしても使うなら、CREATE INDEX ... ON orders_big (CAST(ordered_at AS DATE)) の式インデックスを作ると Index Scan になりました。
MySQL 8.0:type が range か ALL か
EXPLAIN SELECT * FROM orders_big WHERE ordered_at BETWEEN '2026-02-01' AND '2026-02-02';
| type | possible_keys | key | rows | Extra |
| range | idx_orders_big_at | idx_orders_big_at | 1441 | Using index condition |
EXPLAIN SELECT * FROM orders_big WHERE ordered_at >= '2026-02-01' AND ordered_at < '2026-02-03';
| type | possible_keys | key | rows | Extra |
| range | idx_orders_big_at | idx_orders_big_at | 2880 | Using index condition |
EXPLAIN SELECT * FROM orders_big WHERE DATE(ordered_at) BETWEEN '2026-02-01' AND '2026-02-02';
| type | possible_keys | key | rows | Extra |
| ALL | NULL | NULL | 199876 | Using where |
EXPLAIN FORMAT=TREE では、半開区間が Index range scan on orders_big using idx_orders_big_at over ('2026-02-01 00:00:00' <= ordered_at < '2026-02-03 00:00:00')、DATE() 版が Table scan on orders_big と表示されました。EXPLAIN の各列の読み方は関連記事「MySQL EXPLAINの見方|type・key・rows・Extra早見表」で扱っています。
SQLite 3.45:SEARCH か SCAN か
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE ordered_at BETWEEN '2026-09-01' AND '2026-09-30';
SEARCH orders USING INDEX idx_orders_at (ordered_at>? AND ordered_at<?)
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE date(ordered_at) BETWEEN '2026-09-01' AND '2026-09-30';
SCAN orders
| 書き方 | PostgreSQL 16 | MySQL 8.0 | SQLite 3.45 |
|---|---|---|---|
列 BETWEEN a AND b |
Index Scan | range | SEARCH(INDEX) |
列 >= a AND 列 < b |
Index Scan | range | SEARCH(INDEX) |
CAST(列 AS DATE) BETWEEN a AND b |
Seq Scan | ALL | SCAN |
「列はそのまま、比べる値のほうを整える」と覚えておくと、速さと正確さの両方を保てます。範囲が広すぎる(全行の大半に当たる)ときは、インデックスがあっても全体を読む計画が選ばれます(20万行に対して1年分を指定すると PostgreSQL は Seq Scan でした)。インデックスが使われない書き方の一覧は、関連記事「SQLのインデックスが効かない10のパターンと確認方法」で扱っています。
IN・CASE・JOINでのBETWEEN
IN との使い分け
BETWEEN は「連続した範囲」、IN は「とびとびの値」です。amount IN (1000, 2000, 3000) は id 2・4・6 の3行で、間の 1500・2500 は入りません。候補が「1 から 100 まで」のように連続しているなら BETWEEN、「1・3・7」のように離れているなら IN です。
CASE 式の中で金額帯のラベルを付ける
SELECT id, amount,
CASE WHEN amount IS NULL THEN '不明'
WHEN amount < 1000 THEN '小'
WHEN amount BETWEEN 1000 AND 2999 THEN '中'
ELSE '大' END AS band
FROM orders ORDER BY id;
id | amount | band
----+--------+------
1 | 500 | 小
2 | 1000 | 中
3 | 1500 | 中
4 | 2000 | 中
5 | 2500 | 中
6 | 3000 | 大
7 | | 不明
8 | 3500 | 大
(8 rows)
CASE は上から順に判定するので、先に < 1000 を書いておけば次の BETWEEN は下限を省いて amount < 3000 とも書けます。NULL の行は最初の WHEN で拾わないと、どの BETWEEN にも当てはまらず ELSE に落ちます。
JOIN 条件で期間テーブルと結びつける
キャンペーン期間(start_date・end_date は DATE 型、end_date はその日を含む)に、注文を結びつける例です。注文日が DATE 型なら BETWEEN がそのまま使えます。
SELECT c.name, o.id, o.order_date
FROM campaigns c
JOIN orders o ON o.order_date BETWEEN c.start_date AND c.end_date
ORDER BY c.name, o.id;
name | id | order_date
-----------+----+------------
autumn | 2 | 2026-09-01
autumn | 3 | 2026-09-15
autumn | 4 | 2026-09-30
autumn | 5 | 2026-09-30
autumn | 6 | 2026-09-30
month_end | 4 | 2026-09-30
month_end | 5 | 2026-09-30
month_end | 6 | 2026-09-30
(8 rows)
注文の日時(ordered_at)を同じ BETWEEN で結ぶと、最終日の日中が落ちて 4 行になりました(PostgreSQL・MySQL。文字列で比べる SQLite は 9月30日 0:00 の行も落ちて 2 行)。
JOIN orders o ON o.ordered_at BETWEEN c.start_date AND c.end_date
name | id | ordered_at
-----------+----+---------------------
autumn | 2 | 2026-09-01 00:00:00
autumn | 3 | 2026-09-15 12:30:00
autumn | 4 | 2026-09-30 00:00:00
month_end | 4 | 2026-09-30 00:00:00
(4 rows)
終了日の翌日未満にすると、DATE 型のときと同じ 8 行です。
-- PostgreSQL(MySQL は c.end_date + INTERVAL 1 DAY、SQLite は date(c.end_date, '+1 day'))
JOIN orders o ON o.ordered_at >= c.start_date AND o.ordered_at < c.end_date + 1
期間テーブルの終了日を「含む日」で持つか「含まない日時」で持つかは設計の問題です。考え方は関連記事「履歴テーブル設計パターン5選|有効開始日・終了日の持ち方とSQL」で、JOIN の種類は関連記事「SQL INNER JOINとLEFT JOINの違い|JOIN種類早見表と落とし穴」で扱っています。
やってはいけない書き方と、やり直す方法
DELETE・UPDATE の範囲を BETWEEN で書いて、翌月の先頭まで消す
「9月分を消す」つもりで上限を翌月1日にした BETWEEN は、10月1日 0:00:00 ちょうどの行まで対象にします。トランザクションの中で実行し、件数を見てから戻した結果です。
BEGIN;
DELETE FROM orders WHERE ordered_at BETWEEN '2026-09-01' AND '2026-10-01';
DELETE 6
SELECT id, ordered_at FROM orders ORDER BY id;
id | ordered_at
----+---------------------
1 | 2026-08-31 23:59:59
8 |
(2 rows)
ROLLBACK;
SELECT COUNT(*) FROM orders;
count
-------
8
9月分は5行なのに DELETE 6 で、10月1日の id = 7 まで消えています。逆に上限を '2026-09-30' にすると、30日の日中の2行が残ります。COMMIT してしまうと ROLLBACK では戻らないため、同じ WHERE で先に SELECT して件数を控え、BEGIN の中で実行して件数が合ってから COMMIT する手順を型にします。
SELECT COUNT(*) FROM orders WHERE ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01';
count
-------
5
BEGIN;
DELETE FROM orders WHERE ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01';
DELETE 5
-- 件数が一致したので COMMIT(この記事では ROLLBACK で戻した)
DELETE を戻せる条件は、関連記事「DELETE・TRUNCATE・DROPの違い|DB別ロールバック可否の比較表」で扱っています。
やってはいけない書き方の一覧
| 書き方 | 起きること | 代わりに |
|---|---|---|
日時列に BETWEEN '2026-09-01' AND '2026-09-30' |
30日の 0:00 以降が漏れる(SQLite は 0:00 も) | >= '2026-09-01' AND < '2026-10-01' |
上限を '... 23:59:59' にする |
秒の端数(.500 など)が漏れる | 翌日未満 |
| 上限を翌月1日にした BETWEEN | 翌月1日 0:00:00 ちょうどの行が入る | 翌月1日「未満」 |
CAST(列 AS DATE) BETWEEN ...・DATE(列)・EXTRACT(...) |
インデックスが使われず全件を読む | 列はそのまま、値を整える |
月末を '2026-09-31' と決め打ち |
PostgreSQL はエラー、MySQL は 0 件か全件一致(警告のみ) | 翌月1日未満 |
数値列に BETWEEN '1000' AND 'abc' |
MySQL は 'abc' が 0 になり 0 件(警告のみ)。SQLite は 1000 以上が全部出る | 値の型をそろえる |
BETWEEN :min AND :max で片方が NULL |
0 件になる | (:min IS NULL OR ...) で条件を分ける |
BETWEEN 3000 AND 1000(逆順) |
0 件 | 小さいほうを先に。LEAST / GREATEST |
| 連続する区間を BETWEEN で並べる | 境界の値が2つの区間に入る | 半開区間で区切る |
| timestamptz の境界をタイムゾーンなしで書く | 接続の TimeZone で件数が変わる | '... 00:00:00+09' のように明示する |
エラーになる書き方と直し方(原文)
| エラー文(原文) | 原因 | 直し方 |
|---|---|---|
PostgreSQL ERROR: syntax error at or near "3000"/MySQL ERROR 1064 (42000): You have an error in your SQL syntax; ... near '3000' at line 1/SQLite near "3000": syntax error |
BETWEEN 1000 3000 のように AND が抜けた(カンマ区切りも同じ) |
BETWEEN 1000 AND 3000 |
MySQL ERROR 1064 (42000): ... near 'BETWEEN 1000 AND 3000' at line 1/PostgreSQL ERROR: syntax error at or near "1000" |
WHERE BETWEEN 1000 AND 3000 のように列名が抜けた |
WHERE amount BETWEEN 1000 AND 3000 |
PostgreSQL ERROR: date/time field value out of range: "2026-09-31" |
存在しない日付。"2026/13/01" では HINT: Perhaps you need a different "datestyle" setting. が付く |
翌月1日未満で書く。書式は YYYY-MM-DD |
PostgreSQL ERROR: invalid input syntax for type date: "abc" |
日付列に日付でない文字列 | 値を見直す |
MySQL ERROR 1525 (HY000): Incorrect DATETIME value: '2026-09-31'(DATE 列は Incorrect DATE value) |
>=・=・DATE '2026-09-31' で存在しない日付(BETWEEN だけは警告 1292 で通り、0 件か全件になる) |
翌月1日未満で書く |
MySQL Warning 4095 Delimiter '/' in position 4 in datetime value '2026/12/31' ... is deprecated. Prefer the standard '-'. |
区切りに / を使った(8.0.46 では動くが非推奨) |
- 区切りにする |
PostgreSQL ERROR: operator does not exist: character varying >= integer |
文字列の列に数値の境界(customer BETWEEN 1 AND 3) |
境界を文字列にする。HINT: ... You might need to add explicit type casts. |
PostgreSQL ERROR: invalid input syntax for type integer: "abc" |
数値の列に数値でない文字列 | 値の型をそろえる('1000' のような数字の文字列は通る) |
MySQL Warning 1292 Truncated incorrect DOUBLE value: 'abc' |
数値の列と文字列を比べ、文字列が 0 に変換された(エラーにならず結果が変わる) | 値の型をそろえる。SHOW WARNINGS を見る |
MySQL ERROR 1064 ... near '3000 AND 1000'/SQLite near "3000": syntax error |
BETWEEN SYMMETRIC は PostgreSQL だけ |
LEAST / GREATEST(SQLite は min / max) |
SQLite は型の決まりがゆるく、amount BETWEEN '1000' AND 'abc' がエラーにならず id 2〜6 と 8(1000 以上の全部) を返しました。文字列 'abc' はどの整数より大きい扱いになるためです。文字列と数値の変換の決まりは、関連記事「SQL CASTで型変換|文字列・数値・日付の変換をDB別早見表で解説」で扱っています。
BETWEENと「>=・<」の判断表
| 場面 | 使うもの | 理由 |
|---|---|---|
| 整数の範囲(年齢・数量・ID) | BETWEEN | 両端を含む意味がそのまま読める |
| DATE 型の期間 | BETWEEN | 1日が1つの値なので最終日も含まれる |
| DATETIME / TIMESTAMP 型の期間 | >= と < |
最終日の時刻と秒の端数を取りこぼさない |
| タイムゾーン付きの列 | >= と <、境界にオフセットを明示 |
接続の設定で境界がずれない |
| 小数の区間を連続して区切る | >= と < |
境界の値が両方の区間に入らない |
| 文字列の頭文字の範囲 | >= と < |
上限より長い文字列を取りこぼさない |
| 範囲の外側 | NOT BETWEEN(必要なら OR 列 IS NULL) |
NULL の行を含めるかを明示できる |
| 下限・上限が入れ替わりうる | BETWEEN LEAST(a,b) AND GREATEST(a,b) |
逆順の 0 件を防ぐ |
flowchart TD
A["範囲で絞り込みたい"] --> B{"列は日時型か"}
B -- "はい" --> D["列 >= 開始 AND 列 < 翌日"]
D --> K["タイムゾーン付きなら境界に +09 を明示"]
B -- "いいえ" --> L{"文字列か小数か"}
L -- "はい" --> F["列 >= a AND 列 < b"]
L -- "いいえ" --> G{"上限を含めるか"}
G -- "含める" --> C["BETWEEN a AND b"]
G -- "含めない" --> F
DB別の日付リテラルと翌日・翌月の求め方(sql server between を含む)
MySQL・PostgreSQL・SQLite は実行して確かめた書き方、SQL Server と Oracle は公式ドキュメントの構文です。
| DB | 日付リテラル | 日時リテラル | 翌日・翌月1日の求め方 |
|---|---|---|---|
| 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)、DATE_FORMAT(d, '%Y-%m-01') + INTERVAL 1 MONTH、LAST_DAY(d) + INTERVAL 1 DAY |
| PostgreSQL 16 | DATE '2026-09-01'、'2026-09-01' |
TIMESTAMP '2026-09-01 00:00:00'、TIMESTAMPTZ '... +09' |
DATE '2026-09-30' + 1、date_trunc('month', d) + INTERVAL '1 month' |
| SQLite 3 | '2026-09-01'(文字列として保存・比較) |
'2026-09-01 00:00:00' |
date('2026-09-30', '+1 day')、date(d, 'start of month', '+1 month') |
| SQL Server | '20260901'、CAST('2026-09-01' AS date) |
'2026-09-01T00:00:00' |
DATEADD(day, 1, '20260930')、DATEADD(day, 1, EOMONTH(d))、DATEADD(month, 1, DATETRUNC(month, d))(2022 以降) |
| Oracle | 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(TRUNC(d, 'MM'), 1) |
| DB | 知っておきたい点 |
|---|---|
| MySQL | DATETIME は初期設定で秒まで。DATETIME(3)・DATETIME(6) にすると端数が保存され、23:59:59 指定で漏れる。存在しない日付は BETWEEN では警告だけで 0 件か全件になる |
| PostgreSQL | timestamp はマイクロ秒まで保存。BETWEEN SYMMETRIC が使える。存在しない日付はエラー。timestamptz はセッションの TimeZone で境界を解釈する |
| SQLite | 専用の日付型が無く文字列として比べる。書式(桁数・区切り)をそろえて保存すると文字列の大小と日時の前後が一致する。'2026/09/01' のような別書式は一致しない |
| SQL Server | datetime 型は .000 .003 .007 秒の単位に丸められる(ドキュメント)。'... 23:59:59.999' は翌日 00:00:00.000 に丸められるので、上限に書くと翌日 0 時の行まで入る。datetime2・date と半開区間が推奨 |
| Oracle | DATE 型が年月日に加えて時分秒を持つ(ドキュメント)。DATE 型の列でも「最終日が漏れる」現象が起きるので、>= DATE '2026-09-01' AND < DATE '2026-10-01' の半開区間で書く |
SQL Server のドキュメントは、BETWEEN の例で「時刻を省略した日付は 12:00 A.M.(0 時)になり、最終日の 0 時より後の時刻を持つ行は返らない」と明記しています。SQL Server でも「翌日未満」の形は同じです。
-- SQL Server(datetime2 / datetime の列)
WHERE ordered_at >= '20260901' AND ordered_at < '20261001'
-- Oracle(DATE / TIMESTAMP の列)
WHERE ordered_at >= DATE '2026-09-01' AND ordered_at < DATE '2026-10-01'
どの DB でも共通して使えるのは「列 >= 開始 AND 列 < 翌日(翌月1日)」の形です。DB を移るときは、翌日を求める関数の部分だけを置き換えます。
よくある質問
Q. sql between の基本の使い方は?
A. WHERE 列 BETWEEN 下限 AND 上限 で、下限以上・上限以下(両端を含む)の行を取り出します。列 >= 下限 AND 列 <= 上限 と同じ意味です。数値・日付・文字列に使えます。
Q. sql between 日付で、最終日のデータが取れないのはなぜですか?
A. 列が日時型(DATETIME / TIMESTAMP)のとき、'2026-09-30' は「9月30日 0:00:00」として比べられるためです。列 >= '2026-09-01' AND 列 < '2026-10-01' のように翌日(翌月1日)未満で書くと、最終日の全時刻が入ります。DATE 型の列なら BETWEEN のままで最終日も含まれます。
Q. sql server between で日付を指定するときの注意は?
A. 動きは他の DB と同じで、時刻を省略した日付は 0 時として扱われ、最終日の日中が漏れます。さらに datetime 型は .000・.003・.007 秒の単位に丸められるため、上限に 23:59:59.999 と書くと翌日 0 時に丸められて翌日の行まで入ります。WHERE 列 >= '20260901' AND 列 < '20261001' の半開区間で書きます。
Q. between は境界値を含みますか?
A. 含みます。x BETWEEN 1 AND 5 は 1 と 5 のどちらも結果に入ります(PostgreSQL 16・MySQL 8.0・SQLite 3.45 で確認)。片方を含めたくないときは >= と < で書きます。
Q. not between で NULL の行は取れますか?
A. 取れません。NULL との比較は「不明」になり、NOT を付けても不明のままなので WHERE を通りません。NULL の行も含めたいときは OR 列 IS NULL を足します。
Q. between 以上 以下 と「>= AND <=」のどちらが速いですか?
A. 同じです。PostgreSQL 16 の実行計画では BETWEEN が >= と <= の2条件に展開されて Index Scan になり、MySQL 8.0 でも両方 type: range でした。速さではなく「上限を含めたいかどうか」で選びます。
Q. between の下限と上限を逆に書くとどうなりますか?
A. エラーにならず 0 件です。PostgreSQL は BETWEEN SYMMETRIC で自動的に入れ替わります。他の DB では BETWEEN LEAST(a, b) AND GREATEST(a, b) と書きます。
Q. between の上限に翌月1日を書くのはなぜよくないのですか?
A. BETWEEN は上限を含むため、翌月1日 0:00:00 ちょうどの行が入ります。この記事の DELETE の例では、9月分5行のつもりが DELETE 6 になりました。翌月1日は「未満」で書きます。
まとめ
- BETWEEN は両端を含む(以上・以下)。
x BETWEEN a AND bはx >= a AND x <= bと同じで、実行計画でもこの2条件に展開される - 日時列に日付だけを指定すると、上限は 0:00:00 になり最終日の日中が漏れる(PostgreSQL・MySQL は3行、SQLite は2行で再現)。日時の期間は「>= 開始 AND < 翌日」で書くと、秒の端数・月末の日数・列の精度に左右されない
- DATE 型の列は BETWEEN のままでよく、timestamptz は境界にタイムゾーンを明示する(接続の TimeZone で件数が 1件↔3件に変わった)
- NULL の行は BETWEEN でも NOT BETWEEN でも出ない。必要なら
OR 列 IS NULL。下限・上限が NULL になりうる検索は条件を分ける - 下限と上限を逆に書くと 0 件。PostgreSQL は
BETWEEN SYMMETRIC、他はLEAST / GREATEST - 文字列は上限より長い値が範囲の外。頭文字の範囲は「次の文字未満」、大文字小文字は照合順序しだい
- 列に CAST や関数をかけない。PostgreSQL で Seq Scan(12.5 ms)と Index Scan(0.6 ms)の差になった
- MySQL は存在しない日付を BETWEEN に書くと警告だけで 0 件か全件になる。DELETE・UPDATE の範囲は同じ WHERE で SELECT してから BEGIN の中で実行する
参考・出典
確認日はいずれも 2026年10月16日です。
- PostgreSQL 16 Documentation「9.2. Comparison Functions and Operators」(BETWEEN・BETWEEN SYMMETRIC):https://www.postgresql.org/docs/16/functions-comparison.html
- PostgreSQL 16 Documentation「8.5. Date/Time Types」(timestamptz の入力解釈・精度):https://www.postgresql.org/docs/16/datatype-datetime.html
- MySQL 8.0 Reference Manual「Comparison Functions and Operators」(BETWEEN ... AND ...):https://dev.mysql.com/doc/refman/8.0/en/comparison-operators.html
- MySQL 8.0 Reference Manual「Type Conversion in Expression Evaluation」:https://dev.mysql.com/doc/refman/8.0/en/type-conversion.html
- SQLite「SQL Language Expressions」(The BETWEEN operator):https://www.sqlite.org/lang_expr.html
- SQLite「Date And Time Functions」(start of month・+1 month):https://www.sqlite.org/lang_datefunc.html
- Microsoft Learn「BETWEEN (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/language-elements/between-transact-sql
- Microsoft Learn「datetime (Transact-SQL)」(秒の丸め):https://learn.microsoft.com/en-us/sql/t-sql/data-types/datetime-transact-sql
- Microsoft Learn「DATETRUNC (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/functions/datetrunc-transact-sql
- Microsoft Learn「EOMONTH (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/functions/eomonth-transact-sql
- Oracle Database 23ai SQL Language Reference「BETWEEN Condition」:https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/BETWEEN-Condition.html
- Oracle Database 23ai SQL Language Reference「Data Types」(DATE・TIMESTAMP):https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/Data-Types.html
- Oracle Database 23ai SQL Language Reference「Literals」(Datetime Literals):https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/Literals.html
- Oracle Database 23ai SQL Language Reference「TRUNC (date)」:https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/TRUNC-date.html
- Oracle Database 23ai SQL Language Reference「ADD_MONTHS」:https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/ADD_MONTHS.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL BETWEENの使い方|日付の最終日が漏れる原因と直し方・DB別早見表」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。