PythonのTypeError: can’t compare offset-naive and offset-aware datetimesの原因と対処|utcnow()の非推奨・astimezone・zoneinfoとtzdata

PythonのTypeError: can't compare offset-naive and offset-aware datetimesの原因と対処

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

朝9時。 うどん屋さんの電話が鳴る。

支店「9時に出前お願いします!」

店長(おうどん)「了解、9時ね」

支店「はい、9時です!」

店長「……どこの9時?」

(場面は説明用の架空のものです)

支店は海外にありました。 向こうの9時は、こっちの夜。 うどんは、誰もいない店に届きました。。。

これ、Python の datetime(日時を扱う標準ライブラリ)でも毎日のように起きています。 しかも Python は、うどん屋さんより慎重です。 「どこの9時か分からない時刻」と「どこの9時か分かる時刻」を比べようとすると、こう言って止まります。

TypeError: can't compare offset-naive and offset-aware datetimes

Python「比べられません。片方、どこの9時か書いてないので」

……出前は届けちゃう店長より、ずっと偉い。

今回は、このエラーの原因と直し方から始めて、utcnow() の非推奨、Windows で ZoneInfo が見つからない件、夏時間の落とし穴まで掘っていきます。

目次

結論:まずはこれだけ

先に答えを置いておきます。時刻には「どこの時刻か」の札を付けて、札付きどうしで比べる、です。

  • 今の時刻は datetime.now(timezone.utc) で取る(datetime.utcnow() は使わない)
  • 札のない時刻は、どこの時刻かを決めてから replace(tzinfo=...) で札を付ける
  • 別の地域の時刻に直すときは astimezone(...) を使う

全部の時刻を文字列にして比べれば、エラーは出ないのでは?

エラーは出ません。 そのかわり「09:00+09:00」と「00:30Z」を文字の順で並べて、本当の順番とは違う結果を、何食わぬ顔で返してきます。 エラーが出ないほうが、こわいんです。

じゃあ全部の時刻から札をはがせば、比べられる?

比べられます。 支店の9時と本店の9時が、同じ9時になります。 出前は、また誰もいない店に届きます。

naive と aware ってなに?

Python の日時には、札なしと札付きの2種類があります。札なし(naive)は「どこの9時か」を知らない時刻、札付き(aware)は知っている時刻です。

datetime のドキュメントでは、こう定義されています。

  • aware:ほかの aware な時刻との位置関係が決められる。解釈の余地のない、特定の瞬間を表す
  • naive:ほかの時刻と位置関係を決めるだけの情報を持っていない
  • datetime の d が aware なのは、d.tzinfo が None でなく、かつ d.tzinfo.utcoffset(d) が None を返さないとき

tzinfo(タイムゾーンの情報を持つ属性)が札、utcoffset(UTC からのずれ)が札に書いてある数字、と思ってください。 UTC は協定世界時、世界の時計の基準です。日本時間は UTC より9時間進んでいて、+09:00 と書きます。

札なしと札付きを混ぜると、どうなるか。

t1_mix.py:

from datetime import datetime, timezone, timedelta

JST = timezone(timedelta(hours=9))

naive = datetime(2026, 10, 1, 9, 0)             # タイムゾーンなし
aware = datetime(2026, 10, 1, 9, 0, tzinfo=JST)  # +09:00 付き

print("naive:", naive.isoformat(), naive.tzinfo)
print("aware:", aware.isoformat(), aware.tzinfo)
print("naive == aware:", naive == aware)

try:
    print(naive < aware)
except TypeError as e:
    print("比較:", e)

try:
    print(aware - naive)
except TypeError as e:
    print("引き算:", e)
python -X utf8 t1_mix.py
naive: 2026-10-01T09:00:00 None
aware: 2026-10-01T09:00:00+09:00 UTC+09:00
naive == aware: False
比較: can't compare offset-naive and offset-aware datetimes
引き算: can't subtract offset-naive and offset-aware datetimes

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

ドキュメントのとおりの動きです。

  • 大小の比較(< や >)は TypeError
  • 引き算も TypeError
  • == だけはエラーにならず、必ず False(Python 3.3 から、== では TypeError を出さなくなった)

札なしくん「9時です!」

札付きさん「9時です。+09:00 の」

Python「同じ9時ですか?」

札なしくん「たぶん!」

Python「……違うことにしておきます」

……たぶん、では同じにしてもらえない。

同じ9時って書いてあるのに False は、さすがに冷たくない?

