TL;DR
-
datetime.utcnow().timestamp()は、日本時間(JST)のマシンでは 32,400秒(9時間)過去 の値になります。naive な datetime はローカル時刻として解釈されるためです。 - naive と aware の datetime は、
<で比べると TypeError、==で比べると 例外なしで False になります。 -
replace(tzinfo=...)は時刻を変換せず、ラベルを貼り替えるだけです。UTC の値に JST を貼ると9時間ずれます。 - 夏時間のある地域では、同じタイムゾーンどうしの
+ timedelta(days=1)が「壁時計で1日後」になり、実際の経過時間は25時間でした。 - LLM API のレート制限の解除時刻(Unix 秒)を naive どうしで計算すると、JST のマシンでは「1分後」が541分後になりました。
Z付きの期限から tz を外してdatetime.now()と比べると、残り2時間が -7時間(期限切れ)と判定されました。 - システムの tz データベースも PyPI の
tzdataパッケージも無い環境では、ZoneInfo("Asia/Tokyo")がZoneInfoNotFoundErrorになります。tzdataを依存に入れれば動き、夏時間の無い JST だけなら固定オフセットでも足ります。 - Python 3.13.5(Debian 13)で再現と修正を実行し、最後に「平日13:15(JST)の次回実行時刻」を求める関数を、上の落とし穴を避けて組みました。
定時投稿やバッチの予約、API の締切チェックなど、AIエージェントの運用でも日時の計算はあちこちに出てきます。サーバーは UTC、開発機は JST、API のレスポンスは Z 付きの ISO 8601 という組み合わせはよくあり、そこで「9時間ずれる」不具合が起きやすくなります。この記事では、実際に踏みやすい5つを再現し、直し方と実行結果を並べます。
実行環境
$ python3 --version
Python 3.13.5
OS のタイムゾーンによって結果が変わる例があるので、各コマンドの先頭に TZ=Asia/Tokyo または TZ=UTC を付けて実行しています。
全体像:naive と aware
Python の公式ドキュメントは、tzinfo を持たない datetime を naive、持つものを aware と呼び分けています。naive な値は「どのタイムゾーンの時刻か」の情報を持たないので、多くのメソッドがローカル時刻として扱います。以下の落とし穴は、ほとんどがこの区別から来ています。
落とし穴1:utcnow() の timestamp が9時間ずれる
「UTC の現在時刻」のつもりで datetime.utcnow() を使うと、返ってくるのは tzinfo の無い naive な値です。
import time, warnings
from datetime import datetime, timezone
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always") # DeprecationWarning を記録する
naive = datetime.utcnow() # tzinfo なしの「UTCのつもり」
aware = datetime.now(timezone.utc) # tzinfo=UTC 付き
print("警告 :", caught[0].category.__name__)
print("time.time() :", int(time.time()))
print("utcnow().timestamp :", int(naive.timestamp()))
print("now(utc).timestamp :", int(aware.timestamp()))
print("差(秒) :", int(aware.timestamp() - naive.timestamp()))
$ TZ=Asia/Tokyo python3 case1_utcnow.py
警告 : DeprecationWarning
time.time() : 1792976259
utcnow().timestamp : 1792943859
now(utc).timestamp : 1792976259
差(秒) : 32400
$ TZ=UTC python3 case1_utcnow.py
警告 : DeprecationWarning
time.time() : 1792976259
utcnow().timestamp : 1792976259
now(utc).timestamp : 1792976259
差(秒) : 0
JST では差がちょうど32,400秒(9時間×3,600秒)になり、UTC では0でした。ドキュメントにあるとおり、timestamp() は naive な値をローカル時刻とみなして 変換します。UTC の時刻なのに JST として解釈されるので、9時間過去の値になります。サーバー(UTC)では正しく動き、手元(JST)でだけずれるので、テストをすり抜けやすい不具合です。
utcnow() は Python 3.12 で非推奨になり、実行すると DeprecationWarning が出ます(上のスクリプトでは警告を記録して、種類だけを表示しています)。
直し方:現在時刻は最初から aware で作ります。
import time
from datetime import UTC, datetime
aware = datetime.now(UTC) # Python 3.11 以降は datetime.UTC が使える
print("time.time() :", int(time.time()))
print("now(UTC).timestamp :", int(aware.timestamp()))
print("isoformat :", aware.isoformat(timespec="seconds"))
$ TZ=Asia/Tokyo python3 fix1_aware.py
time.time() : 1792976259
now(UTC).timestamp : 1792976259
isoformat : 2026-10-26T00:57:39+00:00
JST のマシンでも time.time() と一致し、isoformat() に +00:00 が付きました。同じ理由で utcfromtimestamp() も非推奨なので、datetime.fromtimestamp(ts, UTC) に置き換えます。
落とし穴2:naive と aware を比べると、< は例外・== は常に False
API から来た締切(aware)と、datetime.now()(naive)を比べるケースです。
from datetime import datetime, timezone
deadline = datetime.fromisoformat("2026-10-27T04:15:00+00:00") # APIから来たaware
now = datetime.now() # naive
try:
print(now < deadline)
except TypeError as e:
print("TypeError:", e)
try:
print(datetime.now() == deadline)
except TypeError as e:
print("TypeError:", e)
$ TZ=Asia/Tokyo python3 case2_compare.py
TypeError: can't compare offset-naive and offset-aware datetimes
False
大小比較は例外で止まるのでまだ気づけます。厄介なのは == で、例外を出さずに False を返します。ドキュメントにも「naive と aware の大小比較は TypeError」「naive と aware は等しくならない」とあります。重複チェックや「同じ時刻なら処理済み」といった判定に == を使っていると、条件が一度も成り立たないまま静かに動き続けます。
直し方:比較の前に両方を aware にそろえます。
from datetime import UTC, datetime
deadline = datetime.fromisoformat("2026-10-27T04:15:00+00:00")
now = datetime.now(UTC) # aware どうしで比べる
print("now < deadline :", now < deadline)
print("締切まで :", deadline - now)
print("JSTでの締切 :", deadline.astimezone().isoformat()) # 引数なしはOSのローカルtz
$ TZ=Asia/Tokyo python3 fix2_compare.py
now < deadline : True
締切まで : 1 day, 3:17:20.124816
JSTでの締切 : 2026-10-27T13:15:00+09:00
aware どうしなら、タイムゾーンが違っても同じ瞬間として比べられ、差も正しく出ます。なお、Python 3.11 から fromisoformat() は末尾の Z も読めるようになりました(What's New In Python 3.11、後述の next_run.py で確認しています)。
実例:LLM API が返す時刻と比べる
LLM の API でも、時刻はよく2つの形で返ってきます。レート制限の解除時刻のような Unix 秒 と、バッチ処理の期限のような Z 付きの ISO 8601 です。どちらも naive な現在時刻と混ぜると、落とし穴1・2がそのまま起きます。ここでは「60秒後に解除」「2時間後が期限」という応答を作って計算しました。
"""LLM API が返す時刻(Unix 秒と Z 付き ISO 8601)を、現在時刻と比べる。"""
import time, warnings
from datetime import UTC, datetime, timedelta
warnings.filterwarnings("ignore", category=DeprecationWarning) # utcnow() の警告は落とし穴1で確認済み
# レート制限の解除時刻(Unix 秒)。「60秒後に解除」という応答を作る
reset = int(time.time()) + 60
wrong = datetime.fromtimestamp(reset) - datetime.utcnow() # naive どうし
right = datetime.fromtimestamp(reset, UTC) - datetime.now(UTC) # aware どうし
print(f"解除まで(naive) : {wrong.total_seconds() / 60:.1f} 分")
print(f"解除まで(aware) : {right.total_seconds() / 60:.1f} 分")
# バッチの期限(Z 付き ISO 8601)。「2時間後が期限」という応答を作る
expires_at = (datetime.now(UTC) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
deadline = datetime.fromisoformat(expires_at) # Z 付きは aware になる
wrong_left = deadline.replace(tzinfo=None) - datetime.now() # TypeError を避けて tz を外す
right_left = deadline - datetime.now(UTC)
print(f"期限まで(tzを外す) : {wrong_left.total_seconds() / 3600:.1f} 時間")
print(f"期限まで(now(UTC)) : {right_left.total_seconds() / 3600:.1f} 時間")
$ TZ=Asia/Tokyo python3 llm_api_times.py
解除まで(naive) : 541.0 分
解除まで(aware) : 1.0 分
期限まで(tzを外す) : -7.0 時間
期限まで(now(UTC)) : 2.0 時間
$ TZ=UTC python3 llm_api_times.py
解除まで(naive) : 1.0 分
解除まで(aware) : 1.0 分
期限まで(tzを外す) : 2.0 時間
期限まで(now(UTC)) : 2.0 時間
JST のマシンでは、naive どうしの計算で解除までが541分(9時間+1分)になりました。fromtimestamp(reset) は JST の時刻を、utcnow() は UTC の時刻を返すので、差に9時間が混ざります。この値で待つと、1分で済むところを9時間以上待つことになります。期限のほうは、TypeError を避けようと replace(tzinfo=None) で tz を外して datetime.now() と比べた結果、残り2時間なのに -7.0 時間(期限切れ)と判定されました。どちらも TZ=UTC では正しい値になるので、UTC のサーバーと JST の手元で結果が変わります。aware どうし(fromtimestamp(ts, UTC) と datetime.now(UTC))で計算すれば、OS のタイムゾーンによらず 1.0 分と 2.0 時間になりました。
落とし穴3:replace(tzinfo=) は変換しない
DB やログから読んだ「UTC のつもりの naive な値」を JST で表示したいとき、replace を使うと9時間ずれます。
from datetime import datetime
from zoneinfo import ZoneInfo
tokyo = ZoneInfo("Asia/Tokyo")
utc_naive = datetime(2026, 10, 27, 4, 15) # DBから来た「UTCのつもり」のnaive値
wrong = utc_naive.replace(tzinfo=tokyo) # 変換せずにラベルだけ貼る
right = utc_naive.replace(tzinfo=ZoneInfo("UTC")).astimezone(tokyo)
print("replace :", wrong.isoformat())
print("astimezone:", right.isoformat())
print("ずれ :", right - wrong)
$ TZ=Asia/Tokyo python3 case3_replace.py
replace : 2026-10-27T04:15:00+09:00
astimezone: 2026-10-27T13:15:00+09:00
ずれ : 9:00:00
replace(tzinfo=tokyo) は、時刻の 04:15 はそのままで「これは JST です」というラベルを付けるだけです。その結果、UTC 04:15(JST 13:15)だったはずの値が JST 04:15 になりました。
直し方:replace(tzinfo=) を使ってよいのは、その値が本当はどのタイムゾーンの時刻か を付けるときだけです。上の例なら、まず replace(tzinfo=UTC) で「UTC の時刻」と明示し、そのあと astimezone(tokyo) で JST に変換します。表示用のタイムゾーンへの変換は、必ず astimezone を使います。
落とし穴4:夏時間のある地域では、+ timedelta(days=1) が25時間になる
日本には夏時間がありませんが、海外の利用者向けの予約や、米国の API の締切を扱うと出てきます。米国の夏時間は2026年11月1日に終わります。
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
start = datetime(2026, 10, 31, 9, 0, tzinfo=ny) # 米国の夏時間終了は 2026-11-01
nxt = start + timedelta(days=1)
print("start :", start.isoformat())
print("+1 day :", nxt.isoformat())
print("+24 hours :", (start + timedelta(hours=24)).isoformat())
print("実経過時間 :", nxt.astimezone(timezone.utc) - start.astimezone(timezone.utc))
print("同じtzの差 :", nxt - start)
print("違うtzの差 :", nxt - start.astimezone(timezone.utc))
# 存在しない時刻(2026-03-08 02:30 は夏時間開始で飛ばされる)
gap = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
print("存在しない :", gap.isoformat(), "-> UTC往復:", gap.astimezone(timezone.utc).astimezone(ny).isoformat())
# 2回ある時刻(2026-11-01 01:30)
for fold in (0, 1):
t = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=fold)
print(f"fold={fold} :", t.isoformat(), t.tzname())
$ TZ=Asia/Tokyo python3 case4_dst.py
start : 2026-10-31T09:00:00-04:00
+1 day : 2026-11-01T09:00:00-05:00
+24 hours : 2026-11-01T09:00:00-05:00
実経過時間 : 1 day, 1:00:00
同じtzの差 : 1 day, 0:00:00
違うtzの差 : 1 day, 1:00:00
存在しない : 2026-03-08T02:30:00-05:00 -> UTC往復: 2026-03-08T03:30:00-04:00
fold=0 : 2026-11-01T01:30:00-04:00 EDT
fold=1 : 2026-11-01T01:30:00-05:00 EST
分かったことは3つです。
- aware な datetime に timedelta を足すと 壁時計の時刻 で計算され、同じ tzinfo どうしの引き算も壁時計の差になります。
+1 dayも+24 hoursも結果は翌日の 9:00 で、UTC に直すと実際には25時間たっています。同じ tz どうしで引き算すると「1日」と表示され、ずれが見えません。tzinfo が違う値どうし(ここでは片方を UTC に直した値)の引き算は UTC に直して計算されるので、1 day, 1:00:00(25時間)と出ました。 - 夏時間の開始で飛ばされる 02:30 のような 存在しない時刻 も、エラーにならずに作れます。UTC を経由して戻すと 03:30 に変わりました。
- 夏時間の終了で 2回ある時刻(01:30)は、
fold=0なら先の EDT(-04:00)、fold=1なら後の EST(-05:00)になります。foldは PEP 495 で入った属性です。
直し方:「毎日 9:00」のような予定は、経過時間ではなく 日付+壁時計の時刻 で組み立てます。逆に「24時間後」のように経過時間が大事なら、UTC で計算してから現地時刻に直します。
"""夏時間のある地域で「毎日 9:00」を作るなら、+24時間ではなく日付+壁時計で組み立てる。"""
from datetime import datetime, time, timedelta, UTC
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
start = datetime(2026, 10, 31, 9, 0, tzinfo=ny)
via_utc = (start.astimezone(UTC) + timedelta(hours=24)).astimezone(ny) # 経過時間で24h後
via_wall = datetime.combine(start.date() + timedelta(days=1), time(9, 0), tzinfo=ny)
print("UTCで+24h :", via_utc.isoformat())
print("壁時計9:00:", via_wall.isoformat())
$ TZ=Asia/Tokyo python3 wallclock_daily.py
UTCで+24h : 2026-11-01T08:00:00-05:00
壁時計9:00: 2026-11-01T09:00:00-05:00
どちらが正しいかは用途で決まります。「ちょうど24時間後に再試行」なら上、「毎朝9時に送信」なら下です。どちらのつもりで書いたのかを、コード上で区別できるようにしておくのが大事です。
落とし穴5:tz データベースの無いコンテナでは ZoneInfo が失敗する
zoneinfo はタイムゾーンのデータを自分では持たず、OS の tz データベース(/usr/share/zoneinfo など)を探し、見つからなければ PyPI の tzdata パッケージにフォールバックします。そのため、この落とし穴は システムのデータが無く、tzdata パッケージもインストールされていない環境で 起きます。ドキュメントによると、PYTHONTZPATH="" を指定するとシステムのデータを使わない設定になります。ただ、手元の python3 には tzdata が入っていたので、それだけでは再現しませんでした(下の2つ目のコマンド)。そこで、追加のパッケージが見えない venv を --without-pip で作って確かめました。
import zoneinfo
print("TZPATH:", zoneinfo.TZPATH)
try:
print(zoneinfo.ZoneInfo("Asia/Tokyo"))
except zoneinfo.ZoneInfoNotFoundError as e:
print("ZoneInfoNotFoundError:", e)
$ python3 case5_tzdata.py
TZPATH: ('/usr/share/zoneinfo', '/usr/lib/zoneinfo', '/usr/share/lib/zoneinfo', '/etc/zoneinfo')
Asia/Tokyo
$ PYTHONTZPATH="" python3 case5_tzdata.py
TZPATH: ()
Asia/Tokyo
$ python3 -c 'import importlib.metadata as m; print(m.version("tzdata"))'
2026.5
$ python3 -m venv --without-pip clean && PYTHONTZPATH="" clean/bin/python case5_tzdata.py
TZPATH: ()
ZoneInfoNotFoundError: 'No time zone found with key Asia/Tokyo'
$ python3 -m venv .venv && .venv/bin/pip install -q tzdata
$ .venv/bin/python -c 'import importlib.metadata as m; print(m.version("tzdata"))'
2026.5
$ PYTHONTZPATH="" .venv/bin/python case5_tzdata.py
TZPATH: ()
Asia/Tokyo
システムのデータも tzdata も無い venv では ZoneInfoNotFoundError になり、venv に tzdata を入れると、同じ条件でも Asia/Tokyo を読めました。ドキュメントは、システムにデータが無ければ tzdata にフォールバックすること、クロスプラットフォームで動かすなら tzdata を依存に宣言することを勧めています。手元では動くのに、軽量なコンテナイメージにしたとたん起動時に落ちる、という形で出やすい不具合です。
直し方:requirements.txt や pyproject.toml の依存に tzdata を入れておきます。コンテナなら、OS 側に tz データを入れる方法もあります。Debian / Ubuntu 系のイメージでは、apt-get install -y tzdata で入る tzdata パッケージが /usr/share/zoneinfo を提供しています(手元の Debian 13 で確認)。
$ dpkg -S /usr/share/zoneinfo/Asia/Tokyo
tzdata: /usr/share/zoneinfo/Asia/Tokyo
夏時間の無い JST だけを扱うなら、timezone(timedelta(hours=9)) の固定オフセットでも足ります。tz データを使わないので、上で ZoneInfoNotFoundError が出た venv でも動きました。夏時間のある地域には使えないので、そこでは ZoneInfo と tzdata を使います。
"""夏時間の無い JST だけなら、固定オフセットで tz データなしに扱える。"""
from datetime import datetime, timedelta, timezone
JST = timezone(timedelta(hours=9), "JST")
t = datetime(2026, 10, 27, 4, 15, tzinfo=timezone.utc).astimezone(JST)
print(t.isoformat(), t.tzname())
$ PYTHONTZPATH="" clean/bin/python fixed_offset.py
2026-10-27T13:15:00+09:00 JST
仕上げ:平日13:15(JST)の次回実行時刻を求める
ここまでの対策をまとめて、定時ジョブの「次回実行時刻」を求める関数を書きました。AIエージェントを毎営業日の決まった時刻に動かす、といった予約実行で使う計算です。
- naive な値は受け付けない(落とし穴1・2)
- 比較の前に
astimezoneで現地時刻に変換する(落とし穴3) - 候補は
datetime.combineで日付+壁時計の時刻から作る(落とし穴4) - 祝日は対象外(必要なら祝日リストで除外する)
"""平日 13:15(Asia/Tokyo)の次回実行時刻を、awareなdatetimeだけで求める。
祝日は対象外(必要なら祝日リストで除外する)。
"""
from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo
TOKYO = ZoneInfo("Asia/Tokyo")
RUN_AT = time(13, 15)
def next_run(now: datetime) -> datetime:
if now.tzinfo is None:
raise ValueError("naive datetime は受け付けない(tzinfo を付けて渡す)")
local = now.astimezone(TOKYO) # 比較の前に必ず現地時刻へ変換
day = local.date()
while True:
cand = datetime.combine(day, RUN_AT, tzinfo=TOKYO) # 壁時計の時刻で組み立てる
if cand.weekday() < 5 and cand > local:
return cand
day += timedelta(days=1)
if __name__ == "__main__":
cases = [
"2026-10-23T04:00:00+00:00", # 金 13:00 JST → 当日 13:15
"2026-10-23T04:15:00+00:00", # 金 13:15 JST ちょうど → 翌営業日
"2026-10-24T01:00:00+00:00", # 土 → 月曜
"2026-10-26T15:30:00+00:00", # 月 UTC 15:30 = 火 0:30 JST → 火 13:15
]
for s in cases:
now = datetime.fromisoformat(s)
r = next_run(now)
print(f"{s} -> {r.isoformat()} ({r:%a}) = {r.astimezone(UTC):%Y-%m-%dT%H:%MZ}")
print("Z 付きも読める:", datetime.fromisoformat("2026-10-27T04:15:00Z").isoformat())
try:
next_run(datetime(2026, 10, 23, 13, 0))
except ValueError as e:
print("ValueError:", e)
OS のタイムゾーンを JST と UTC に変えて、同じ結果になるかを確かめました。
$ TZ=Asia/Tokyo python3 next_run.py
2026-10-23T04:00:00+00:00 -> 2026-10-23T13:15:00+09:00 (Fri) = 2026-10-23T04:15Z
2026-10-23T04:15:00+00:00 -> 2026-10-26T13:15:00+09:00 (Mon) = 2026-10-26T04:15Z
2026-10-24T01:00:00+00:00 -> 2026-10-26T13:15:00+09:00 (Mon) = 2026-10-26T04:15Z
2026-10-26T15:30:00+00:00 -> 2026-10-27T13:15:00+09:00 (Tue) = 2026-10-27T04:15Z
Z 付きも読める: 2026-10-27T04:15:00+00:00
ValueError: naive datetime は受け付けない(tzinfo を付けて渡す)
$ TZ=UTC python3 next_run.py
2026-10-23T04:00:00+00:00 -> 2026-10-23T13:15:00+09:00 (Fri) = 2026-10-23T04:15Z
2026-10-23T04:15:00+00:00 -> 2026-10-26T13:15:00+09:00 (Mon) = 2026-10-26T04:15Z
2026-10-24T01:00:00+00:00 -> 2026-10-26T13:15:00+09:00 (Mon) = 2026-10-26T04:15Z
2026-10-26T15:30:00+00:00 -> 2026-10-27T13:15:00+09:00 (Tue) = 2026-10-27T04:15Z
Z 付きも読める: 2026-10-27T04:15:00+00:00
ValueError: naive datetime は受け付けない(tzinfo を付けて渡す)
4つのケースはどちらの OS 設定でも同じ結果になりました。UTC では月曜の15:30でも、JST では火曜の0:30なので、次回は火曜の13:15になります。曜日の判定を UTC の日付でしていると、ここで1日ずれます。
次回実行時刻や API の期限をログに残すときも、Z か +00:00 の付いた UTC の aware な値で書いておくと、JST の記録や API の応答とあとで並べて比べられます。実行ログを1行1件の JSONL で残す方法は、実行ログを JSONL に残すラッパーの記事にまとめています。
まとめ
| 落とし穴 | 起きること | 直し方 |
|---|---|---|
utcnow().timestamp() |
JST のマシンで9時間過去になる | datetime.now(UTC) |
| naive と aware の比較 |
< は TypeError、== は常に False |
両方 aware にそろえる |
replace(tzinfo=tokyo) |
時刻が変換されずにずれる | astimezone(tokyo) |
夏時間をまたぐ + timedelta
|
壁時計で計算され、実経過は25時間 | 用途で「UTCで加算」か「日付+壁時計」を選ぶ |
tz データも tzdata も無い環境 |
ZoneInfoNotFoundError |
依存に tzdata(コンテナなら OS の tzdata も可)。JST だけなら固定オフセット |
- 現在時刻は
datetime.now(UTC)で作り、naive な値はプログラムの入口で拒否する。 - API の Unix 秒は
fromtimestamp(ts, UTC)、Z付きの時刻はfromisoformat()で aware のまま読み、datetime.now(UTC)と比べる。 - タイムゾーンの変換は
astimezone。replace(tzinfo=)は「本来の tz を付ける」ときだけ使う。 - 予定時刻は日付+壁時計の時刻から組み立て、テストは OS の
TZを変えて2回流す。
出典
-
Python 3.13 ドキュメント: datetime — 基本的な日付型および時間型(aware と naive、
utcnow()の非推奨、timestamp()、比較の規則、fromisoformat()) -
Python 3.13 ドキュメント: zoneinfo — IANA タイムゾーンサポート(データの探し方、
PYTHONTZPATH、tzdata、fold) -
What's New In Python 3.11(datetime)(
fromisoformat()が ISO 8601 のほとんどの形式を読めるようになった変更) - PEP 495 – Local Time Disambiguation
- PEP 615 – Support for the IANA Time Zone Database in the Standard Library
- tzdata(PyPI)