DevKitLab Logo DevKitLab
タイムスタンプ / タイムゾーン / JavaScript / デバッグ

Unix タイムスタンプがずれるのはなぜ?秒・ミリ秒・タイムゾーン

時刻がずれて表示されるタイムスタンプは、ほとんどの場合、数値が壊れているのではなく、カテゴリの取り違えです。Unix タイムスタンプは日付ではなく、タイムゾーンも持ちません。ある固定した瞬間から数えた、ただ一つの秒数です。「瞬間」と「その描画のされ方」を切り分ければ、「数十年ずれる」「三時間ずれる」「一日ずれる」はそれぞれ、具体的で突き止められる一つの原因に収束します。

タイムスタンプを記録したら、ずれて出てきた。派手にずれることもある——今朝作ったレコードが、1970 年 1 月に起きたと言い張ったり、西暦 55840 年に飛んだり。静かにずれることもある——時刻は合っているのに三時間ずれていたり、15 日のつもりが 14 日に落ちたり。そこであなたはパッチを当てはじめる。何かを掛ける、数時間引く、もう一段の変換で包む。どのパッチも目の前の一例では直り、次の一例で壊れる。症状に手を当てているだけで、すべてを一度に説明する唯一の事実をつかんでいないからだ。

その事実はこうだ。Unix タイムスタンプは日付ではなく、タイムゾーンも持たない。 それはただ一つの整数——ある固定した瞬間 1970-01-01T00:00:00Z から経過した秒数——であり、この同じ整数が、地球上のどこであっても同じ瞬間を指す。それは今が何年か、あなたがどの暦を使うか、東京にいるのかシカゴにいるのかを知らない。そうした意味はすべて後から付け足される——数値を、人間が読める何かへ描画するその瞬間に。「タイムスタンプが違う」というバグのほとんどは、そもそも数値が違うのではない。それはカテゴリの取り違えだ。「瞬間」と「その表示のされ方」を混同しているか、値を、別の単位を期待している関数に渡しているかのどちらかである。

この記事はこの二つの層を切り分け、切り分けたまま保つ。まずタイムスタンプが実際に何であるか——一つの数値、ゾーンなし。それから、二つの層が交わってしまう数か所を歩く。あなたを 1970 年へ放り込む秒/ミリ秒の取り違え、正しい瞬間を数時間ずらすタイムゾーンの混乱、日付を丸一日動かす文字列パースの規則、そして夏時間まわりの暦計算の罠。読み終える頃には、ほぼどんなタイムスタンプのバグも一つの具体的な原因に絞り込める、短いチェックリストが手に入る。

唯一の核心:瞬間は数値であり、日付はその描画である

層は二つあり、ほぼすべてのバグは、その二層のあいだの隙間に棲む。

  1. 瞬間(instant)。 宇宙のタイムライン上の、絶対的な一点。計算機の中では、たいてい Unix タイムスタンプ——一つの整数、タイムゾーンなし、全員にとって同じ値だ。1700000000 は特定の一瞬であり、サンパウロでもソウルでも同時に、その同じ瞬間を指す。
  2. 描画(rendering)。 人間が読める日付と時刻——"2023-11-14 22:13:20""11月14日 17:13"——であり、これはどのタイムゾーンで表現されているかを同時に言ってはじめて意味を持つ。同じ瞬間でも、描かれる壁時計の文字列はゾーンごとに違う。

瞬間は、あなたが保存し、送り、比較する「真実」だ。描画はその一つのビューにすぎず、ある人間のために、あるゾーンで、ある地域書式で、必要に応じて生成される。この二語——瞬間描画——を頭に置いたまま最後まで読んでほしい。この先のどの罠も、同じ誤りの変奏だからだ。描画を瞬間だと思い込むか、両者を変換するときに単位かゾーンを取り違えるか、そのどちらかである。

Unix タイムスタンプとは実際に何か

ツール層を剥がすと、Unix タイムスタンプは気恥ずかしいほど単純だ。それは 1970-01-01T00:00:00Z から経過した秒数を数える。 この任意に定めた原点は Unix エポック(epoch) と呼ばれる。値 0 は 1970 年のその真夜中、1700000000 はそのおよそ 53.9 年後、1970 年より前の時刻は単に負になる。