札なしくんの9時は、東京の9時かもしれないし、ロンドンの9時かもしれません。 「かもしれない」を同じ扱いにすると、あとで出前が迷子になります。 Python は、疑わしきは別人、の方針なんです。

いちばん多い原因:オフセットのある文字列とない文字列が混ざる

実務でこのエラーに出会う一番の入口は、いろいろな場所から集めた時刻の文字列を、そのまま読み込んで並べたときです。

API(プログラム同士がやりとりする窓口)は +09:00 付き、クラウドのログは末尾に Z(UTC の意味)、古い CSV はオフセットなし。 よくある組み合わせを、fromisoformat()(ISO 8601 という日時の書式の文字列を読む関数)で読んでみます。

t2_sort.py:

from datetime import datetime

# ログから集めた時刻(説明用のデータ)
rows = [
    "2026-10-01T09:00:00+09:00",   # API から
    "2026-10-01T00:30:00Z",        # クラウドのログから
    "2026-10-01 08:45:00",         # 古い CSV から(オフセットなし)
]
times = [datetime.fromisoformat(s) for s in rows]
for s, t in zip(rows, times):
    print(f"{s:<28} -> tzinfo={t.tzinfo}")

print(sorted(times))
python -X utf8 t2_sort.py
2026-10-01T09:00:00+09:00    -> tzinfo=UTC+09:00
2026-10-01T00:30:00Z         -> tzinfo=UTC
2026-10-01 08:45:00          -> tzinfo=None
Traceback (most recent call last):
  File "t2_sort.py", line 13, in <module>
    print(sorted(times))
          ~~~~~~^^^^^^^
TypeError: can't compare offset-naive and offset-aware datetimes

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です。標準出力と標準エラーを続けて載せ、トレースバックのファイルのパスは短くしています)

読み込みまでは、すんなり通ります。 エラーになるのは、sorted() の中で大小を比べた瞬間です。

sorted「並べます! 1番目と2番目を比べて……3番目、札がないですね。無理です」

店長「読み込みのときに言ってよ」

fromisoformat「書いてあるとおりに読んだだけです」

……誰も悪くないのに、止まる。

なので、エラーの行(sorted() や max()、<)を直すのではなく、時刻を読み込んだ場所を探すのが近道です。 print(t.tzinfo) で、どれが None(札なし)かを見ましょう。

CSV の作った人に、札を付けてもらえばいいのでは?

それが一番です。 ただ、その CSV は5年前に作られて、作った人はもう別の部署にいます。たぶん。 だから、読み込む側で札を付けます。次の章です。

直し方:札を付ける replace、時差を直す astimezone

札の付け方は2通りあって、役目がまったく違います。

  • replace(tzinfo=tz):時刻の数字はそのままで、札だけ付け替える
  • astimezone(tz):同じ瞬間のまま、tz の地域の時刻に換算する

ドキュメントでも、時刻を変えずにタイムゾーンを付けたいだけなら replace(tzinfo=tz)、astimezone() は「UTC で同じ時刻になるように」日時を調整する、と説明されています。

t5_replace.py:

from datetime import datetime, timezone, timedelta

JST = timezone(timedelta(hours=9))
t = datetime(2026, 10, 1, 9, 0, tzinfo=JST)   # 日本時間の朝9時

print("元の時刻        :", t)
print("astimezone(UTC) :", t.astimezone(timezone.utc))
print("replace(UTC)    :", t.replace(tzinfo=timezone.utc))
print("同じ瞬間? astimezone:", t == t.astimezone(timezone.utc))
print("同じ瞬間? replace   :", t == t.replace(tzinfo=timezone.utc))
元の時刻        : 2026-10-01 09:00:00+09:00
astimezone(UTC) : 2026-10-01 00:00:00+00:00
replace(UTC)    : 2026-10-01 09:00:00+00:00
同じ瞬間? astimezone: True
同じ瞬間? replace   : False

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

astimezone は、日本の9時を「UTC の0時」に換算しました。同じ瞬間です。 replace は、数字の9時はそのままで札を「UTC」に貼り替えました。9時間ずれた、別の瞬間になっています。

使い分けはこうです。

  • 札なしの時刻に「これは日本時間です」と札を付ける → replace
  • 札付きの時刻を、別の地域の時刻に直す → astimezone

店長「東京の9時の伝票、ロンドン支店に回して」

見習いさん「はい! 伝票の『東京』を消して『ロンドン』と書きました!」

店長「……9時のまま?」

見習いさん「9時のままです!」

……それは換算ではなく、改ざんです。

全部 astimezone にしておけば、安全なのでは?

