「9時間足す」だけのはずが、なぜ事故るのか

UTCとJSTの関係は、実はとても単純です。JST = UTC + 9時間、固定。 日本にはサマータイムが無いので、季節によって時差が変わることもありません。ヨーロッパやアメリカのタイムゾーンを扱うときの厄介さと比べれば、計算そのものは暗算でできます。

それでもタイムゾーンの事故が絶えないのは、変換の計算ではなく「その日時が今どちらなのか分からなくなる」ことが原因だからです。ログはUTC、DBはJST、CIはUTC、開発マシンはJST——という状況で 2026-08-06 09:00:00 という文字列を見ても、それが何時なのか誰にも分かりません。

この記事は、変換の早見表と、実際に事故が起きるポイントをまとめたリファレンスです。

時差の早見表

UTCJST(同日)備考
00:0009:00UTCの0時は日本の朝9時
03:0012:00日本の正午
09:0018:00日本の定時
12:0021:00
15:00翌日 00:00ここから日付が変わる
18:00翌日 03:00
21:00翌日 06:00

逆方向(JST→UTC)は9時間引きます。JSTの00:00〜08:59は、UTCでは前日になります。

日付が変わる境界は UTC 15:00 です。日次バッチや集計の対象期間がずれる事故は、ほぼこの一線をまたいだところで起きます。「日本時間の1日分」を集計したいなら、UTCでは 前日15:00 〜 当日15:00 を指定することになります。

ISO 8601の Z+09:00

日時を文字列でやり取りするときの表記を整理します。

表記意味
2026-08-06T00:00:00ZUTCの0時。ZはUTC(Zulu time)を表す
2026-08-06T09:00:00+09:00上とまったく同じ瞬間をJSTで書いたもの
2026-08-06T09:00:00オフセット無し=どちらか不明。解釈は処理系任せ
2026-08-06日付のみ。時刻とタイムゾーンの解釈が処理系ごとに割れる

真ん中の2つは同じ瞬間を指します。オフセット付きで書いてある限り、表記が違っても情報は失われません。

問題は3番目です。オフセットの無い日時文字列は、それ単体では意味が確定しません。 APIのレスポンスやCSVでこの形式を見かけたら、まず仕様を確認してください。

JavaScriptで日付がズレる条件

もっとも遭遇しやすい罠がこれです。同じ日付を表しているように見える2つの文字列が、別の瞬間として解釈されます。

// 日付のみの形式は UTC として解釈される
new Date('2026-08-06').toISOString();
// => '2026-08-06T00:00:00.000Z'  (JSTでは 8/6 09:00)

// 時刻付きでオフセットが無い形式は「ローカルタイム」として解釈される
new Date('2026-08-06T00:00:00').toISOString();
// => '2026-08-05T15:00:00.000Z'  (JST環境で実行した場合。日付が前日に)

T00:00:00 を足しただけで結果が9時間ずれ、しかも日付が変わります。この挙動はES仕様どおりで、バグではありません。日付のみの形式はUTC、時刻付きでオフセット無しはローカル、と覚えるしかない部分です。

安全な書き方は、常にオフセットを明示することです。

new Date('2026-08-06T00:00:00+09:00').toISOString();
// => '2026-08-05T15:00:00.000Z'  (意図どおり。JSTの8/6 0時)

表示側でJSTに変換したいときは、toLocaleString にタイムゾーンを渡します。実行環境のタイムゾーン設定に依存しなくなるので、サーバー上でもブラウザ上でも同じ結果になります。

const d = new Date('2026-08-06T00:00:00Z');

d.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });
// => '2026/8/6 9:00:00'

// 実行環境がUTCでもJSTでも結果は変わらない

なお toISOString()常にUTCで出力します。ローカル時刻をそのままISO文字列にしたつもりで使うと、9時間ずれた値が保存されます。

サーバーサイドでの変換

Python

from datetime import datetime, timezone
from zoneinfo import ZoneInfo  # Python 3.9+

now_utc = datetime.now(timezone.utc)
now_jst = now_utc.astimezone(ZoneInfo('Asia/Tokyo'))

# naive(タイムゾーン情報なし)のdatetimeは扱わないのが鉄則

MySQL

SELECT CONVERT_TZ('2026-08-06 00:00:00', '+00:00', '+09:00');
-- => 2026-08-06 09:00:00

MySQLで注意すべきは、TIMESTAMP 型はセッションのタイムゾーンで自動変換されるのに対し、DATETIME 型は一切変換されないことです。同じテーブルに両方の型が混在していると、同じ「時刻」でも意味が違う列が並ぶことになります。名前付きタイムゾーン('Asia/Tokyo')を使うにはタイムゾーンテーブルの読み込みが必要なので、オフセット指定のほうが確実です。

PostgreSQL

SELECT TIMESTAMPTZ '2026-08-06 00:00:00+00' AT TIME ZONE 'Asia/Tokyo';
-- => 2026-08-06 09:00:00

PostgreSQLでは TIMESTAMPTZ(タイムゾーン付き)を基本にして、表示時に AT TIME ZONE で変換するのが定石です。型の使い分けは CREATE TABLE リファレンス にもまとめています。

