先に結論を言うと、Unix タイムスタンプ自体にタイムゾーンはありません。それは「1970-01-01 00:00:00 UTC から何秒経ったか」という値にすぎません。あなたが見ている「8 時間ずれ」は、ほぼすべてそれを人が読める文字列に翻訳する段階で起きています。
タイムスタンプとは何か
Unix タイムスタンプはある瞬間を表す整数です。「北京時間」や「ニューヨーク時間」という概念はなく、同じ瞬間ならどこで取得しても同じ数になります。だからこそデータベースにタイムスタンプを保存するのが最も安全なのです。サーバーを移設しても、システムのタイムゾーンを変えても、値が歪むことはありません。
唯一気をつけるべきは精度です。10 桁は秒、13 桁はミリ秒。13 桁のミリ秒値を秒として解釈すると西暦 5 万年台になり、逆に 10 桁の秒値をミリ秒として扱うと 1970 年 1 月に戻ります。この種の誤りはどの言語でも起きています。
8 時間ずれる 3 つの原因
1 つ目は、保存と表示で基準が違うこと。 バックエンドは UTC で保存し、フロントエンドはそれを「ローカル時刻」に整形して表示する。すると合わないように見えますが、どちらも間違っていません。ただ、それぞれがどのタイムゾーンを使っているかを誰も明言していないだけです。
2 つ目は、サーバーのタイムゾーンが UTC でないこと。 あるイメージは既定で Asia/Shanghai、別のイメージは既定で UTC ということがあります。同じコードを 2 台で動かすと、ログに書かれる時刻が 8 時間ずれます。ログの時刻が合わないと感じたら、まず date と timedatectl を見てください。
3 つ目は、文字列の解析時にタイムゾーンが自動で補われること。 2026-10-19 00:00:00 のようにタイムゾーンなしで書かれた値は、ブラウザや Node ではローカルタイムゾーンとして解釈されますが、一部のライブラリは UTC として解釈します。同じ 1 本の文字列が、解釈次第で 8 時間ずれるのです。
素早い自己診断
判断に迷ったら 2 つの動作をしてください。1 つ目は、同じタイムスタンプを UTC とローカルの両方で読んでみて、2 つの結果がちょうど整数時間ずれているかを見る。2 つ目は、「これがその時刻だろう」と思う値をタイムスタンプに換算し、ログの数値と比べる。差が 28800 秒なら 8 時間で、タイムゾーンの問題でありロジックの問題ではありません。
ほとんどの罠を避けられる 3 つの習慣
- 保存は常に UTC のタイムスタンプ、またはタイムゾーン付きの ISO 8601(
2026-10-19T02:00:00Zなど)を使う - 表示するときはタイムゾーンを明示し、実行環境の既定値に頼らない
- API で時刻を渡すときは必ずタイムゾーンを付け、「これは UTC」と明示する
ひと言でまとめると
保存は UTC、表示は利用者のタイムゾーン、解析はタイムゾーンを明示して。 この 3 つを覚えておけば、「時刻が合わない」問題のほとんどを防げます。
同じ瞬間を 5 通りに書く
「2026-10-19 02:00:00 UTC」という 1 つの瞬間を 5 通りに書いてみると、違いがどこにあるか一目でわかります。
1792375200—— Unix 秒。絶対値で、曖昧さなし1792375200000—— Unix ミリ秒。絶対値で、曖昧さなし2026-10-19T02:00:00Z—— ISO 8601。末尾の Z が UTC を表す。絶対値2026-10-19T10:00:00+08:00—— ISO 8601。オフセットを明示。絶対値2026-10-19 10:00:00—— タイムゾーンがまったくない。これは絶対時刻ではない。解析する機械のタイムゾーンがそのまま当てはまる
前の 4 つは時間軸上の同じ 1 点を指しますが、5 つ目は機械ごとに違う点を指します。「時刻が合わない」という議論のほとんどは、5 つ目を前の 4 つと同じ感覚で使ってしまうことから生じます。
既定の挙動は製品ごとに違う
ここが最も問題になりやすいところです。同じ「時刻を保存する」という行為でも、技術スタックによって扱いがまったく異なります。
- MySQL:
TIMESTAMPは接続のタイムゾーンで UTC に変換して保存し、読み出し時に接続のタイムゾーンへ戻します。DATETIMEはそのまま保存し、変換しません。フィールド型を選び間違えると、別のタイムゾーンのクライアントが読んだときに値が変わります - PostgreSQL:
timestamptzは UTC で保存しタイムゾーンの意味も保持しますが、timestampはタイムゾーンを持ちません。公式ドキュメントは前者を優先して使うよう明確に勧めています - Java:
Instantは絶対的な時点、LocalDateTimeはタイムゾーンを持ちません。LocalDateTimeをそのまま文字列にシリアライズしてフロントエンドへ渡すと、タイムゾーン情報はその場で失われます - Python:
datetimeは naive(タイムゾーンなし)と aware(あり)に分かれ、両者を引き算すると例外になります。zoneinfoで明示的にタイムゾーンを付けるのが現在の標準です - JavaScript:
Dateの内部は常にミリ秒のタイムスタンプですが、getHours()やtoString()は実行環境のタイムゾーンで出力し、toISOString()は常に UTC で出力します
共通する法則は 1 つだけです。「ローカル時刻」という名前の型はタイムゾーン情報を持たず、機械をまたいだ瞬間に歪む。
タイムゾーンは略号ではなく IANA 名で書く
CST という略号は、中国標準時(UTC+8)、米国中部標準時(UTC−6)、キューバ標準時(UTC−5)の 3 つを同時に指し得ます。EST も安全ではありません。夏時間を扱えず、米国東部は夏時間の期間に実際には EDT(UTC−4)で、EST より 1 時間進んでいます。
正しい方法は IANA のタイムゾーン名で書くことです。たとえば Asia/Shanghai、America/New_York、Europe/Berlin。これらの名前は完全な夏時間の規則を内包しており、システムのタイムゾーンデータベースが管理しているので、年をまたいでも正しく動きます。
見落とされがちな事実をもう 1 つ。中国は 1986 年から 1991 年まで夏時間を実施しており、その数年の夏季は実際には UTC+9 でした。その年代の履歴データを扱うなら、Asia/Shanghai なら正しく計算できますが、UTC+8 を固定で書くと 1 時間ずれます。
調査の前に問う 3 つの質問
- この値は絶対時刻か、壁時計の時刻か? タイムスタンプなら問題は必ず表示側にあります。文字列なら、まずどのタイムゾーンで書かれたのかを確認してください
- 生成した側、保存した側、表示する側のタイムゾーンは揃っているか? 揃っていなければ必ず換算が入り、その換算コードが容疑者になります
- 差は整数時間か? 8 時間、5.5 時間(インドは UTC+5:30)の差はタイムゾーンの問題です。数秒、数十秒の差はむしろ時計同期や精度の問題で、タイムゾーンとは無関係です
この 3 つの質問で、コードを読む前に可能性を半分に削れます。ログをひっくり返すよりずっと速いです。