札なしの時刻に astimezone を使うと、ドキュメントのとおり「この PC のタイムゾーンの時刻」とみなされます(Python 3.6 から)。 CSV の時刻が本当は UTC だったら、9時間ずれます。 札なしの時刻には、まず自分で replace で札を付ける。これが確実です。

🔰 ここまで読めば今日から困らない

  • naive(札なし)と aware(札付き)は、大小の比較も引き算もできない。== は必ず False
  • エラーの行ではなく、時刻を読み込んだ場所で tzinfo が None のものを探す
  • 札なしの時刻には replace(tzinfo=...) で札を付け、別の地域に直すときは astimezone(...)
  • 今の時刻は datetime.now(timezone.utc) で取る

4つ。出前の電話で「どこの9時?」と聞き返すのと、ほぼ同じ手間。

聞き返すのは一瞬です。 誰もいない店にうどんを届けて、のびたうどんを持って帰るよりは、ずっと早い。

聞き返したら「えっ、9時は9時ですよ」って言われたら?

それが札なしくんです。 そのときは、店長が「東京の9時ね」と決めて伝票に書く。replace の出番です。

ここから先は中級者向け。読み飛ばしてもOKです。

中級:utcnow() は UTC なのに、札が付いていない

札なしの時刻を量産している犯人が、意外なところにいます。datetime.utcnow() は UTC の今の時刻を返すのに、札(tzinfo)を付けません。

ドキュメントには、こう書かれています。

  • naive な datetime は、多くのメソッドでローカルの時刻(その PC のタイムゾーンの時刻)として扱われる
  • だから UTC の時刻は aware で表すのがよく、今の UTC は datetime.now(timezone.utc) で取るのがおすすめ
  • utcnow() と utcfromtimestamp() は Python 3.12 で非推奨(deprecated、将来なくなる予定)

実際に、ずれを見てみます。どちらも「UTC の 2026-10-01 00:00」のつもりの時刻です。

t4_timestamp.py:

from datetime import datetime, timezone

print("この PC のタイムゾーン:", datetime.now().astimezone().tzinfo)

# どちらも「UTC の 2026-10-01 00:00」のつもり
naive_utc = datetime(2026, 10, 1, 0, 0)                       # utcnow() と同じく tzinfo なし
aware_utc = datetime(2026, 10, 1, 0, 0, tzinfo=timezone.utc)

print("naive.timestamp():", naive_utc.timestamp())
print("aware.timestamp():", aware_utc.timestamp())
print("差(秒):", aware_utc.timestamp() - naive_utc.timestamp())

print("naive.astimezone(UTC):", naive_utc.astimezone(timezone.utc))
print("aware.astimezone(UTC):", aware_utc.astimezone(timezone.utc))
この PC のタイムゾーン: 東京 (標準時)
naive.timestamp(): 1790780400.0
aware.timestamp(): 1790812800.0
差(秒): 32400.0
naive.astimezone(UTC): 2026-09-30 15:00:00+00:00
aware.astimezone(UTC): 2026-10-01 00:00:00+00:00

(今回の検証環境の Windows 10・Python 3.13.1、タイムゾーンは東京で実行した結果です)

32400秒は、ちょうど9時間。 札なしの UTC の0時を、timestamp()(1970年1月1日 UTC からの秒数に直すメソッド)が「東京の0時」として計算したので、9時間ずれました。 ドキュメントにも、naive な datetime はローカルの時刻とみなして変換する、と書かれています。

utcnow「UTC の0時です!」

timestamp「札がないので、東京の0時として数えますね」

utcnow「UTC って言ったのに……」

timestamp「言っただけで、書いてないので」

……口約束は、Python では通らない。

ずれの大きさは PC のタイムゾーンで変わります。UTC の PC(クラウドのサーバーによくある設定)なら、計算上は差が0秒になります。 つまり手元の PC では9時間ずれるのに、本番サーバーではずれない、という、いちばん見つけにくい形になりえます。

じゃあ全部のサーバーを東京時間にすれば、ずれ方がそろう?

そろいます。 そして海外のお客さんが来た日に、全部のずれ方が一斉に変わります。

非推奨の警告は、出ないこともある

「非推奨なら、警告が出るから気づくでしょ」と思いきや、そうとも限りません。

t3_utcnow.py:

from datetime import datetime, timezone
import helper_mod

a = datetime.utcnow()             # __main__ から直接呼ぶ
b = helper_mod.now_utc_old()      # 別モジュールの中で呼ぶ
c = datetime.now(timezone.utc)    # おすすめの書き方

