SQLのBETWEENで日付の最終日が抜ける原因と対処|時刻付きの列は「>= 開始 AND < 翌日」で書く(Oracle・PostgreSQL・SQL Server・SQLite)

SQLのBETWEENで日付の最終日が抜ける原因と対処

こんにちは、おうどんです🍜

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つ探して、その列に時刻が入っているかだけ確かめてみてください。 入っていたら、< 翌日 に書き換える。それで来年のハロウィンも、かぼちゃうどんは冷めずに売上表に並びます🍜

参考資料

確認日:2026-10-03。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次