午前一時が二度来た
一言でいうと
保存は瞬間(UTC)で行い、表示だけをローカル時刻にします。ローカル時刻で保存した記録は、1年に2回、重なったり消えたりします。
なぜ必要なのか
「うちのサービスは韓国でしか使わないので、そのままローカル時刻で保存しました。」この文は、数年間、何の問題も起こしません。韓国は現在、夏時間を使っていないので、韓国時刻とUTCの差は、常に9時間で固定されています。問題はそのあとです。アメリカの顧客ができて、現地時刻でアラートを送り始めたり、クラウドのリージョンを1つ増やしたり、精算の基準を現地の営業日に変えたりした瞬間、この設計は壊れます。そして、壊れる日は1年に2回だけなので、普段のテストでは絶対に捕まりません。
どう動くのか
ローカル時刻と瞬間の間の変換ルールは、国が政治的に決め、その歴史をまとめたものが、IANAタイムゾーンデータベースです。Asia/Seoul、America/New_Yorkのような名前は、都市の名前ではなく、その地域の時刻ルール全体の名前です。このデータベースは、政治的な決定があるたびに更新されます。確認した時点の最新版は2026d(2026-09-11公開)で、その版の変更点の1つが、カナダのノースウェスト準州が恒久的に-06へ移ったことでした。ルールがこのように変わり続けるので、オフセットを+09:00のような数字でコードに埋め込んではいけません。名前で保存し、変換はデータベースに任せます。
夏時間が終わる日には、時計を1時間戻します。すると、その1時間のローカル時刻が2度来ます。始まる日には、1時間進めるので、その1時間はまったく存在しません。Pythonは、この2つの場合を区別するために、foldという属性をdatetimeに持たせました。PEP 495がその提案で、0が2つの候補のうち早いほう、1が遅いほうです。
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
def classify(naive):
early = naive.replace(tzinfo=ny, fold=0)
late = naive.replace(tzinfo=ny, fold=1)
if early.utcoffset() == late.utcoffset():
return "normal" # 후보가 하나뿐 — 평범한 시각
back = early.astimezone(timezone.utc).astimezone(ny).replace(tzinfo=None)
return "ambiguous" if back == naive else "nonexistent"
classify(datetime(2025, 11, 2, 1, 30)) # ambiguous — 두 번 온다
classify(datetime(2025, 3, 9, 2, 30)) # nonexistent — 오지 않는다
判定の後半が核心です。2つの候補のオフセットが違うというだけでは、「2度来る時刻」と「存在しない時刻」を区別できません。どちらの場合も、オフセットが分かれるからです。区別は往復で行います。ローカル時刻を瞬間に変換してから、またローカル時刻に戻したときに、元の値が出てくれば、その時刻は実在するもので(2度来る側)、違う値が出てくれば、そもそも存在しない時刻です。
保存側のルールは、そのため単純になります。瞬間は、UTCで保存します。PostgreSQLの日付・時刻型で、timestamptzを使うよう言われる理由が、これです。名前が紛らわしいですが、この型はタイムゾーンを保存しません。入力をUTCに変換して保存し、読むときにセッションのタイムゾーンで見せます。逆に、timestamp(without time zone)は、書かれた数字をそのまま持っているので、その数字がどの地域のものか、誰にもわかりません。
ただし、未来の約束は例外です。「来月の最初の月曜日の午前9時に会議」は、瞬間ではなく、ローカル時刻で行った約束です。その間にその国が夏時間のルールを変えると、UTCに固定しておいた値は、間違った時刻に鳴ります。このようなものは、ローカル時刻とタイムゾーン名を一緒に保存して、読むときに変換します。
現場での姿
最もよくある事故は、ローカル時刻で名前を付けたバッチジョブです。夏時間が終わる明け方、「01:00の精算」が2回動きます。2回とも正常終了し、ログにもエラーがなく、総実行回数も普段と同じです。ただ、その日の売上が2回反映されます。
2番目によくあるのが、存在しない時刻にかけた予約です。夏時間が始まる明け方の2時台は、その地域に存在しないので、その時刻にかけたジョブは、黙って1回飛ばされます。失敗したのではなく、そもそも起きなかったので、失敗アラートも来ません。翌朝、誰かが「昨日のレポートがありませんね」と言うまで、誰も気づきません。
3つ目は、人の勘違いです。ローカル時刻だけで残った記録を見て、「01:30に2件ありますが、重複ですか」と尋ねます。その2件は、実際には1時間離れた、別々の瞬間です。記録にオフセットがなければ、これを判別する方法が、原理的にありません。
次のクイズで確認すること
ローカル時刻で保存したときに、どのような2つの事故が起きるか、foldで2つの場合をどう分けるか、保存はUTCでというルールに、どのような例外があるかを確認します。