print("utcnow():", a.tzinfo)
print("helper  :", b.tzinfo)
print("now(utc):", c.tzinfo)

helper_mod.py:

from datetime import datetime


def now_utc_old():
    return datetime.utcnow()
python -X utf8 t3_utcnow.py
t3_utcnow.py:4: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
  a = datetime.utcnow()             # __main__ から直接呼ぶ
utcnow(): None
helper  : None
now(utc): UTC

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です。標準エラーの警告を先に載せ、ファイルのパスは短くしています)

警告が出たのは4行目、__main__(直接実行したスクリプト)の中の utcnow() だけ。 helper_mod の中の utcnow() は、同じように札なしの時刻を返したのに、警告は出ていません。

warnings のドキュメントによると、通常のビルドの既定の設定では、DeprecationWarning(非推奨の警告)は __main__ のコードから直接出たときだけ表示され、それ以外は無視されます(Python 3.7 から)。 ライブラリや、自分で作ったモジュールの中の utcnow() は、黙って札なしの時刻を返し続けるわけです。

helper_mod「ぼくも utcnow 使ってますけど、何も言われてません!」

警告係「本店(main)の中しか見回ってないので」

……支店の中は、ノーチェック。

テストや CI(コードを変えるたびに自動でテストする仕組み)では、警告をエラーにして見つけるのがおすすめです。

python -X utf8 -W error::DeprecationWarning t3_utcnow.py
Traceback (most recent call last):
  File "t3_utcnow.py", line 4, in <module>
    a = datetime.utcnow()             # __main__ から直接呼ぶ
DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です。ファイルのパスは短くしています)

-W error::DeprecationWarning は、DeprecationWarning を例外にするオプションです。 ただしこの結果のとおり、最初に見つかった1か所で止まります。モジュールの中まで一覧で見たいときは、あとで出てくる点検スクリプトのほうが向いています。

警告を全部エラーにしたら、テストが全部落ちた。

それは、今まで黙っていた警告が一斉にしゃべり出しただけです。 まずは DeprecationWarning だけに絞って、1つずつ片付けましょう。

中級:Windows で ZoneInfo が見つからない

UTC と日本時間だけなら timezone(timedelta(hours=9)) で足ります。 でも、夏時間のある地域を扱うなら zoneinfo(Python 3.9 からの標準モジュール)の出番です。そして Windows では、タイムゾーンのデータが OS に入っていないことがある、という落とし穴があります。

zoneinfo のドキュメントによると、

  • 既定では、OS のタイムゾーンのデータを使う。なければ PyPI(Python のパッケージの置き場)の tzdata パッケージを使う
  • Windows など、IANA のタイムゾーンデータベース(「Asia/Tokyo」のような名前と、各地の時差・夏時間の決まりをまとめたデータ)を持たないシステムがある。いろいろな OS で動かすなら tzdata を依存関係に入れるのがおすすめ
  • どちらもなければ、ZoneInfo の呼び出しはすべて ZoneInfoNotFoundError になる

今回の検証環境には、たまたま tzdata が入っていました。

t6_zoneinfo.py:

import importlib.util
import sys
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import zoneinfo

print(sys.version.split()[0], sys.platform)
print("tzdata パッケージ:", "あり" if importlib.util.find_spec("tzdata") else "なし")
print("TZPATH:", zoneinfo.TZPATH)

try:
    tokyo = ZoneInfo("Asia/Tokyo")
    print(datetime(2026, 10, 1, 9, 0, tzinfo=tokyo))
except ZoneInfoNotFoundError as e:
    print("ZoneInfoNotFoundError:", e)
3.13.1 win32
tzdata パッケージ: あり
TZPATH: ()
2026-10-01 09:00:00+09:00

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

TZPATH(OS のタイムゾーンのデータを探すフォルダの一覧)は、今回の検証環境では空でした。 つまり OS からは見つけられず、tzdata パッケージのおかげで動いています。

では tzdata がなかったら。 パッケージを消すかわりに、tzdata を読み込めないように細工して試しました。

t8_notzdata.py:

import sys

sys.modules["tzdata"] = None   # tzdata が入っていない環境を再現する(検証用の細工)

from zoneinfo import ZoneInfo
import zoneinfo

print("TZPATH:", zoneinfo.TZPATH)
ZoneInfo("Asia/Tokyo")
TZPATH: ()
zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key Asia/Tokyo'

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です。長いトレースバックは最後の1行だけにしています)

ZoneInfo「Asia/Tokyo ですね。……地図がありません」

店長「東京だよ? 日本の」

