コンテンツへスキップ

タイムゾーン処理の罠 — UTC/JST変換でハマりやすいポイント

日本標準時(JST)は UTC(協定世界時)からちょうど9時間進んでいて、夏時間(サマータイム)による切り替えもない。この単純さゆえに「JSTは足し算・引き算だけで済むから簡単」と思われがちだが、実際にタイムゾーン絡みの不具合を踏む場面の多くは、変換の計算そのものではなく「今扱っている時刻がそもそもどちらの基準なのか」を見失うところで起きる。この記事では、コードとログ、そしてこのブログ自体の投稿ワークフローで実際に発生した事例をもとに、UTC/JST変換でハマりやすいポイントを整理する。

罠1: 「タイムゾーン情報を持たない時刻」が紛れ込む

補足: naive(ナイーブ)なdatetimeとは、タイムゾーン情報を一切持たない日時オブジェクトのこと。対して、タイムゾーン情報を持つものを aware(アウェア)なdatetimeと呼ぶ。

Pythonの datetime.now() は、実行環境のローカルタイムゾーンでの時刻を返すが、返ってくる datetime オブジェクト自体には「これはJSTです」という情報が付いていない。同じ理由で datetime.utcnow() もUTCの時刻を返すだけで、「これはUTCです」というタグは付かない。

from datetime import datetime

local_now = datetime.now()      # 例: 2026-09-15 14:30:00 だが「JST」とは書かれていない
utc_now = datetime.utcnow()     # 例: 2026-09-15 05:30:00 だが「UTC」とは書かれていない

この2つの値を見比べても、どちらがどのタイムゾーンなのかはコードを書いた本人の記憶に頼るしかない。片方をログに書き、もう片方をデータベースに保存するようなコードを書いてしまうと、後から読んだ人(あるいは数ヶ月後の自分)が「この時刻はJSTかUTCか」を判断できなくなる。

これが典型的な事故の温床になる。「9時間ズレている」というバグは、正しい値と見比べて初めて気づける類のもので、単体で見ると一見もっともらしい日時が表示されているため、テストや目視確認をすり抜けやすい。

なお datetime.utcnow() はPython 3.12で非推奨になった。理由もまさにこの「naiveな値はタイムゾーンが自明ではない」問題で、代わりに datetime.now(timezone.utc) を使うことで、値自体に「UTCである」という情報を持たせられる(aware なdatetime)。

from datetime import datetime, timezone

utc_now = datetime.now(timezone.utc)  # tzinfo=UTC が値に含まれる

罠2: ログのタイムスタンプはデフォルトでローカル時刻

以前の記事でログレベル設計を扱った際、maintenance_agent.py の logging.Formatter を実例に使った。このフォーマッタが出力する %(asctime)s は、Pythonの logging モジュールの仕様上、デフォルトでは time.localtime(サーバーのローカルタイムゾーン)を使って時刻を組み立てる。

_log_fmt = logging.Formatter('%(asctime)s - [%(levelname)s] - %(message)s')

普段サーバーを1台しか使わず、そのサーバーがJSTで動いていれば、ログの時刻はそのままJSTとして読めるので問題は表面化しない。しかし、複数のサーバー(タイムゾーン設定が異なる場合がある)のログを1箇所に集約して時系列を突き合わせたり、UTCで動くクラウド環境のログと自社サーバーのログを並べて障害調査をしたりする場面になると、「どちらもただの時刻文字列」であるログは、タイムゾーンを明示していない限り正しく突き合わせられない。

logging.Formatter は converter 属性を time.gmtime に差し替えることでUTC出力に切り替えられる。

import time
_log_fmt = logging.Formatter('%(asctime)s - [%(levelname)s] - %(message)s')
_log_fmt.converter = time.gmtime  # asctime が UTC ベースになる

どちらが正解というわけではなく、「このログのタイムスタンプは何を基準にしているか」をコード上、あるいは運用ドキュメント上で明示しておくことが重要になる。

罠3: WordPressの post_date と post_date_gmt の二重管理

このブログ自体の投稿ワークフローにも、まさにこの種の罠がある。WordPressは投稿の日時を post_date(サイトの管理画面で設定したタイムゾーンでのローカル時刻。このブログの場合はJST)と post_date_gmt(同じ瞬間をUTCで表した値)の2つの列に別々に保持している。