事故が起きる4つの場所

1. cronはサーバーのタイムゾーンで動く

GitHub Actionsの schedule は常にUTCです。 「毎朝9時に実行」のつもりで 0 9 * * * と書くと、日本時間の18時に動きます。

on:
  schedule:
    # JSTの朝9時 = UTCの0時
    - cron: '0 0 * * *'

cron式そのものの読み方は cron式の書き方、ワークフローの依存関係は GitHub Actions needs設計パターン にまとめています。

2. Dockerコンテナの既定はUTC

多くのベースイメージはタイムゾーン設定を持たず、UTCで動きます。ローカル開発(JST)とコンテナ内(UTC)でログの時刻が9時間ずれるのはこれが原因です。アプリ側で時刻を「ローカルタイム」として扱っていると、環境によって結果が変わります。

3. ログのタイムゾーンが混在する

アクセスログはサーバーのローカルタイム、アプリケーションログはUTC、監視サービスの表示はブラウザのタイムゾーン——という状態は珍しくありません。障害調査で複数のログを突き合わせるときは、まず全部をUTCかUNIX時間に揃えてから並べるのが確実です。UNIXタイムスタンプはタイムゾーンの概念を持たない絶対的な秒数なので、突き合わせの基準として使えます(詳しくは UNIX時間とは)。

4. 「日付だけ」を保存する

2026-08-06 のような日付のみの値は、タイムゾーンによって指す範囲が変わります。締め日や集計日など業務上の日付は、日時に変換せず日付のまま扱うDATE 型を使う)ほうが安全です。日時に変換した瞬間に、どのタイムゾーンの0時なのかという問題が発生します。

鉄則: 保存はUTC、表示で変換

これらをまとめると、実務上の指針は次の3行になります。

  1. 保存と内部処理はUTC(またはUNIX時間)で統一する
  2. 表示するときだけJSTに変換する
  3. 文字列でやり取りするときは必ずオフセットを付けるZ または +09:00

「日本国内向けサービスだからJSTで保存する」は一見合理的ですが、CIやコンテナがUTCで動く以上、どこかで境界が生まれます。境界は少ないほど事故が減ります。

手元で変換する

ログに出ていたUTC時刻を今すぐJSTで確認したいときは、UTC/JST変換ツール に貼り付ければ相互変換できます。ISO 8601形式にも対応していて、現在時刻の取得やコピーもその場でできます。

UNIXタイムスタンプ(1785974400 のような数値)から日時に直したいときは UNIX時間変換、タイムアウトやTTLの設定値を秒・分・時間で読み替えたいときは 時間単位変換 が使えます。いずれもブラウザ内で処理され、入力した日時が外部に送信されることはありません。

まとめ

  • JST = UTC + 9時間で固定。日本にサマータイムは無い
  • 日付が変わる境界は UTC 15:00。「日本時間の1日」はUTCでは前日15時〜当日15時
  • ISO 8601でオフセット(Z / +09:00)が無い文字列は、それだけでは意味が確定しない
  • JavaScriptは日付のみ=UTC、時刻付きオフセット無し=ローカルと解釈が割れる
  • MySQLは TIMESTAMP だけ自動変換され DATETIME は変換されない
  • GitHub Actionsのcronは常にUTC。JST 9時は 0 0 * * *
  • 保存はUTC、表示で変換、文字列には必ずオフセット

よくある質問

JSTとUTCの時差は季節で変わりますか?

変わりません。日本標準時(JST)は UTC+9 で固定されており、サマータイム(夏時間)は導入されていません。したがって年間を通じて9時間差のままです。ヨーロッパや北米のタイムゾーンを扱う場合は夏時間の切り替えがあるため、オフセットを固定値で持たず、Asia/Tokyo のようなタイムゾーン名で扱うのが安全です。

2026-08-06T09:00:00+09:002026-08-06T00:00:00Z は違う時刻ですか?

同じ瞬間です。表記しているタイムゾーンが違うだけで、指している時点は完全に一致します。どちらの形式で保存しても情報は失われませんが、システム内では片方に統一しておくと比較やソートのときに迷いません。

JavaScriptで日付が1日ずれるのはなぜですか?

日付のみの文字列('2026-08-06')はUTCとして解釈されるのに対し、時刻付きでオフセットの無い文字列('2026-08-06T00:00:00')はローカルタイムとして解釈されるためです。JST環境では後者が9時間前(前日15:00 UTC)を指すことになり、UTC基準で日付を出力すると1日ずれて見えます。文字列には常にオフセットを付けてください。

GitHub Actionsで毎朝9時(日本時間)に実行するには?

schedule のcronはUTCで解釈されるため、- cron: '0 0 * * *' と書きます。JSTの9時はUTCの0時です。なお、GitHub Actionsのスケジュール実行は負荷状況によって数分〜十数分遅延することがあるため、分単位の正確さが必要な処理には向きません。

変換したい日時を貼り付けると、データは送信されますか?

いいえ。UTC/JST変換ツールUNIX時間変換 はどちらもブラウザ内で処理が完結し、入力した日時がサーバーへ送信されることはありません。