ZoneInfo「地図がないので、日本がどこかも分かりません」

……自分の国の場所も分からない。Windows に地図帳を持たせるのが tzdata です。

対処は、tzdata を入れることです。

python -m pip install tzdata

(今回の検証環境には入っていたので、このコマンドは実行していません)

requirements.txt(使うパッケージの一覧)にも tzdata を書いておくと、別の Windows の PC や、OS のデータがない環境に持っていったときに困りません。

手元では動いたのに、同僚の PC では ZoneInfoNotFoundError。

手元の PC には、ほかのパッケージを入れたついでに tzdata が入っていた、という形がありえます。 「動いた」は「地図帳を持っていた」だけかもしれません。

地図帳、全員に配っておけば……

それが requirements.txt です。配布リストに書いておけば、全員の机に届きます。

中級:完成コード|時刻を「aware の UTC」にそろえる道具箱と、札なしを探す点検スクリプト

実務で使える形にまとめます。方針は入ってきたらすぐ aware の UTC にそろえる、札なしは黙って受け入れない、です。

timeutil.py:

"""時刻を「aware の UTC」にそろえるための小さな道具箱"""
from datetime import datetime, timezone, timedelta

JST = timezone(timedelta(hours=9), "JST")


def is_aware(dt: datetime) -> bool:
    """ドキュメントの定義どおり: tzinfo があり、utcoffset() が None でない"""
    return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None


def ensure_aware(dt: datetime, assume=None) -> datetime:
    """naive なら assume のタイムゾーンとみなす。assume がなければエラーにする"""
    if is_aware(dt):
        return dt
    if assume is None:
        raise ValueError(f"タイムゾーンのない時刻です: {dt.isoformat()}")
    return dt.replace(tzinfo=assume)


def to_utc(dt: datetime, assume=None) -> datetime:
    """aware の UTC に変換する(瞬間は変えない)"""
    return ensure_aware(dt, assume).astimezone(timezone.utc)


def parse(text: str, assume=None) -> datetime:
    """ISO 8601 の文字列を読み、aware の UTC で返す(Z は Python 3.11 以降)"""
    return to_utc(datetime.fromisoformat(text), assume)


def now_utc() -> datetime:
    """utcnow() の代わり"""
    return datetime.now(timezone.utc)

さっきエラーになった3つの時刻を、これで並べ直します。

t10_use_timeutil.py:

from timeutil import JST, parse

rows = [
    "2026-10-01T09:00:00+09:00",
    "2026-10-01T00:30:00Z",
    "2026-10-01 08:45:00",
]

try:
    [parse(s) for s in rows]
except ValueError as e:
    print("assume なし:", e)

times = sorted(parse(s, assume=JST) for s in rows)
for t in times:
    print(t.isoformat(), "=", t.astimezone(JST).strftime("%H:%M JST"))

[parse(s) for s in rows]

except ValueError as e: print(“assume なし:”, e) times = sorted(parse(s, assume=JST) for s in rows) for t in times: print(t.isoformat(), “=”, t.astimezone(JST).strftime(“%H:%M JST”))” tabindex=”0″ role=”button” style=”box-sizing: border-box; margin: 8px !important; padding: 0px !important; font-size: 14px; font-weight: 500; white-space: nowrap; vertical-align: middle; cursor: pointer; user-select: none; appearance: none; border: 0px; border-radius: 6px; line-height: 20px; display: flex !important; position: relative; color: rgb(9, 105, 218); background-color: rgba(0, 0, 0, 0); box-shadow: none; transition: color 80ms cubic-bezier(0.33, 1, 0.68, 1), background-color 80ms cubic-bezier(0.33, 1, 0.68, 1), box-shadow 80ms cubic-bezier(0.33, 1, 0.68, 1), border-color 80ms cubic-bezier(0.33, 1, 0.68, 1); justify-content: center !important; align-items: center !important; width: 28px; height: 28px;”>

assume なし: タイムゾーンのない時刻です: 2026-10-01T08:45:00
2026-09-30T23:45:00+00:00 = 08:45 JST
2026-10-01T00:00:00+00:00 = 09:00 JST
2026-10-01T00:30:00+00:00 = 09:30 JST

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

ポイントです。

  • assume を渡さないと、札なしの時刻は ValueError で止まる。「どこの9時?」を聞き返す係
  • assume=JST を渡すと、CSV の札なしの時刻を日本時間とみなして札を付ける(replace)
  • そのあと全部 UTC に換算する(astimezone)ので、並べ替えも引き算もできる
  • fromisoformat() が末尾の Z を読めるのは Python 3.11 から(ドキュメントの変更履歴より)。3.10 以前で使うなら、Z を +00:00 に置き換えてから読む