これについて、人がつい取り違えがちな点が二つある。

  • 描画された日付のように「UTC の中にある」わけではない。 数値そのものはどんなゾーンも帯びていない。UTC が現れるのは二か所だけ——エポックのラベルとして(この計数は UTC の真夜中、1970 年から始まる)、そしてあなたがふつう描画先にする中立なゾーンとして。整数 1700000000 は UTC 時刻でも現地時刻でもない。それは瞬間であり、「UTC」はそれを声に出して読むのに一番都合のいい方言にすぎない。
  • うるう秒を無視する。 現実の UTC には、地球のわずかに不規則な自転に時計を合わせるため、1972 年以来 20 数個のうるう秒が挿入されてきた。Unix 時間はそれらが一度も起きなかったかのように振る舞う——どの日も、ちょうど 86,400 秒として扱う。おかげで計算は綺麗で可逆になる(単純な割り算でタイムスタンプと日付を行き来できる)が、その代わり、この計数は物理的な秒の完全に忠実な集計ではなくなる。日々の作業でこれが問題になることはない。「エポックからの秒数」という言い回しに惑わされて天文学的な精度を期待しないため、知っておく価値があるだけだ。

この単純さこそが、その魅力のすべてだ。二つの瞬間を比べるのは、二つの整数を比べること。イベントを並べ替えるのは、数値を並べ替えること。暦もゾーンも曖昧さもない——人間の日付との間で変換するまでは。そして、まさにそこからバグが始まる。

一番のバグ:秒 vs ミリ秒

タイムスタンプがずれる最も一般的な原因であり、失敗があまりに派手なので独立した節に値する。「1970 年からの計数」には、競合する二つの慣習がある。

  • Unix / POSIX の慣習:秒。 コマンドラインの date +%s、JWT の expiat クレーム、たいていのデータベースのエポック関数、たいていの Unix システムコール——すべて秒だ。
  • JavaScript の慣習:ミリ秒。 Date.now()new Date().getTime() はエポックからのミリ秒を返す。秒の千倍だ。

この二つを混ぜると 1000 倍ずれる——タイムライン上では、それは数十年のずれになる。JavaScript での典型例:

// 「秒」単位の Unix タイムスタンプ。たとえば API や JWT の exp クレームから:
const ts = 1700000000;

new Date(ts);        // Tue Jan 20 1970 —— Date コンストラクタが欲しいのは「ミリ秒」
new Date(ts * 1000); // Tue Nov 14 2023 —— 正しい

new Date(1700000000) はエラーにならない。あなたの秒を陽気にミリ秒として解釈し、エポックの 17 億ミリ秒後(およそ 20 日)に着地して、1970 年 1 月の日付を差し出す。逆向きに、JavaScript のミリ秒から秒が必要なら、1000 で割って切り捨てなければならない。

Math.floor(Date.now() / 1000); // 「秒」単位の Unix タイムスタンプ

現在の時代における見分け方は桁数だ。秒のタイムスタンプは 10 桁(1700000000)、ミリ秒のタイムスタンプは 13 桁(1700000000000)。17 億前後の値が 1970 年の日付を出したなら、ミリ秒を欲しがる何かに秒を渡している。1.7 前後の値が数万年先の日付を出したなら、逆をやっている。手元がどちらの単位か分からないときは、生の数値を Unix タイムスタンプ変換ツール に貼ってみるといい。両方の読み方で解釈し、どちらがもっともらしい日付に落ちるかを見せてくれる。当てずっぽうではなく、見ることで単位が決まる。

タイムスタンプにタイムゾーンはない——混乱は書式化にある

次は、より静かな失敗だ。日付はほぼ合っているのに、数時間ずれている。ここで「数値を直す」という本能はまさに間違いだ。数値は問題ない。 あなたは正しい瞬間を、間違ったタイムゾーンで描画しているだけだ。

瞬間はどんなゾーンも帯びない、を思い出してほしい。それをテキストに変えるとき、あなた——あるいは言語の既定値——が一つのゾーンを選び、同じ瞬間が異なる壁時計文字列を生む。

const d = new Date(1700000000 * 1000);

d.toISOString();    // "2023-11-14T22:13:20.000Z" —— 常に UTC。末尾の Z が目印
d.toString();       // "Tue Nov 14 2023 17:13:20 GMT-0500 ..." —— ランタイムの現地ゾーン
d.toLocaleString(); // ロケールとゾーンに依存した描画

上のどの行も、同じ瞬間を記述している。22:13:20Z と、その 5 時間後ろの 17:13:20 は、二つの異なる時刻ではない——一つの瞬間を、二つのゾーンで名付けたものだ。だからログの一行が「本来より三時間早い」と言うとき、たいていの原因はタイムスタンプの破損ではない。現地時刻を期待した場所で値が UTC に書式化されたか、ユーザーのゾーンを期待した場所でサーバーのゾーンに書式化されたかだ。直し方は、表示の時点でゾーンを制御すること。保存した値に時間を足し引きすることではない——数値にオフセットを焼き込んだ瞬間、唯一正しかったものを壊してしまう。