wp post create で投稿する際、どちらか片方しか指定しないとどうなるか。実際にこのブログの運用で起きた事例では、--post_date は正しくJSTの過去時刻を指定したが、--post_date_gmt を省略(またはJSTと同じ値のまま保存)した投稿で、WordPress側が「この投稿はまだ未来の時刻に公開予定だ」と誤判定し、投稿ステータスが自動的に future(予約投稿)に変わってしまったことがある。予約投稿はページとして404を返すため、公開したはずの記事が一時的にアクセスできなくなった。

# 安全な投稿コマンド: post_date(JST)と post_date_gmt(UTC)の両方を明示する
wp post create \
  --post_status=publish \
  --post_date='2026-09-15 14:00:00' \
  --post_date_gmt='2026-09-15 05:00:00' \
  ...

原因は単純で、post_date_gmt が「本来はJSTから9時間引いたUTC値」であるべきところを、WordPress側の初期値ロジックか、コマンド側の指定漏れによって「JSTの値がそのままUTCとして」保存されてしまうと、実際の時刻より9時間先の未来として解釈される。9時間という差はちょうどJST-UTCのオフセットと同じ大きさなので、「なぜか投稿が9時間だけ未来扱いになる」という症状が出たときは、まずこの post_date / post_date_gmt の不一致を疑う価値がある。対処は単純で、投稿・更新コマンドでは常に両方の値を明示的に、かつ正しい9時間差で指定することに尽きる。

補足: なぜWordPressが2つの列を別々に持つかというと、post_date はサイト管理者が見る「表示用の時刻」、post_date_gmt はプラグインやAPI連携など「タイムゾーンに依存しない基準時刻」が必要な処理のための値、という役割分担があるため。REST API のレスポンスにも date(サイトのタイムゾーン)と date_gmt(UTC)の両方が含まれており、同じ設計思想が一貫している。

3つの罠に共通する構造

ここまでの3つの罠は、表面上はまったく別の技術(Pythonのdatetime・ログフォーマッタ・WordPressのDB列)に見えるが、根っこにある問題は同じである。「この時刻の値は、どのタイムゾーンを基準にしているか」という情報が、値そのものからは読み取れない場面がある、という一点に尽きる。

罠 どこで起きるか 症状
naiveなdatetime Pythonコード内 どちらの基準か分からない値同士を比較・保存してしまう
ログのローカル時刻 logging.Formatter 複数サーバー・複数環境のログの時系列を正しく突き合わせられない
post_date/post_date_gmt 不一致 WordPress投稿 9時間先の未来と誤判定され post_status=future 化・404

どう防ぐか

対策の方向性はいずれも共通していて、特別な工夫というより「基準を1つに決めて、値自体にその基準を持たせる」という地道な徹底に尽きる。

  • 内部の処理・保存はUTCに統一し、画面表示や人が読む場面でだけJSTに変換する(「UTC-in-core, local-at-edge」と呼ばれる考え方)。変換のタイミングが早すぎたり複数箇所に散らばったりすると、どこかで二重変換や変換漏れが起きやすい
  • Pythonでは naive な datetime をなるべく作らず、datetime.now(timezone.utc) のように常にタイムゾーン情報を値に持たせる(aware なdatetimeを徹底する)
  • ログや外部連携のように「基準が自明ではない値」を扱う箇所では、変数名やドキュメントに _utc / _jst のような接尾辞を付けて、値を見ただけで基準が分かるようにしておく
  • WordPressのように「同じ瞬間を2列で別々に持つ」設計のシステムでは、片方だけを更新するコマンドを書かない。更新系のコマンド・API呼び出しは常に両方をセットで明示する

JSTがUTC+9固定・夏時間なしというシンプルな仕様であることは、変換の計算自体を簡単にしてくれる一方で、「どうせ9時間足すか引くかだけだろう」という油断を生みやすい面もある。実際に事故が起きるのは計算を間違えたときではなく、そもそも今扱っている値がどちらの基準なのかを見失ったときである、という点を覚えておくと、上記のような罠を未然に防ぎやすくなる。