timeutil「札のない時刻が来ました。どこの時刻ですか?」

店長「知らない。たぶん日本」

timeutil「では assume=JST と書いてください。書いた人の責任になります」

……ハンコを押させるタイプの道具箱。

assume を省略できないようにしたのは、わざとです。 「たぶん日本」を、コードに書いて残しておくためです。

札なしを量産している場所を探す

既存のコードで、札なしの時刻を作りがちな呼び出しを一覧にするスクリプトです。 ast(Python のコードを構文として読む標準モジュール)で、関数の呼び出しを見ています。

find_naive.py:

"""naive な datetime を作りがちな呼び出しを探す(簡易チェック。見つけたら人が確認する)"""
import ast
import sys
from pathlib import Path

ALWAYS = {"utcnow", "utcfromtimestamp"}          # 3.12 で非推奨。常に naive を返す
NEED_TZ = {"now": 0, "fromtimestamp": 1}         # tz がないか None だと naive(位置引数の番号)


def has_non_none_tz(node, position):
    """tz が省略されず、明示的な None でもないかを確かめる"""
    if len(node.args) > position:
        value = node.args[position]
        return not (isinstance(value, ast.Constant) and value.value is None)
    for keyword in node.keywords:
        if keyword.arg == "tz":
            value = keyword.value
            return not (isinstance(value, ast.Constant) and value.value is None)
    return False


def check(path):
    tree = ast.parse(Path(path).read_text(encoding="utf-8"), filename=str(path))
    for node in ast.walk(tree):
        if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
            continue
        name = node.func.attr
        if name in ALWAYS:
            yield node.lineno, f"{name}() は naive を返します(3.12 で非推奨)"
        elif name in NEED_TZ:
            if not has_non_none_tz(node, NEED_TZ[name]):
                yield node.lineno, f"{name}() の tz が省略または None です(naive になります)"
        elif name == "today":
            yield node.lineno, "today() は naive を返します"


for arg in sys.argv[1:]:
    for p in sorted(Path(arg).rglob("*.py")) if Path(arg).is_dir() else [Path(arg)]:
        for line, msg in check(p):
            print(f"{p.as_posix()}:{line}: {msg}")

点検したファイル(sample_app/report.py):

import time
from datetime import datetime, timezone

started = datetime.utcnow()
local = datetime.now()
ok = datetime.now(timezone.utc)
stamp = datetime.fromtimestamp(time.time())
stamp_ok = datetime.fromtimestamp(time.time(), tz=timezone.utc)
day = datetime.today()
python -X utf8 find_naive.py sample_app
sample_app/report.py:4: utcnow() は naive を返します(3.12 で非推奨)
sample_app/report.py:5: now() の tz が省略または None です(naive になります)
sample_app/report.py:7: fromtimestamp() の tz が省略または None です(naive になります)
sample_app/report.py:9: today() は naive を返します

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

6行目と8行目(None ではない tz を渡している行)は出てきません。 名前だけで判定する簡易チェックなので、date.today() のような別物も拾いますし、datetime を別名で読み込んだ書き方は見落とすことがあります。見つかった行は、人が確認してください。

find_naive「today() を見つけました!」

店長「それ、日付しか使ってないやつ」

find_naive「名前が today だったので」

……名札だけで職務質問する係。でも、見逃すよりはいい。

見つかった datetime.now() を、全部 now(timezone.utc) に置換すれば終わり?

画面に「今の日本時間」を出したいだけの場所まで UTC になって、時計が9時間遅れて見えます。 保存・比較に使う時刻は UTC、画面に出す時刻は表示の直前に astimezone で直す。置換の前に、その時刻の役目を見てください。

ここから先は上級者向け。読み飛ばしてもOKです。

上級:夏時間のある地域では、足し算も比較も「壁の時計」で数える

ZoneInfo で札を付けると、夏時間(DST、夏のあいだ時計を1時間進める制度)も正しく扱えます。 ただし、同じ tzinfo どうしの計算は、実際の経過時間ではなく壁の時計の数字で行われます。

ドキュメントによると、aware な datetime どうしの引き算と比較は、

  • 両方の tzinfo が同じなら、tzinfo と fold(後で説明)を無視して、日時の数字どうしで計算する
  • tzinfo が違えば、両方を UTC に直してから計算する

2026年11月1日は、ニューヨークの夏時間が終わる日です(深夜2時に時計を1時間戻す)。この日の0時から「3時間後」を計算します。