これは時刻を保存・送信するときの規則でもある。瞬間はゾーンに対して中立に保て——Unix タイムスタンプか、2023-11-14T22:13:20Z のように明示的オフセット付きの ISO 8601 文字列で——そして、人間が読む必要のあるいちばん端でだけ、ゾーンをかぶせよ。(この二つの記法——整数のタイムスタンプか ISO 文字列か——を、データベースで実際にどちら選ぶかは、保存のトレードオフを扱う別の記事がある。) オフセットのない裸の現地壁時計テキスト("2023-11-14 17:13:20")として書かれた時刻は、それがどの瞬間だったかを知るのに要る情報を、すでに捨ててしまっている。一つの瞬間が、ユーザーが実際に暮らす各ゾーンでどう見えるかを確かめるには、タイムゾーン変換ツール が横並びにしてくれる。これは「数時間ずれ」バグも一目瞭然にする——瞬間は一貫していて、変わるのはラベルだけだ、と。

パースの落とし穴:この文字列はどのゾーンのつもりだったのか

文字列を瞬間へ戻すにも、独自の鋭い刃がある。そしてそれは「日付が一日進んでいる」たぐいのバグの大きな部分の犯人だ。JavaScript では——そしてこの挙動は仕様で定められており、特定エンジンの癖ではない——日付文字列がどのゾーンにあると仮定されるかは、時刻部分を含むかどうかで決まる。

new Date("2026-07-15");            // 日付のみ → UTC の真夜中としてパース
new Date("2026-07-15T00:00:00");   // 日付 + 時刻、オフセットなし → 「現地時刻」としてパース

二度読んでほしい。本当に直感に反するからだ——時刻部分を足すと、仮定されるゾーンが反転する。日付のみの ISO 文字列は UTC として解釈される。オフセットなしの時刻を付け足した途端、標準はランタイムの現地ゾーンでの解釈へ切り替わる。見た目はほとんど同じ文字列、しかし異なる瞬間だ。

目に見える被害は、暦上のずれだ。ロサンゼルス(夏は UTC−7)のブラウザが次を実行したとする。

new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"

あなたは「7 月 15 日」をパースしたのに、画面は 7 月 14 日を映す。何も壊れていない——文字列は 2026-07-15T00:00:00Z と読まれ、太平洋時間では 14 の午後 5 時だ——が、ユーザーには、アプリが一日遅れているように見える。負オフセットのゾーン(南北アメリカのすべて)ならどこでもこれが起こりうる。だからこのバグは、UTC+いくつかの地にいるチームからは気づかれないまま本番へ届きがちだ。

防御はたった一つの習慣だ。既定のゾーンに頼るな——どの日時文字列にも明示的オフセットを付けよ。 UTC のつもりなら 2026-07-15T00:00:00Z、特定の現地の一瞬のつもりなら 2026-07-15T00:00:00-07:00 と書く。オフセットがあれば、どのパーサも瞬間について一致し、「日付のみ vs 日時」の罠は完全に消える。

別の理由で一日ずれる:暦計算と夏時間

パースが綺麗でも、日付に対する算術には、裸の整数にはない罠がある。瞬間は数値なので、瞬間どうしの差は安全だ——二つのタイムスタンプを引けば、ゾーン抜きで正確な秒数が得られる。危険なのは、描画された暦の上で計算することだ。

二通りの噛みつき方をする。

  • 「24 時間足す」は「明日の同じ壁時計時刻」ではない。 ある地域が夏時間で進む/戻る日には、現地の一日は 23 時間または 25 時間になる。春に進む前日の午前 9:00 に 86,400 秒を足すと、着くのは午前 10:00 であって 9:00 ではない。「オフセットがどうであれ明日の朝 9 時」が本当に欲しいなら、固定の秒数を足すのではなく、現地の暦の上でその推論をしなければならない。
  • 存在しない壁時計時刻があり、二度起きる壁時計時刻もある。 時計が進むとき、飛ばされた一時間(たとえば 2:30)は、その日一度も起きない現地時刻だ。時計が戻るとき、繰り返される一時間は二度起きるので、その窓の中の裸の現地時刻は、どの瞬間を指すのか曖昧になる。

信頼できるやり方は、この記事がずっと立ち返っているものだ。比較と経過時間はゾーン中立な瞬間の上で行い、現地の暦に触れるのは表示のため、あるいは本当に暦に基づく問い(「1 日まであと何日?」)のときだけにする。そして後者には、手書きの算術ではなくタイムゾーン対応のライブラリを使う。このありふれて地味な用途——二つの日付の間は何日か、90 日後は何日か、あるものは何歳か——には、日付計算ツール が、途中に DST の境界が挟まったかを気にせずに数えてくれる。

視野を広げて:2038 年問題

多くの古いシステムには、上限の壁が組み込まれている。タイムスタンプが符号付き 32 ビット整数の秒数として保存される場合、収められる最大値は 2^31 − 1 = 2147483647、Unix 時間にすると次だ。

2038-01-19T03:14:07Z

