毎日、現地時刻の 20:00 は、なぜ 24 時間おきと違うのか
毎晩実行するタスクの次回時刻を求めるとき、最初に思いつくのは「今回の時刻に一日を足す」という方法です。しかし、一日には二つの意味があります。暦を翌日に進めて時計を 20:00 のままにすることと、時間軸に沿って 24 時間を経過させることです。毎日、現地時刻の 20:00 という要件に対して、24 時間おきの実行では、なぜ約束した時刻から外れることがあるのでしょう。どう計算し、何を確認すればよいのでしょうか。
違いが現れるのはタイムゾーンの規則が変わるときです。ニューヨークの 2026 年の夏時間切り替えを再現可能な計算に使い、上海を対照にします。公式資料で規則を確認し、実行した計算で数値を示し、最後に業務上の振る舞いを選ぶための実装上の提案を述べます。実験はすべて日付の計算だけで、定期実行サービスを起動したり、実際の実行時刻まで待ったりはしていません。
1. 「毎日」を判定できる要件にする
「毎日、現地時刻の 20:00」は、まず暦に基づく規則です。指定したタイムゾーンで、対象となる各日付の時計が 20:00 を指す時刻を求めます。日報や営業日のリマインダーでは、人の一日の区切りに合うことが重視されるでしょう。「24 時間おき」は間隔の規則で、ある起点から、以後の時刻を同じ長さの間隔で並べます。多くの日には両方が同じ答えになるため、同じ設定だと考えやすいのです。
名前を持つタイムゾーンと固定オフセットにも違いがあります。America/New_York は日付に応じて適用する規則を含みます。-05:00 は、現地の時計が UTC より五時間遅いという情報だけで、将来のニューヨークの毎日の時計を単独で表せません。IANA のタイムゾーンデータベースは、政治機関による境界、オフセット、夏時間規則の変更に応じて更新されます。IANA:Time Zones
発生済みの一件のイベントなら、UTC の時刻と当時のオフセットを記録できます。将来繰り返すタスクには、名前を持つタイムゾーンと現地の時計に関する要件も残す必要があります。固定の UTC オフセットを使うことが明示されたタスクなら、その選択も正当です。先に決めるべきなのは、タスクがどの時間規則に従うかであり、すべての時刻を一律にどこかの都市へ変換することではありません。
2. 三つの実測計算:23、25、24 時間
2026 年 10 月 4 日に、macOS、Python 3.9.6 で完全な実験スクリプトを実行しました。システムのタイムゾーンデータのバージョン表示は 2026c、tzdata.zi の先頭行には 2026c-rearguard とありました。実行結果の JSONには、二つのタイムゾーンデータファイルの SHA-256 も記録しています。これは実験環境の記録です。再現するときは、自分の環境とデータの出所を確認してください。
スクリプトを一つのディレクトリにダウンロードし、そのディレクトリで実行します。
python3 calendar-day-lab.py --json calendar-day-results.json
Python 3.9 以上と、利用可能な IANA タイムゾーンデータが必要です。zoneinfo はシステムのデータを先に探し、見つからなければ tzdata パッケージを参照します。どちらもなければ名前を持つタイムゾーンを解決できません。Python:zoneinfo のデータソース 今回はシステムのデータを使いました。依存パッケージを別途インストールしたり、他の Python バージョンで再検証したりはしていません。
次の表の日付はすべて 2026 年です。起点と「翌日」の列は現地時刻、UTC 間隔は両端を UTC に変換してから差を求めた値です。
| 場所とケース | 起点 | 翌日の同じ時計時刻 | UTC 間隔 | 起点から 24 時間後の現地時刻 |
|---|---|---|---|---|
| ニューヨーク、春の切り替え | 03-07 20:00 -05:00 | 03-08 20:00 -04:00 | 23 時間 | 03-08 21:00 -04:00 |
| ニューヨーク、秋の切り替え | 10-31 20:00 -04:00 | 11-01 20:00 -05:00 | 25 時間 | 11-01 19:00 -05:00 |
| 上海、対照 | 03-07 20:00 +08:00 | 03-08 20:00 +08:00 | 24 時間 | 03-08 20:00 +08:00 |
NIST が示す現在の米国の夏時間規則では、三月の第二日曜日の 02:00 に時計を 03:00 へ進め、十一月の第一日曜日の 02:00 に 01:00 へ戻します。夏時間を採用しない地域もあります。この実験のニューヨークでは、それぞれ 3 月 8 日と 11 月 1 日に対応します。NIST:Daylight Saving Time
春の起点を UTC にすると 3 月 8 日 01:00、対象時刻は 3 月 9 日 00:00 なので、間隔は 23 時間です。秋は UTC の 11 月 1 日 00:00 から 11 月 2 日 01:00 までで、25 時間です。上海では選んだ日付のオフセットが変わらず、二つの計算が一致しました。この対照が示すのは検証した日付の結果だけで、タイムゾーンの全歴史を推測する材料にはなりません。
ニューヨークのタスクを時間軸に沿って 24 時間おきに発火させると、春の切り替え後は 21:00 になり、秋の例では 19:00 になります。オフセットの変更によって現地の時計との対応が変わるのであって、毎日一時間ずつ誤差が積み重なるわけではありません。
3. Python の直接の減算が 24 時間を返す理由
より見えにくい問題もあります。二つのオブジェクトがともにタイムゾーンを持っていても、直接の減算が時間軸上の間隔を求めるとは限りません。次の例を実行すると、順に 24.0 と 23.0 が出力されました。
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
zone = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 20, tzinfo=zone)
tomorrow = start + timedelta(days=1)
print((tomorrow - start).total_seconds() / 3600)
print((tomorrow.astimezone(timezone.utc)
- start.astimezone(timezone.utc)).total_seconds() / 3600)
Python の文書では、二つの aware datetime が同じ tzinfo オブジェクトを持つ場合、減算でタイムゾーンの調整を無視すると定めています。上の start と tomorrow は zone を共有するため、最初の行は暦のフィールドの差から 24 時間を返します。UTC に変換した二行目が、このオフセット変更を反映します。Python:datetime の演算規則
タイムゾーンの情報は必要ですが、それを持つだけですべての演算が意図した意味になるわけではありません。時間軸上の間隔を計算するときは、両端を明示的に UTC へ変換できます。翌日の同じ時計時刻を求めるときは、対象の現地日付を作り、その時刻が有効かを確認します。上の 20:00 は有効でしたが、直接の加算そのものは有効性の確認をしていません。
また、timedelta(days=1) と timedelta(hours=24) は同じ長さを表します。引数の名前を変えても演算の意味は変わりません。添付スクリプトの add_elapsed_hours は UTC に変換してから足し、next_calendar_day は現地の日付を一日進めてから、後述の検証関数を呼びます。振る舞いの違いは計算の手順から生じます。
4. 存在しない現地時刻と、二度発生する現地時刻
タスクを午前 02:30 に変えると、ニューヨークの 2026 年 3 月 8 日にはその時計時刻がありません。時計が 02:00 から 03:00 へ飛ぶためです。しかし、datetime(2026, 3, 8, 2, 30) にニューヨークのタイムゾーンを直接付ける操作は成功します。実験では 2026-03-08T02:30:00-05:00 ができ、それを UTC にしてからニューヨークへ戻すと、2026-03-08T03:30:00-04:00 になりました。
オブジェクトを作れたことは、その現地時刻の存在を保証しません。PEP 495 は、コンストラクターと replace() がこの有効性を確認しないと明記しています。PEP 495:fold と無効なインスタンス フォームで早朝の時刻を入力できるなら、生成時に例外が出るかの確認に加えて、別の検証が必要です。
秋には逆のことが起きます。ニューヨークの 2026 年 11 月 1 日の 01:30 は二度発生します。スクリプトで早い方と遅い方の候補を選ぶと、次の結果になりました。
| 選択 | 現地時刻 | UTC 時刻 |
|---|---|---|
早い方、fold=0 | 2026-11-01 01:30 -04:00 | 2026-11-01 05:30 +00:00 |
遅い方、fold=1 | 2026-11-01 01:30 -05:00 | 2026-11-01 06:30 +00:00 |
二つの時刻は一時間離れていますが、現地の日付、時、分は同じです。fold は先後を区別する情報であり、タスクを一回実行するか二回実行するかを業務の代わりに決めません。この選択を定めていない「毎日 01:30」は、その日にはまだ不完全な要件です。
5. 往復変換で検証し、方針を明示する
添付スクリプトは、fold=0 と fold=1 をそれぞれ試し、UTC へ変換してから元のタイムゾーンへ戻します。戻った日付と時計のフィールドが入力と一致する候補だけを残し、同じ UTC 時刻は一つにまとめます。中心となる関数は、完全なスクリプトと同じ次のコードです。
from datetime import timezone
UTC = timezone.utc
def local_candidates(local, zone):
"""Find distinct UTC instants that round-trip to a naive local datetime."""
if local.tzinfo is not None:
raise ValueError("local_candidates requires a naive local datetime")
found = {}
for fold in (0, 1):
candidate = local.replace(tzinfo=zone, fold=fold)
instant = candidate.astimezone(UTC)
back = instant.astimezone(zone)
if back.replace(tzinfo=None) == local:
found[instant] = back
return [found[instant] for instant in sorted(found)]
通常の時刻は一候補、春の欠落した時刻はゼロ候補、秋の重複した時刻は二候補になります。UTC で重複を除くことが重要です。同じ tzinfo の現地オブジェクトの比較は fold を無視するため、現地オブジェクトをそのまま重複除去に使うと、別々の実際の時刻をまとめてしまうことがあります。PEP 495:時間の演算と比較
スクリプトの resolve_local はゼロ候補ならエラーにし、二候補の場合も既定ではエラーにします。呼び出す側が earlier または later を明示できます。この既定値は要件の抜けを発見しやすくするためのもので、すべての製品でエラーにする必要があるという意味ではありません。欠落した日の実行を飛ばしたり、約束に従って後ろへずらしたりする製品も考えられます。その方針には別の実装と検証が必要で、このスクリプトでは実装していません。
実行では、三組の間隔に関するアサーションと八項目の境界確認が通りました。通常時刻、欠落時刻の拒否、重複時刻の選択、不正な方針の拒否、タイムゾーンのない間隔入力の拒否、未知のタイムゾーンの拒否、翌日が欠落時刻になる場合の拒否、翌日が重複時刻になる場合の拒否です。最後の二つは、入力を単独で検証するだけでなく、日付を進める関数からも検証を呼ぶことを確認しています。通常の Python 実行モードですべてのアサーションを実行しました。再現時はアサーションを無効にする -O を使わないでください。
6. 繰り返しの規則と、一回の実行時刻を保存する
以上の計算を踏まえると、暦に沿うタスクには、タイムゾーン名、時計上の対象時刻、繰り返す日付の規則、欠落と重複の処理方針を保存できます。各回の対象となる現地日付から有効な候補を求め、実行予定の UTC 時刻へ変換します。初回の UTC 時刻だけを保存し、その後ずっと 24 時間を足すと、「毎日、現地 20:00」という要件が失われます。
日付を進めるときには、予定の日付と実際の開始時刻も区別します。設計上の仮定として、昨日の 20:00 のタスクが待ち行列のため 21:10 に開始したとします。実際の開始時刻に一日を足せば、次の対象も 21:10 になります。これはタイムゾーンの切り替えとは無関係で、実行の遅れを繰り返しの規則に取り込んだ結果です。現地 20:00 という要件を保つなら、次の予定日について 20:00 の候補を改めて求めます。遅れたタスクを追実行するかどうかは別に定めます。
固定間隔のタスクには起点と間隔を残します。さらに、「予定上の起点から 24 時間おき」と「前回のタスクが終わってから 24 時間待つ」を区別する必要があります。タスクが二時間かかれば、後者は次の開始をさらに二時間遅らせます。これは業務定義の差であり、タイムゾーンのライブラリーが代わりに選ぶことはできません。
タイムゾーンデータの更新後には、将来の日付を新しい規則で再計算する必要が生じる場合があります。実行時刻が変わった理由を調べるためには、採用したタイムゾーン、現地の対象時刻、オフセット、データのバージョンを確認できるようにします。文脈のないタイムスタンプだけでは足りません。この記事がバージョンとファイルのハッシュを記録するのも、計算結果の出所を確認できるようにするためです。IANA:データベースと更新
実際のスケジュール実行には、停止後の追実行、重複実行の防止、利用者がタイムゾーンを変えた場合の動作も必要です。それらは上記の日付計算の外側にあり、今回の実験ではスケジューラー、システムのスリープ復帰、再起動を検証していません。また、Python の datetime はうるう秒をモデル化しません。UTC 間隔はこのライブラリーの時間モデルで求めており、すべての計時機器の物理的な時間計測を証明するものではありません。Python:datetime の時間モデル
7. 実装が約束に合うかを判断する方法
冒頭の問いには、二つの確認で答えられます。次回の実行が対象の現地日付の 20:00 に対応するか、前回からの UTC 間隔はいくらかです。暦に沿うタスクではオフセット変更に伴う間隔の変化を許し、固定間隔のタスクでは現地の時計時刻の変化を許します。両方を同時に表示すると、「日付が一日進んだ」だけを見るより誤りを発見しやすくなります。
通常の日に一度成功するだけでは十分ではありません。少なくともオフセットが進む日と戻る日を一つずつ選び、欠落時刻と重複時刻の方針も守られているか確認します。今回のスクリプトはその日付計算の証拠を提供します。実際の定期実行システムへ接続するときには、保存、発火、復旧の方法も検証が必要です。「毎日」の意味を先に明確にし、その意味に対応する量を確認することで、定時実行の要件が実装できたかを判断できます。