こんにちは、おうどんです🍜
10月の終わり。 うどん屋の店長(おうどん)が、月の売上を集計しています。
今月の目玉は、ハロウィン当日だけの限定メニュー「かぼちゃうどん」。 10月31日だけ、販売しました。
SQL を書く。
SELECT item, count(*) FROM orders WHERE ordered_at BETWEEN '2026-10-01' AND '2026-10-31' GROUP BY item;
実行する。
きつねうどん、1杯。 ざるうどん、1杯。
……かぼちゃうどん、0杯。
店長「え、売れなかったの? あんなに行列できてたのに」
BETWEENさん「10月1日から10月31日まで、ちゃんと数えましたよ」
店長「31日の分は?」
BETWEENさん「31日になった瞬間までは、数えました」
店長「……瞬間?」
(場面は説明用の架空のものです)
これ、SQL の日付の範囲検索で本当によくある落とし穴です。 BETWEEN '2026-10-01' AND '2026-10-31' と書いたのに、10月31日の注文がほとんど出てこない。
「31日まで」って書いたんだから、31日は全部入るでしょ?
人間はそう読みます。 でも SQL は、そう読んでくれないんです。
今回は、かぼちゃうどんが消えた理由と、消さない書き方を、初心者向けの直し方から、23:59:59 の罠、日ごとの集計の二重カウント、インデックス、DB ごとの違いまで掘っていきます。
結論:日付だけの「まで」は、その日の0時0分0秒まで
BETWEENさんが悪いわけではありません。時刻付きの列に、日付だけを書いて範囲を指定したのが原因です。
時刻付きの列(注文日時のように、時・分・秒まで入っている列)と、日付だけの値を比べると、日付だけの値は「その日の0時0分0秒」として扱われます。 Oracle の日付リテラルのドキュメントにも、時刻を指定しない日付の値は、既定の時刻が真夜中(00:00:00)になる、と書かれています。
そして BETWEEN は、両端を含む「以上・以下」の比較です。 Oracle の BETWEEN 条件のドキュメントでは、expr1 BETWEEN expr2 AND expr3 は expr2 <= expr1 AND expr1 <= expr3 と同じ、とされています。
つまり、こう読まれています。
'2026-10-01 00:00:00' <= ordered_at AND ordered_at <= '2026-10-31 00:00:00'
10月31日の 0時0分0秒ちょうどは入る。 でも 0時0分1秒から先は、全部範囲の外です。
まずは、この1つだけ覚えて帰ってください。
- 時刻付きの列の期間検索は、
>= 開始日 AND < 終了日の翌日で書く
BETWEENさん「わたしは言われたとおり、31日の0時ちょうどまで数えました」
店長「かぼちゃうどんは、お昼に売れたんだけど」
BETWEENさん「お昼は、31日の0時より後ですね」
……正論。正論すぎて、かぼちゃが冷めていく。
じゃあ、ハロウィンを30日に引っ越せば解決?
解決はします。 世界中の子どもたちに、1日早く仮装してもらう必要がありますが。
再現:10月31日のかぼちゃうどんが消える(SQLite)
実際に試してみます。終わりを「31日」と書いても「31日 23:59:59」と書いても、31日の注文は全部は出てきません。
今回は、インストールなしで動く SQLite(ファイル1つで動く小さなデータベース。Python に最初から入っています)を、Python から使いました。 注文を6件入れます。10月31日のかぼちゃうどんは、0時ちょうど・お昼・閉店まぎわの3杯です。
t1_between.py:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, item TEXT, ordered_at TEXT)")
con.executemany(
"INSERT INTO orders (item, ordered_at) VALUES (?, ?)",
[
("きつねうどん", "2026-10-01 00:00:00"),
("ざるうどん", "2026-10-15 12:30:00"),
("かぼちゃうどん", "2026-10-31 00:00:00"),
("かぼちゃうどん", "2026-10-31 12:10:00"),
("かぼちゃうどん", "2026-10-31 23:59:59.500"),
("月見うどん", "2026-11-01 00:00:00"),
],
)
queries = {
"A: BETWEEN '2026-10-01' AND '2026-10-31'":
"SELECT id, item, ordered_at FROM orders "
"WHERE ordered_at BETWEEN '2026-10-01' AND '2026-10-31' ORDER BY id",
"B: BETWEEN '... 00:00:00' AND '2026-10-31 23:59:59'":
"SELECT id, item, ordered_at FROM orders "
"WHERE ordered_at BETWEEN '2026-10-01 00:00:00' AND '2026-10-31 23:59:59' ORDER BY id",
"C: >= '2026-10-01' AND < '2026-11-01'":
"SELECT id, item, ordered_at FROM orders "
"WHERE ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01' ORDER BY id",
}
print("SQLite", sqlite3.sqlite_version)
for title, sql in queries.items():
rows = con.execute(sql).fetchall()
print(f"--- {title} -> {len(rows)}件")
for r in rows:
print(*r)
SQLite 3.45.3
--- A: BETWEEN '2026-10-01' AND '2026-10-31' -> 2件
1 きつねうどん 2026-10-01 00:00:00
2 ざるうどん 2026-10-15 12:30:00
--- B: BETWEEN '... 00:00:00' AND '2026-10-31 23:59:59' -> 4件
1 きつねうどん 2026-10-01 00:00:00
2 ざるうどん 2026-10-15 12:30:00
3 かぼちゃうどん 2026-10-31 00:00:00
4 かぼちゃうどん 2026-10-31 12:10:00
--- C: >= '2026-10-01' AND < '2026-11-01' -> 5件
1 きつねうどん 2026-10-01 00:00:00
2 ざるうどん 2026-10-15 12:30:00
3 かぼちゃうどん 2026-10-31 00:00:00
4 かぼちゃうどん 2026-10-31 12:10:00
5 かぼちゃうどん 2026-10-31 23:59:59.500
(今回の検証環境の Windows 10・Python 3.13.1・SQLite 3.45.3 で、python -X utf8 t1_between.py で実行した結果です)
- A(終わりを日付だけ):かぼちゃうどんが3杯とも消えた
- B(終わりを 23:59:59):閉店まぎわの 23:59:59.500 の1杯が消えた
- C(翌月1日の「未満」):10月の5杯が全部出て、11月1日の月見うどんは入っていない
かぼちゃうどん「ぼくたち、3杯とも売れたはずなんですけど」
BETWEENさん「Aの書き方だと、皆さんは11月の方ですね」
かぼちゃうどん「……ハロウィン当日に、ハロウィンから追い出された」
……仮装しすぎて、本人だと分かってもらえなかったパターンです。
ひとつ補足です。 A で 0時ちょうどの1杯まで消えたのは、SQLite が日時を文字列のまま比べているからです(上級者向けの章で詳しく扱います)。Oracle や PostgreSQL のように日付・時刻の型を持つ DB では、0時ちょうどの1杯は入って、0時1秒以降が消えます。 どちらにしても、31日の注文が欠けることに変わりはありません。
Bで4件出たし、だいたい合ってるからヨシ!
1杯だけ合わない売上表は、だいたい月末に経理さんから電話がかかってきます。 しかも、その1杯を探すのに半日かかります。
直し方:「>= 開始 AND < 翌日」の半開区間で書く
直し方はシンプルです。始まりは「以上」、終わりは「次の期間の始まり」の「未満」で書きます。
SELECT item, count(*) AS cups FROM orders WHERE ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01' GROUP BY item;
始まりを含み、終わりを含まない範囲を、半開区間(はんかいくかん)と呼びます。数学では [2026-10-01, 2026-11-01) と書く形です。
この書き方のいいところです。
- 31日が何時何分何秒でも、小数秒がいくつあっても、全部入る
- 終わりの日を「月末が30日か31日か」で悩まなくていい。翌月1日を書けばいい
- 11月の集計は
>= '2026-11-01' AND < '2026-12-01'。前の月の終わりと、次の月の始まりが同じ値なので、すき間も重なりもない
BETWEENさん「わたしの出番は?」
店長「今回は、>= さんと < さんのコンビにお願いします」
BETWEENさん「……2人がかりで、わたし1人分の仕事を」
……2人がかりのほうが正確なんです。BETWEENさんは、上級者向けの章でちゃんと活躍の場があります。
終わりを ‘2026-10-31 23:59:59.999999’ にすれば、BETWEEN のままいけるのでは?
いけることもあります。 でも、その「9 をいくつ並べればいいか」は DB と型によって違って、並べ方を間違えると、次の章で翌日に引っ越します。
🔰 ここまで読めば今日から困らない
- 時刻付きの列に
BETWEEN '開始日' AND '終了日'と書くと、終了日は0時0分0秒までしか入らない '23:59:59'で締めても、小数秒のある行が漏れることがある- 期間検索は
列 >= 開始日 AND 列 < 終了日の翌日で書く - 月の集計なら、終わりは「翌月1日」の未満
4行。かぼちゃうどんのレシピより短い。
レシピより短いのに、売上表は1杯も漏れなくなります。 コスパのいい4行です。
今まで書いた BETWEEN、全部書き換えなきゃダメ?
全部ではありません。 列に時刻が入っていない(日付だけの型、または時刻が必ず0時)なら、BETWEEN でも正しく動きます。疑わしいのは、列名に _at や datetime が付いている、時刻付きの列です。
ここから先は中級者向け。読み飛ばしてもOKです。
中級:23:59:59 で締める書き方が危ない理由
よく見かけるのが、BETWEEN '2026-10-01 00:00:00' AND '2026-10-31 23:59:59'。23時59分59秒と0時0分0秒のあいだにも時間はあって、そこに入った行が漏れます。
さっきの B の結果で、2026-10-31 23:59:59.500 が消えたのがこれです。 秒より細かい値(小数秒)を持てる列だと、59秒台の後半に入った行は範囲の外になります。
かぼちゃうどん(23:59:59.500)「閉店0.5秒前に滑り込みました!」
BETWEENさん「23時59分59秒ちょうどまでの受付です」
かぼちゃうどん「……0.5秒の遅刻で、存在ごと消えるの?」
……電車なら乗れた0.5秒です。
じゃあ 9 を増やせばいいかというと、今度は型によって逆の事故が起きます。 SQL Server の datetime 型は、datetime のドキュメントによると、小数秒が .000・.003・.007 秒の刻みに丸められます。ドキュメントの例では、01/01/2024 23:59:59.999 が 2024-01-02 00:00:00.000 として保存されています。
つまり SQL Server の datetime の列に対して、こう書くと、
-- SQL Server(未実行の例。datetime 型の列を想定) WHERE ordered_at BETWEEN '2026-10-01' AND '2026-10-31 23:59:59.999'
終わりの値が翌日の0時0分0秒に丸められて、11月1日0時ちょうどの月見うどんが10月に入ってくる可能性があります(SQL Server は今回の検証環境では動かしていないので、ドキュメントの丸めの例に基づく説明です)。 同じドキュメントは、新しく作るものには datetime ではなく datetime2 などを使うように、とも書いています。
9 を3つ並べたら、明日になった。
9 を6つ並べたら、型によっては今日のまま。
……9 の数で日付が変わるのは、もう占いです。 < 翌日 なら、9 を数えなくて済みます。
中級:日ごとの集計を BETWEEN で書くと、0時ちょうどが2回数えられる
日報や日ごとのグラフを作るときに、こう書いていませんか。BETWEEN は両端を含むので、0時ちょうどの行は、前の日と次の日の両方に入ります。
t2_midnight.py(抜粋):
days = [("2026-10-31", "2026-11-01"), ("2026-11-01", "2026-11-02")]
total = 0
for d, nd in days:
n = con.execute(
"SELECT count(*) FROM orders WHERE ordered_at BETWEEN datetime(?) AND datetime(?)",
(d, nd),
).fetchone()[0]
total += n
print(d, n, "件")
print("合計", total, "件 / 実際の行数", con.execute("SELECT count(*) FROM orders").fetchone()[0], "件")
注文は、10/31 の 0:00 と 12:10、11/1 の 0:00 と 8:00 の4件です。 datetime(?) は SQLite の関数で、'2026-10-31' を '2026-10-31 00:00:00' にそろえています(日付の型を持つ DB と同じ「日付だけ=0時」の比較にするため)。
--- 日ごとの集計を BETWEEN で書いた場合
2026-10-31 3 件
2026-11-01 2 件
合計 5 件 / 実際の行数 4 件
--- 日ごとの集計を >= と < で書いた場合
2026-10-31 2 件
2026-11-01 2 件
合計 4 件
(今回の検証環境で、t2_midnight.py を実行した結果のうち、この部分だけを抜き出しています。>= と < の版は、同じループの BETWEEN を書き換えたものです)
BETWEEN 版は、11月1日0時ちょうどの月見うどんを、10/31 にも 11/1 にも数えています。 4杯しか売れていないのに、日ごとの合計は5杯。
月見うどん「0時ちょうどに来たら、両方の日報に名前が載りました」
店長「お得な人だね」
経理さん「お得じゃないです。売上が1杯分、架空です」
……日付の境目に立つと、分身できてしまう。
半開区間なら、どの行も必ず1つの日にだけ入ります。 日ごと・月ごと・1時間ごと、どの単位でも同じです。
0時ちょうどの注文なんて、めったにないでしょ?
人間の注文ならそうかもしれません。 でも、バッチ(決まった時刻に自動で動く処理)が0時ちょうどに書き込む行は、毎日ぴったり0時です。
中級:date() や TRUNC() で包むと、インデックスが使われない
「じゃあ列のほうを日付だけにすればいい」と、date(ordered_at) BETWEEN ... や Oracle の TRUNC(ordered_at) = ... と書く方法もあります。結果は正しくなりますが、列を関数で包むと、その列の普通のインデックスが範囲検索に使われなくなります。
インデックスは、本の索引のようなものです。ordered_at の値の順に並んだ索引があっても、date(ordered_at) の値で探すと、索引を引けません。
SQLite の EXPLAIN QUERY PLAN(どうやって探すかの計画を表示する命令)で比べました。
t3_index_format.py(抜粋):
con.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, item TEXT, ordered_at TEXT)")
con.execute("CREATE INDEX idx_orders_ordered_at ON orders (ordered_at)")
print("--- 関数で包んだ場合の実行計画")
for r in con.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders "
"WHERE date(ordered_at) BETWEEN '2026-10-01' AND '2026-10-31'"
):
print(r[-1])
print("--- 列をそのまま比べた場合の実行計画")
for r in con.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders "
"WHERE ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01'"
):
print(r[-1])
--- 関数で包んだ場合の実行計画
SCAN orders
--- 列をそのまま比べた場合の実行計画
SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at>? AND ordered_at<?)
(今回の検証環境の SQLite 3.45.3 で、t3_index_format.py を実行した結果のうち、この部分だけを抜き出しています)
SCAN orders:表を頭から全部読むSEARCH ... USING INDEX:索引を使って、範囲の部分だけ読む
計画の表示は ordered_at>? になっていますが、書いた条件は >= です(今回の検証環境の表示をそのまま載せています)。
店長「10月の伝票、出して」
関数で包んだ版「伝票の山を、1枚目から全部めくります」
半開区間の版「10月の棚から取ってきました」
……どちらも正解を持ってきますが、伝票が100万枚になると、片方だけ帰ってきません。
Oracle のドキュメントにも、時刻を含む DATE の列を日付で探すときは、TRUNC(datecol) = DATE '2002-10-03' のように時刻を切り捨てるか、等号のかわりに大なり・小なりの条件を使う、と書かれています。 どちらも結果は正しいので、インデックスを使いたい表では、大なり・小なりのほうを選ぶ、という使い分けです。
関数で包むのが分かりやすいから、全部それでいこう。
小さな表なら、それで困りません。 1年後に表が大きくなったとき、「なんか月末だけ遅い」の原因になるだけです。
中級:完成コード|月の範囲を作って集計する(Python+SQLite)
アプリから集計するなら、範囲の計算はプログラム側で「初日」と「翌月の初日」を作り、SQL にはパラメータで渡すのが安全です。
monthly_sales.py:
import sqlite3
from datetime import date
def month_range(year: int, month: int) -> tuple[str, str]:
"""その月の初日と、翌月の初日を返す(半開区間 [start, end) 用)"""
start = date(year, month, 1)
end = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
return start.isoformat(), end.isoformat()
def monthly_sales(con: sqlite3.Connection, year: int, month: int) -> list[tuple]:
start, end = month_range(year, month)
sql = """
SELECT item, count(*) AS cups
FROM orders
WHERE ordered_at >= ?
AND ordered_at < ?
GROUP BY item
ORDER BY cups DESC, item
"""
return con.execute(sql, (start, end)).fetchall()
if __name__ == "__main__":
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, item TEXT, ordered_at TEXT)")
con.execute("CREATE INDEX idx_orders_ordered_at ON orders (ordered_at)")
con.executemany(
"INSERT INTO orders (item, ordered_at) VALUES (?, ?)",
[
("きつねうどん", "2026-10-01 00:00:00"),
("ざるうどん", "2026-10-15 12:30:00"),
("かぼちゃうどん", "2026-10-31 00:00:00"),
("かぼちゃうどん", "2026-10-31 12:10:00"),
("かぼちゃうどん", "2026-10-31 23:59:59.500"),
("月見うどん", "2026-11-01 00:00:00"),
("年越しうどん", "2026-12-31 23:30:00"),
],
)
print("範囲:", month_range(2026, 10), month_range(2026, 12))
for item, cups in monthly_sales(con, 2026, 10):
print(f"{item}: {cups}杯")
print("12月:", monthly_sales(con, 2026, 12))
範囲: ('2026-10-01', '2026-11-01') ('2026-12-01', '2027-01-01')
かぼちゃうどん: 3杯
きつねうどん: 1杯
ざるうどん: 1杯
12月: [('年越しうどん', 1)]
(今回の検証環境の Python 3.13.1・SQLite 3.45.3 で、python -X utf8 monthly_sales.py で実行した結果です)
ポイントです。
- 月末の日(30日か31日か、うるう年の2月か)を一切計算していない。翌月の1日だけを作る
- 12月は翌年の1月1日にする(ここを忘れると、存在しない13月を作ろうとしてしまいます)
- 値は
?のパラメータで渡す。日付の文字列を SQL に直接つなげない
ほかの DB で同じ条件を書くと、こうなります(どれも未実行の例です。各 DB のドキュメントの書き方に基づきます)。
-- Oracle(DATE 型や TIMESTAMP 型の列) WHERE ordered_at >= DATE '2026-10-01' AND ordered_at < DATE '2026-11-01' -- PostgreSQL(timestamp 型の列) WHERE ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01' -- SQL Server(datetime2 型の列) WHERE ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01'
かぼちゃうどん「3杯、ちゃんと数えてもらえました!」
年越しうどん「ぼくは12月31日の23時30分ですが、大丈夫ですか」
month_range「翌年の1月1日未満なので、大丈夫です」
……年越しうどんが、年を越さずに済みました。
月末の日を計算する関数、苦労して作ったのに。
その関数は、カレンダーを表示するときに活躍させてあげてください。 範囲検索には、翌月1日という、もっと怠けられる道があります。
ここから先は上級者向け。読み飛ばしてもOKです。
上級:DB ごとに「日付だけ」の中身が違う
同じ '2026-10-31' でも、比べる列の型と DB によって、「0時の時刻として比べる」のか「文字列として比べる」のかが変わります。ここを押さえると、A の結果が DB によって少し違う理由が分かります。
- Oracle:DATE 型の列は、日付だけでなく時刻も持てます。日付リテラルのドキュメントでも、0時以外の時刻が入っている DATE の列を探すときの注意が書かれています。名前が DATE なのに時刻がある、という点で特に事故が起きやすい型です。日付リテラル
DATE '2026-10-31'は時刻を持たず、0時0分0秒として扱われます - SQL Server:
datetimeは小数秒を .000・.003・.007 秒の刻みに丸める。datetime2はもっと細かい小数秒を持てる - SQLite:日付・時刻の専用の型がありません。SQLite の日付と時刻の関数のドキュメントによると、日時は ISO-8601 形式の文字列、ユリウス日(数値)、Unix 時間(整数)のどれかで保存します。今回のように文字列で入れた場合、比較は文字列どうしの比較です
BETWEENさん「Oracle では DATE と書いてあるのに、時刻まで持っていました」
店長「名札に『日付』って書いてあるのに?」
BETWEENさん「名札は、創業当時のままなんです」
……名札と中身が違う社員、どこの職場にもいます。
SQLite の文字列比較の話に戻ると、A で0時ちょうどの1杯まで消えたのはこういう理由です。
n1_note.py(抜粋):
print("'2026-10-31 12:10:00' <= '2026-10-31' :", "2026-10-31 12:10:00" <= "2026-10-31")
'2026-10-31 12:10:00' <= '2026-10-31' : False
(今回の検証環境の Python 3.13.1 で実行した結果のうち、この行だけを抜き出しています。Python の文字列比較で、SQLite の既定の文字列比較と同じ前から1文字ずつ比べる動きを確かめたものです)
文字列の比較では、'2026-10-31' まで同じで、短いほうが小さいとみなされます。 だから '2026-10-31 00:00:00' でさえ '2026-10-31' より大きい、つまり範囲の外です。
日付の型を持つ DB と同じ比べ方にしたいなら、SQLite では datetime() で、比べる値を0時の時刻付きにそろえます。
t2_midnight.py(抜粋):
print("datetime('2026-10-31') =", con.execute("SELECT datetime('2026-10-31')").fetchone()[0])
print("--- datetime() にそろえて BETWEEN(日付型のDBと同じく、日付だけ=0時0分0秒)")
for r in con.execute(
"SELECT id, ordered_at FROM orders "
"WHERE ordered_at BETWEEN datetime('2026-10-01') AND datetime('2026-10-31') ORDER BY id"
):
print(*r)
datetime('2026-10-31') = 2026-10-31 00:00:00
--- datetime() にそろえて BETWEEN(日付型のDBと同じく、日付だけ=0時0分0秒)
1 2026-10-31 00:00:00
(今回の検証環境で、t2_midnight.py を実行した結果のうち、この部分だけを抜き出しています。この表には 10/31 0:00・10/31 12:10・11/1 0:00・11/1 8:00 の4件が入っています)
0時ちょうどの1杯だけが入り、12時10分の1杯は外れました。 日付の型を持つ DB での、A の結果はこの形になるはずです(Oracle・PostgreSQL では未実行。ドキュメントの BETWEEN と日付リテラルの説明に基づきます)。
文字列で日付を保存するなんて、SQLite さん、雑すぎない?
SQLite「そのかわり、型で迷う日はありません」
……迷わないかわりに、書式を守る責任は全部こっちに来ます。それが次の章です。
上級:SQLite は文字列で比べるから、書式が混ざると順番が崩れる
文字列で比べるということは、書式がそろっていれば日時の順と文字列の順が一致し、そろっていなければ一致しない、ということです。
よくあるのが、ISO-8601 の区切りの T(2026-10-31T09:00:00)と空白(2026-10-31 09:00:00)の混在です。 どちらも SQLite の日付と時刻の関数は受け付けますが、文字列としては別物です。
t3_index_format.py(抜粋):
con.executemany(
"INSERT INTO orders (item, ordered_at) VALUES (?, ?)",
[
("かぼちゃうどん", "2026-10-31 09:00:00"),
("かぼちゃうどん", "2026-10-31T09:00:00"),
("かぼちゃうどん", "2026-10-31 11:00:00"),
],
)
print("10時以降を探す(文字列のまま比べる)")
for r in con.execute(
"SELECT id, ordered_at FROM orders "
"WHERE ordered_at >= '2026-10-31 10:00:00' AND ordered_at < '2026-11-01' ORDER BY id"
):
print(*r)
print("10時以降を探す(datetime() でそろえて比べる)")
for r in con.execute(
"SELECT id, ordered_at FROM orders "
"WHERE datetime(ordered_at) >= '2026-10-31 10:00:00' "
"AND datetime(ordered_at) < '2026-11-01 00:00:00' ORDER BY id"
):
print(*r)
10時以降を探す(文字列のまま比べる)
2 2026-10-31T09:00:00
3 2026-10-31 11:00:00
10時以降を探す(datetime() でそろえて比べる)
3 2026-10-31 11:00:00
(今回の検証環境の SQLite 3.45.3 で、t3_index_format.py を実行した結果のうち、この部分だけを抜き出しています)
9時の T 区切りの行が、10時以降として出てきました。 11文字目で T と空白を比べると、T のほうが大きい文字だからです。
かぼちゃうどん(T区切り)「9時に来ましたが、10時以降の列に並んでいます」
BETWEENさん「Tの字が、背伸びしているんです」
……1文字の背伸びで、1時間の行列を抜かしている。
datetime(ordered_at) でそろえれば正しくなりますが、それは前の章の「関数で包むとインデックスが使われない」に戻ってしまいます。 根本的には、保存するときに書式を1つに決めておくのが一番です。
もう1つ、月の計算の小ネタです。SQLite の '+1 month' は、月末の日から足すと翌々月にはみ出します。
--- 月の加算
2026-10-01 +1 month -> 2026-11-01
2026-01-31 +1 month -> 2026-03-03
2026-10-31 start of month +1 month -> 2026-11-01
(今回の検証環境の SQLite 3.45.3 で、SELECT date(?, '+1 month') などを実行した結果です。t3_index_format.py の最後の部分)
1月31日の1か月後は、2月31日……は存在しないので、3月3日になりました。 翌月の初日を作るときは、'start of month' で月の1日に戻してから '+1 month' を足すと、はみ出しません。
1月31日に「1か月後に会おうね」と約束したら、3月3日に来た。
ひな祭りです。 約束の日付は、1日から数えるのが安全です。
上級:BETWEEN が安全な場面と、逆向きの範囲
BETWEENさんの名誉のために。値がとびとびで、両端を含めたいとき(整数、時刻のない日付の型)なら、BETWEEN は読みやすくて正しい書き方です。
- 時刻を持たない日付だけの型(PostgreSQL や SQL Server の
date型など)の列なら、BETWEEN '2026-10-01' AND '2026-10-31'で10月が全部入る - 整数の範囲(
id BETWEEN 1 AND 100)も問題なし - 時刻付きの列(Oracle の DATE、
timestamp、datetime、datetime2、SQLite の時刻付き文字列)では、半開区間で書く
ただし、範囲を逆に書くと何も出てきません。 Oracle の BETWEEN 条件のドキュメントでも、終わりの値が始まりより小さいと、範囲は空になり結果は FALSE、とされています。
--- 範囲を逆に書いた BETWEEN
0 件
(今回の検証環境で、BETWEEN '2026-11-01' AND '2026-10-01' を SQLite で実行した結果です。t2_midnight.py の最後の部分)
PostgreSQL には、両端を自動で並べ替える BETWEEN SYMMETRIC があります。PostgreSQL 18 の比較演算子のドキュメントでは、2 BETWEEN 3 AND 1 は偽、2 BETWEEN SYMMETRIC 3 AND 1 は真、という例が載っています。
BETWEENさん「わたしは、小さいほうから言ってもらわないと困ります」
店長「10月31日から10月1日まで、お願い」
BETWEENさん「そんな期間は、ありません」
……時をさかのぼる集計は、BETWEENさんの業務範囲外です。
画面で「開始日」と「終了日」を入力させるアプリなら、入れ替えて入力されることを考えて、プログラム側で小さいほうを開始にそろえておくと親切です。
SYMMETRIC があるなら、全部の DB に付けてほしい。
付いている DB も、付いていない DB もあります。 付いていない DB で書くとエラーになるので、使う前にその DB のドキュメントを確認してください。
チェックリスト
- 期間で探している列に、時刻が入っているか確認した
- 時刻付きの列は
>= 開始 AND < 終了の翌日で書いた 23:59:59や.999で締める書き方をしていない- 日ごと・月ごとの集計で、0時ちょうどの行が2回数えられていない
- 列を
date()・TRUNC()などで包まず、列をそのまま比べている(インデックスを使いたい表) - 範囲はプログラム側で「初日」と「次の期間の初日」を作り、パラメータで渡している
- SQLite では日時の書式(
T区切りか空白か、小数秒の有無)を1つにそろえている - 開始と終了が逆に入力されたときの扱いを決めている
8個。かぼちゃうどんの仕込みより多い。
全部やらなくても大丈夫です。 今日の売上表を直すなら、上の2つだけで足ります。
チェックを全部付けたら、BETWEENさんとはお別れ?
お別れではありません。 時刻のない列では、これからもBETWEENさんが一番読みやすい書き方です。
まとめ
冒頭では、ハロウィン当日のかぼちゃうどんが、10月の売上から消えていました。
- 時刻付きの列に日付だけを書くと、その日の0時0分0秒として比べられる
- BETWEEN は両端を含む「以上・以下」。終わりの日は、0時ちょうどまでしか入らない
23:59:59で締めると小数秒が漏れ、SQL Server のdatetimeで.999を書くと翌日に丸められる- 期間検索は
>= 開始 AND < 終了の翌日の半開区間で書く。日ごとの集計でも、すき間も重なりもない - 列を関数で包むと、普通のインデックスが範囲検索に使われない
- SQLite は日時を文字列のまま比べる。書式をそろえる
- BETWEEN は、時刻のない日付や整数なら安全。範囲を逆に書くと0件
かぼちゃうどん「3杯とも、10月の売上に入れてもらえました」
BETWEENさん「わたし、今回はお休みでしたね」
店長「来月の1日から10日までの、日付だけの列の集計は、お願いするね」
BETWEENさん「……そういうのを待ってました」
……適材適所。BETWEENさんにも、ちゃんと出番はあります。
来年は、ハロウィンを11月1日の0時ちょうどにやれば全部解決では?
それだと、BETWEEN で書いた日ごとの集計では、10月31日と11月1日の両方に載ります。 仮装したまま、2日分の売上に出演です。
期間の終わりは「その日まで」ではなく「次の日になる前まで」と書く。これだけで、月末の売上表から何かが消える日は来なくなります。
まずは今日、自分の SQL の中から BETWEEN を1つ探して、その列に時刻が入っているかだけ確かめてみてください。 入っていたら、< 翌日 に書き換える。それで来年のハロウィンも、かぼちゃうどんは冷めずに売上表に並びます🍜
参考資料
- Oracle Database 19c SQL Language Reference:BETWEEN Condition — BETWEEN が
expr2 <= expr1 AND expr1 <= expr3と同じであること、終わりが始まりより小さいと範囲が空で FALSE になること - Oracle Database 19c SQL Language Reference:Literals — 日付リテラルは時刻を持たず、時刻を指定しない日付は真夜中(00:00:00)になること、0時以外の時刻が入っている DATE の列は TRUNC で切り捨てるか大なり・小なりで比べること
- PostgreSQL 18:Comparison Functions and Operators — BETWEEN が
a >= x AND a <= yと同じであること、BETWEEN SYMMETRIC の例 - Microsoft Learn:datetime (Transact-SQL) — 小数秒が .000・.003・.007 秒の刻みに丸められ、23:59:59.999 が翌日の 00:00:00.000 になる例、新しく作るものには datetime2 などを使うこと
- SQLite:Date And Time Functions — 日時を ISO-8601 の文字列・ユリウス日・Unix 時間で保存すること、日付だけの値は 00:00:00 になること、
start of day・+1 dayなどの修飾子
確認日:2026-10-03。

コメント