t9_dst.py:

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

NY = ZoneInfo("America/New_York")

start = datetime(2026, 11, 1, 0, 0, tzinfo=NY)   # 夏時間が終わる日の0時
end = start + timedelta(hours=3)

print("start:", start)
print("end  :", end)
print("end - start(同じ tzinfo):", end - start)
print("UTC で引き算             :", end.astimezone(timezone.utc) - start.astimezone(timezone.utc))

# 1:30 は2回ある
first = datetime(2026, 11, 1, 1, 30, tzinfo=NY)
second = first.replace(fold=1)
print("fold=0:", first, first.tzname())
print("fold=1:", second, second.tzname())
print("fold=0 == fold=1:", first == second)
start: 2026-11-01 00:00:00-04:00
end  : 2026-11-01 03:00:00-05:00
end - start(同じ tzinfo): 3:00:00
UTC で引き算             : 4:00:00
fold=0: 2026-11-01 01:30:00-04:00 EDT
fold=1: 2026-11-01 01:30:00-05:00 EST
fold=0 == fold=1: True

(今回の検証環境の Windows 10・Python 3.13.1・tzdata ありで実行した結果です)

読み方です。

  • start + 3時間 は、壁の時計で0時→3時。でも途中で1時間戻ったので、実際には4時間たっている
  • 同じ tzinfo どうしの end - start は3時間。UTC に直して引くと4時間
  • 1時30分は、この日2回来る。fold(Python 3.6 から。0 が1回目、1 が2回目)で区別できて、オフセットも -04:00 と -05:00 で違う
  • それなのに、同じ tzinfo どうしの == は True。1時間離れた2つの瞬間が「等しい」と判定される

fold=0「1時30分です。1回目の」

fold=1「1時30分です。2回目の」

Python「同じ札なので、同じ人です」

店長「……1時間ずれて出勤してきた、双子」

……札が同じだと、双子を見分けてくれない。

実際の経過時間(課金、タイムアウト、稼働時間の計算など)がほしいときは、UTC に直してから引く。 「翌日の同じ時刻」のように、壁の時計で数えたいときは、同じ tzinfo のまま足す。 どちらがほしいのかを、コードを書く前に決めておく必要があります。

夏時間のない日本なら、関係ない話?

Asia/Tokyo だけで閉じているなら、今の日本には夏時間がないので、この差は出ません。 でも海外のユーザー、海外のクラウドのリージョン、海外の取引先のログが1つでも入ってきたら、関係者になります。

じゃあ全部 UTC で計算すれば、双子問題も起きない?

起きません。UTC には夏時間がないからです。 これが「中では UTC、見せるときだけ現地時間」がよく勧められる理由の1つです。

上級:書き出すときに札を落とさない

最後は、ファイルやデータベースに時刻を書き出す話です。書き出した文字列に札(オフセット)が残っていないと、読み込んだ側でまた札なしくんが生まれます。

t7_format.py:

from datetime import datetime, timezone, timedelta

JST = timezone(timedelta(hours=9))
naive = datetime(2026, 10, 1, 9, 0)
aware = naive.replace(tzinfo=JST)

print(repr(naive.strftime("%Y-%m-%d %H:%M %z")))
print(repr(aware.strftime("%Y-%m-%d %H:%M %z")))
print(naive.isoformat())
print(aware.isoformat())
print(JST, aware.tzname())
print(datetime.fromisoformat("2026-10-01T00:00:00Z"))
'2026-10-01 09:00 '
'2026-10-01 09:00 +0900'
2026-10-01T09:00:00
2026-10-01T09:00:00+09:00
UTC+09:00 UTC+09:00
2026-10-01 00:00:00+00:00

(今回の検証環境の Windows 10・Python 3.13.1 で実行した結果です)

  • naive の %z(UTC からのずれを出す書式)は空文字になる。エラーにはならず、末尾に空白だけ残る
  • isoformat() は、aware なら +09:00 まで書き出す。naive だと何も付かない
  • timezone(timedelta(hours=9)) の名前は UTC+09:00。「JST」と表示したければ、timezone(timedelta(hours=9), "JST") のように名前を渡す
  • 末尾が Z の文字列は、Python 3.11 以降の fromisoformat() なら UTC の aware として読める

strftime「%z ですね。札がないので、空白にしておきました!」

店長「エラーにしてよ」

strftime「空白は、立派な出力です」

……空白を書き出して、仕事をしたことになっている。

