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)
orders 全8行と amount BETWEEN 1000 AND 3000(当たる行に色)
idcustomeramount結果
1Aoki500出ない
2Baba1000出る(下限ちょうど)
3Chiba1500出る
4Doi2000出る
5Endo2500出る
6Fujii3000出る(上限ちょうど)
7GotoNULL出ない
8Hara3500出ない

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 |
+----+-------------------------+
orders 全8行と ordered_at BETWEEN '2026-09-01' AND '2026-09-30'(MySQL 8.0)
idordered_at結果
12026-08-31 23:59:59.000出ない(8月)
22026-09-01 00:00:00.000出る
32026-09-15 12:30:00.000出る
42026-09-30 00:00:00.000出る(上限ちょうど)
52026-09-30 18:45:00.000出ない(9月30日なのに漏れる)
62026-09-30 23:59:59.500出ない(9月30日なのに漏れる)
72026-10-01 00:00:00.000出ない(10月)
8NULL出ない

上限が 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)
orders 全8行と ordered_at >= '2026-09-01' AND ordered_at < '2026-10-01'
idordered_at結果
12026-08-31 23:59:59出ない(8月)
22026-09-01 00:00:00出る
32026-09-15 12:30:00出る
42026-09-30 00:00:00出る
52026-09-30 18:45:00出る(BETWEEN では漏れた行)
62026-09-30 23:59:59.5出る(BETWEEN では漏れた行)
72026-10-01 00:00:00出ない(10月)
8NULL出ない

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)
orders 全8行と BETWEEN/NOT BETWEEN 1000 AND 3000
idamountBETWEENNOT BETWEEN
1500出ない出る
21000出る出ない
31500出る出ない
42000出る出ない
52500出る出ない
63000出る出ない
7NULL出ない出ない
83500出ない出る

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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?