その一秒後、カウンタは符号付き 32 ビット整数の上限を越えてオーバーフローし、大きな負の数へ巻き戻る——日付を 1901 年 12 月へひっくり返す。これが 2038 年問題(「Y2038」または「Unix 終末」)だ。Y2K の直系の子孫であり、現代の 64 ビットシステムで time_t が 64 ビットへ拡張された理由でもある(これで壁はおよそ 2920 億年先——実質的に永遠に来ない——へ押しやられた)。それでもなお、組み込み機器、古いバイナリ/ネットワーク形式、32 ビットのエポック列を持つデータベースに潜んでいる。たとえば MySQL の TIMESTAMP 型は歴史的に 2038 年で頭打ちだったが、DATETIME はそうではなかった。遠い未来の日付が静かに 1901 年へ潰れたら、32 ビットオーバーフローが第一容疑者だ。

同じ符号は、過去に向けても逆に切る。整数が符号付きだからこそ、1970 年より前の日付は合法で、単に負になる(-1000000000 は 1938 年 4 月)。タイムスタンプを符号なしだと誤って仮定するコードは、エポック以前のあらゆる日付を壊す——誕生日、歴史記録、1970 年代より前へ遡るあらゆるものにとって、これは現実の懸念だ。

視野を広げて:すべてが 1970 年から数えるわけではない

「とんでもない量ずれる」もう一つの原因で、秒/ミリ秒の取り違えではないもの。何かを「タイムスタンプ」と呼ぶすべてのシステムが、Unix エポックから数えるわけではない。 単位が違うことも、原点が違うこともある。実際に出くわすものをいくつか。

  • 表計算ソフト(Excel、Google Sheets)は、1970 年からの秒数ではなく、おおよそ 1899〜1900 年からの日数を数える。だから表計算から生のシリアル番号として貼り付けた日付は、尺度も原点もまったく別物だ。
  • Windows はファイル時刻で、1601 年からの 100 ナノ秒間隔を数える。
  • NTP(ネットワークタイムプロトコル)は、1900 年からの秒数を数える。

だから、どんな変換も信じる前に、両方の座標を突き止めよ。単位(秒? ミリ秒? 日? 100 ナノ秒のティック?)と、エポック(1970? 1900? 1601?)だ。×1000 では説明のつかない形で数十年ずれる値は、単位の不一致ではなく、たいていエポックの不一致だ。手元が何か判じかねるときは、生の数値を タイムスタンプ変換ツール に通し、Unix エポックとしての解釈がまともな日付に落ちるかを確かめるのが、よくあるケースを外すか固めるかの一番速い手だ。

タイムスタンプがずれたときのチェックリスト

次に時刻がずれて出たら、やみくもに掛けたり引いたりを始めないこと。順に辿ってほしい——ほぼすべてのケースを切り分ける。

  1. どの単位か? 桁を数える。この時代では 10 桁 ≈ 秒、13 桁 ≈ ミリ秒。「今」の値が 1970 年の日付を出したなら、ミリ秒を欲しがる何かに秒を渡している(1000 を掛ける)。数万年先の日付を出したなら、逆をやる。
  2. 瞬間か、描画か? 保存した数値にタイムゾーンはない。時刻がきっかり数時間ずれているなら、ほぼ確実に、正しい瞬間を間違ったゾーンで書式化している——直すのは表示時のゾーンで、保存した値に時間を足すのではない。
  3. 文字列をパースしている? オフセットは付いているか? 日付のみの文字列は UTC として読まれ、オフセットなしの日時文字列は現地として読まれる——「一日ずれる」バグの大半の源だ。どの日時文字列にも明示的な Z±hh:mm を付けよ。
  4. 日付の算術をしている? 二つの瞬間を引くのは安全。現地時刻の上で暦の量(「明日」「来月」)を足すのは、23/25 時間の DST の日があるため安全ではない——瞬間の上で、あるいはゾーン対応ライブラリで行い、単純な日数の数え上げはツールに任せよ。
  5. 数十年ずれ、しかも単位は正しい? 別のエポックを疑え——表計算のシリアル番号、1601 年基準のファイル時刻、NTP 値——それはそもそも Unix タイムスタンプではない。そして遠い未来の日付が 1901 年へ潰れたなら、それは符号付き 32 ビットのオーバーフローだ。

この五つの底にあるのは、冒頭のあの唯一の切り分けだ。瞬間はゾーン中立な一つの数値であり、画面上の日付はそれをどこかのゾーンで描画したものだ。 間違っているのは、ほとんどの場合その数値ではない。間違っているのは、入り口で取り違えた単位か、出口で取り違えたゾーンだ——そして、そのどちらが起きたかを言えるようになった瞬間、タイムスタンプは謎ではなくなり、一行で直る何かに変わる。