書き出す側で aware にしておけば、isoformat() が札ごと書いてくれます。 ログやファイルの時刻は、オフセット付きの ISO 8601(2026-10-01T00:00:00+00:00 の形)にそろえておくと、次に読む人(未来の自分を含む)が「どこの9時?」と悩まずにすみます。

未来のおうどん「このログの 09:00、どこの9時?」

過去のおうどん「……(返事がない)」

過去の自分は、聞き返しても答えてくれません。書き出す瞬間が、札を付ける最後のチャンスです。

チェックリスト

  • 今の時刻は datetime.now(timezone.utc)(Python 3.11 以降なら datetime.now(UTC) も可)で取っている
  • utcnow()・utcfromtimestamp() を使っていない(Python 3.12 で非推奨)
  • 外から読み込んだ時刻は、読み込んだ直後に aware の UTC にそろえている
  • 札なしの時刻に札を付けるときは replace、別の地域に直すときは astimezone と使い分けている
  • 札なしの時刻に timestamp()・astimezone() を使うと、PC のタイムゾーンとみなされることを知っている
  • Windows で zoneinfo を使うなら、tzdata を依存関係に入れている
  • 経過時間は UTC で引き、壁の時計で数えたいときだけ同じ tzinfo のまま計算している
  • 書き出す時刻は、オフセット付きの ISO 8601 にしている
  • テストで -W error::DeprecationWarning を試し、隠れた utcnow() を探した

9個。出前の電話で聞くことより多い。

出前の電話で聞くのは「どこの、何時」の2つだけ。 このチェックリストも、突きつめれば全部「どこの時刻か、札を付けておく」の言いかえです。

1個にまとめてくれればよかったのに。

1個にまとめると「札を付けろ」です。 それだと、どこで付けるのかが分からなくて、結局このリストに戻ってきます。

まとめ

冒頭では、支店の「9時です!」を信じて、誰もいない店にうどんが届きました。

  • naive(札なし)と aware(札付き)は比べられない。== は必ず False、< と引き算は TypeError
  • 直すのはエラーの行ではなく、時刻を読み込んだ場所
  • 札を付けるのは replace、別の地域に直すのは astimezone
  • utcnow() は UTC なのに札なし。多くのメソッドが PC のタイムゾーンの時刻とみなす。今の時刻は datetime.now(timezone.utc)
  • 非推奨の警告は __main__ の中でしか出ない。テストでは -W error::DeprecationWarning
  • Windows で ZoneInfo を使うなら tzdata
  • 同じ tzinfo どうしの計算は壁の時計。経過時間は UTC で

店長「よし、店じゅうの時計に札を付けよう。キッチンタイマーにも」

キッチンタイマー「……ぼく、3分を数えるだけなんですけど」

……タイマーは、どこで鳴っても3分です。札が要るのは「いつ」を表す時刻で、「どれだけ」を表す長さ(timedelta)ではありません。

支店「9時に出前お願いします!」

店長「どこの9時?」

支店「+09:00 の9時です!」

……今度は、ちゃんと札が付いてきた。

時刻は「何時か」だけでなく「どこの何時か」まで書いて、はじめて1つの瞬間になる。数字だけの9時は、出前の住所に番地が書いていないのと同じです。

まずは今日、手元のコードで utcnow( を検索してみてください。 1つ見つけて datetime.now(timezone.utc) に直したら、今日の出前は、ちゃんと温かいうちに届きます🍜

参考資料

  • Python ドキュメント:datetime — Basic date and time types — aware と naive の定義、naive と aware の比較・引き算の TypeError と == が False(3.3 の変更)、同じ tzinfo どうしの計算と比較で tzinfo・fold が無視されること、utcnow()・utcfromtimestamp() の非推奨(3.12)とおすすめの書き方、naive がローカルの時刻として扱われること、timestamp()・astimezone() の naive の扱い(3.6)、replace(tzinfo=) と astimezone() の違い、fromisoformat() の変更(3.11)、UTC の別名(3.11)、fold(3.6)、timezone が固定のずれだけを表すこと
  • Python ドキュメント:zoneinfo — IANA time zone support — Python 3.9 で追加、OS のデータと tzdata パッケージの順、Windows には IANA のデータベースがないことと tzdata を依存関係に入れるすすめ、ZoneInfoNotFoundError、夏時間の切り替わりでの fold
  • Python ドキュメント:warnings — Warning control — 既定では DeprecationWarning が __main__ からのときだけ表示されること(3.7)、-W オプションと error の動き

確認日:2026-10-01。

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

この記事を書いた人

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

コメント

コメントする

目次