廣瀬製紙株式会社

Employees' Blog 社員ブログ

タイムゾーンのない日時をJSONでやり取りすると、更新や削除が静かに壊れる理由
壁時計の数字が同じでも瞬間がずれる理由、書式と解釈をそろえる考え方、Temporalによる型分離までが分かる

公開日: 2026.08.17 更新日: 2026.08.17
「同じ数字、違う時刻」というキャッチコピーとともに、アナログの壁時計とデジタルな日時の数字列が並んで描かれた絵

更新も削除も、ある日突然落ちるようになった

あるAPIを想像してください。GETでレコードを取得すると、レスポンスのupdatedAtというフィールドに更新日時が入っています。クライアントはその値をそのまま保持しておき、更新や削除のリクエストを送るときに一緒に送り返します。

サーバー側は、送られてきたupdatedAtといま自分が持っている値を突き合わせ、一致していれば「他の誰かが先に更新していない」とみなして処理を進めます。これは楽観排他(楽観的ロック)と呼ばれるやり方で、同時編集を検知するためのよくある仕組みです。

このupdatedAtの中身は、データベースのタイムゾーンを持たない日時の列から来ています。列に入っているのは2026-04-15T10:30:00という数字の並びだけで、それがどの地域の時刻なのかという情報はどこにも書かれていません。

サーバーはこの数字をどうJSONに乗せていたのでしょうか。実際には、末尾にZを付けて2026-04-15T10:30:00.000Zという形で返していました。表示側で時刻がずれて見える、という報告を受けてこの返し方だけを直したところ、今度は更新と削除のリクエストがまとめて失敗するようになりました。

サーバーは、いったい何を直して、何を壊したのでしょうか。ここから順に確かめます。

壁時計と瞬間、何が違うのか

時刻には、実は2つの数え方があります。ひとつは「壁時計」です。壁にかかった時計を見て「10時30分」と読むときの、その地域のカレンダーと時計の数字そのものを指します。

もうひとつは「瞬間」です。地球上のどこで区切っても同じ一点を指す、いわば共通の物差しで、コンピュータの世界では協定世界時(UTC)からの経過時間として表します。

この2つは、同じ「10時30分」という言葉でも意味が違います。東京の壁時計が10時30分を指しているとき、ニューヨークの壁時計はまだ前日の21時30分あたりです。けれど、どちらも同じ瞬間を指しています。

壁時計の時刻を瞬間に変換するには、UTCからどれだけずれているかという「オフセット」が要ります。東京なら+09:00、つまりUTCより9時間進んでいる、という具合です。オフセットが分かって初めて、壁時計の数字と瞬間が1対1で結びつきます。

瞬間を数値で表す方法のひとつが、1970年1月1日0時(UTC)からの経過ミリ秒、いわゆるエポックミリ秒です。JavaScriptのDateオブジェクトは、内部ではこの数値だけを持っています。

この2つをテキストでやり取りするための書式が、ISO 8601や、それを整理したRFC 3339です。RFC 3339にはZや+09:00のようなオフセットを書く決まりがあり、これがまさに壁時計から瞬間への変換に使う情報です。

症状を手元で再現する

実際に手元で確かめてみます。環境はNode.js v22.12.0、Windows 11、タイムゾーンの既定値はAsia/Tokyoです(以降の実測はすべて同じ環境で、条件を変えたところだけ明記します)。

壁時計の「10:30」という数字が、そのまま読まれる経路と「Z」を付けて瞬間として読まれる経路に分かれ、後者が9時間ズレることを示す図
図 1: 同じ壁時計の数字が、Zを付けた瞬間として読まれると9時間ずれる
const wall = '2026-04-15T10:30:00'; // タイムゾーンを持たない日時の列に入っている数字はこれだけ

const fromDb = new Date(wall + 'Z'); // 数字の末尾にZを付けてUTCとして扱った
const wire = JSON.stringify({ updatedAt: fromDb });
console.log(wire); // {"updatedAt":"2026-04-15T10:30:00.000Z"}

受け手がこの値を標準どおりに解釈するとどうなるでしょうか。

const back = new Date(JSON.parse(wire).updatedAt); // 受け手は標準どおり「瞬間」として解釈する
// back を東京のローカル時刻の壁時計に戻すと 2026-04-15T19:30:00

手元の実測では、受け手の壁時計は2026-04-15T19:30:00になり、元の2026-04-15T10:30:00とは一致しませんでした。ちょうど9時間ずれています。なぜこんなことが起きるのでしょうか。答えは、Zという1文字が持つ意味にあります。

なぜオフセットが無いと地域任せになるのか

RFC 3339は、時刻を書くときのオフセットを必須にしています。文法上、date-timeはfull-timeを含み、full-timeはtime-offsetという要素を持たなければなりません。time-offsetに入るのはZ(UTCそのもの)か、+09:00のような具体的なオフセットのどちらかで、どちらも書かない「ただの数字の並び」はそもそも正式な時刻として認められていません。

RFC 3339自身も、オフセットを省いたローカル時刻については相互運用性の問題があるためインターネットでは受け入れがたいと述べています(§4.4)。地球上のおよそ24分の23の地域では、オフセット無しのローカル時刻の解釈が失敗するというのがその理由です。

余談ですが、RFC 3339には-00:00という特殊な書き方もあります(§4.3)。これはZや+00:00と違い、「UTCの時刻は分かるが、送信元のローカルなオフセットは分からない」ということを表す約束です。日常のAPI設計で使う機会は多くありませんが、見た目が似ているぶん、意味の違いは知っておいて損はありません。

ところが、JavaScriptのDateはRFC 3339に厳密には従っていません。MDNの説明はこうです。

“When the time zone offset is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time. The interpretation as a UTC time is due to a historical spec error that was not consistent with ISO 8601 but could not be changed due to web compatibility.” — MDN Date

日本語にすると、オフセットが無いとき、日付のみの形式(2026-04-15)はUTCとして、日時形式(2026-04-15T10:30:00)はローカル時刻として解釈される、という規則です。MDNはこれを、ISO 8601とは整合しない仕様上のミスに由来する挙動であり、Web互換性のために変更できずに残っている、と説明しています。

本当にランタイムのタイムゾーンで結果が変わるのでしょうか。同じ2026-04-15T10:30:00という文字列を、タイムゾーンだけ変えて4通り試してみます。

地球儀の上に「東京」「ニューヨーク」「UTC」の時計が並び、同じ「10:30」という壁時計の数字がそれぞれ違う瞬間を指すことを示す図
図 2: 同じ壁時計の数字が、地域によって違う瞬間として読まれる
タイムゾーン2026-04-15T10:30:00の解釈(UTC換算)
UTC2026-04-15T10:30:00.000Z
Asia/Tokyo2026-04-15T01:30:00.000Z
America/New_York2026-04-15T14:30:00.000Z
Pacific/Kiritimati2026-04-14T20:30:00.000Z

同じ2026-04-15T10:30:00という文字列が、実行環境のタイムゾーンによって最大で18時間ほど違う瞬間になります(手元の実測)。一方、Zや+09:00のようにオフセットを付けた文字列は、どのタイムゾーンで実行しても同じ瞬間になりました。オフセットの有無が、この差の分かれ目です。

「等しい」には2通りある

楽観排他の仕組みに戻ります。サーバーは送られてきたupdatedAtと、自分が持っている値が「等しいか」を判定します。ところが「等しい」には、実は2つの意味があります。

ひとつは文字列としての一致、もうひとつは瞬間としての一致です。この2つは、表記が違うだけで指している瞬間が同じ場合には食い違います。

const a = '2026-04-15T10:30:00.000Z';
const b = '2026-04-15T19:30:00.000+09:00';
a === b;                                          // false(文字列としては別)
new Date(a).getTime() === new Date(b).getTime();  // true(瞬間としては同じ)

逆に、同じ数字の並びに違うオフセットを付けた場合は、文字列としても瞬間としても一致しません。手元の実測では、2026-04-15T10:30:00.000Zと2026-04-15T10:30:00.000+09:00の差はちょうど9時間、ミリ秒にすると32,400,000でした。

冒頭のAPIが更新と削除を落とすようになったのは、この2通りの「等しい」を取り違えたからです。書き出す側だけが+09:00付きの新しい表記に変わり、受け取って突き合わせる側は、入力を瞬間として解釈する処理のままでした。同じ2026-04-15T10:30:00という数字なのに、実際には9時間ずれた別の瞬間として扱われてしまったわけです。

これは、さきほどのaとb(同じ瞬間・違う表記)とは逆のパターンで、同じ数字・違うオフセットの組み合わせにあたります。手元の実測どおり、その差はちょうど32,400,000ミリ秒(9時間)あるので、突合はいつまでも一致しません。書式(どう書き出すか)と解釈(どう読み込むか)は、本来ワンセットの約束事で、片方だけを直すと、もう片方との噛み合わせがずれて、こういう壊れ方をします。

では、なぜこんな単純な話が見落とされやすいのでしょうか。理由のひとつは、壁時計の数字を見ていると、それがそのまま「瞬間そのもの」だと錯覚しやすいことにあります。

壁時計には、存在しない時刻と2回ある時刻がある

夏時間(DST)を採用している地域では、年に2回、時計の針を進めたり戻したりします。針を進める瞬間には、存在しない壁時計の時刻が生まれます。針を戻す瞬間には、逆に同じ壁時計の時刻が2回訪れます。

たとえば2026年のアメリカ東部(America/New_York)では、3月8日の2時に時計を1時間進め、11月1日の2時に1時間戻します。3月8日の02:30という壁時計の時刻は、その地域には一度も存在しません。

この02:30をNode.js(TZをAmerica/New_Yorkにして実行)の標準パーサに渡すとどうなるでしょうか。手元の実測では、例外は出ずに03:30(針を進めた後の側)へ静かに読み替えられました。

一方、11月1日の01:30は2回訪れる時刻です。標準パーサは早いほう(まだ針を戻す前の-04:00側)を選んでいました。書き戻した文字列だけを見ていると01:30のままなので気づきませんが、内部で保持している瞬間は、後から来る01:30とは1時間ずれています。

夏時間の切り替えを表すタイムライン上に「02:30 存在しない」と「01:30 2回ある」というラベルが示された図
図 3: 存在しない時刻と、2回ある時刻

文字列を送り返すたびに元の表記へ戻るなら、それで十分なのではないでしょうか。実はそうとも言い切れません。2回ある01:30のどちらだったかという情報は、文字列の往復だけでは取り戻せないからです。この実測は1つの地域・1つの年で確かめただけで、どちらを選ぶかは仕様が定めているわけではなく、他のランタイムで同じとは限りません。

移行のあいだ、3通りの表記が同時に飛んでくる

書式を変える作業には、もうひとつ厄介な事情があります。サーバー側の書式を一度に切り替えられればよいのですが、実際には移行期間中、新旧の表記が入り混じって届きます。

たとえば2026-04-15T10:30:00.000+09:00(オフセット付き)、2026-04-15T10:30:00.000Z(UTC扱い)、2026-04-15T10:30:00(タイムゾーン無し)という3通りの表記が、同じ壁時計を指しているつもりで飛んでくる、という状況です。

対処のひとつは、オフセットやZを無視して、先頭の桁だけを壁時計として読むパーサを自分で用意することです。手元の実測では、この桁読みパーサに3通りの表記をすべて通すと、同じひとつの値に潰れました。一方、標準のDateにそのまま渡すと、瞬間としては2種類に割れます(+09:00と無指定は同じ瞬間、Zだけ別の瞬間)。この「桁を読むパーサはタイムゾーンが何であっても1種類に潰れるはず」という予想は、実行環境のタイムゾーンを4通り変えて試しても崩れませんでした。地域が変わっても結果が変わらないことこそ、桁読みパーサを使う狙いです。

ここでもうひとつ、「往復する」という言葉の意味を分けておきます。ひとつは値の往復です。手元にある値を書き出し、また読み込んだら元の値と一致するか、というものです。桁読みパーサでは、この値の恒等式は成り立っていました。

もうひとつは文字列の往復です。ある文字列を読み込み、また書き出したら元の文字列と一致するか、というものです。桁読みパーサが吐き出す正規形はひとつだけなので、入力が3通りあれば、そのうち2通りは書き出しても元の文字列には戻りません。値がきちんと揃っていることと、表記が変わらずに残ることは、別の話だと考えておくとよさそうです。

では、桁を読むだけのパーサに任せておけば安心なのでしょうか。残念ながら、そう単純でもありません。

受け取る形を絞る

標準パーサは、RFC 3339やECMA-262が定めた形式以外の文字列も、実は結構通してしまいます。仕様外の形式をどう扱うかはランタイムの実装依存で、MDNのDate.parse()も「その他の形式は実装依存であり、すべてのブラウザで動くとは限らない」と説明しています。

入力Node.js v22.12.0(Windows、Asia/Tokyo)での結果
2026-04-15 10:30:00(スペース区切り)通る
2026-4-15T10:30:00(ゼロ埋め無し)Invalid Date
April 15, 2026 10:30:00(英語表記)通る
15/04/2026(スラッシュ区切り)Invalid Date
2026-04-15T10:30:00+0900(コロン無しオフセット)通る

とくに気をつけたいのが最後の行です。+0900のようにコロンを省いたオフセットは仕様の形式には含まれませんが、この実装では通ってしまい、しかも+09:00と同じ瞬間になりました。通ってしまう形式のほうが、通らない形式よりもかえって危険です。「動いているから正しい」と思い込んで、仕様に無い形式に依存したコードが書かれてしまうかもしれません。

APIとしてやり取りする形式は、受け取る側でも明示的に絞り込むほうが安全です。仕様が定めた形式(オフセット必須のRFC 3339)だけを受け付け、それ以外は拒否する、という方針が無難でしょう。

型で壁時計と瞬間を分ける

RFC 3339には、後発の拡張仕様があります。RFC 9557は、2022-07-08T00:14:07Z[Europe/London]のように、角括弧でタイムゾーン名などの注釈を付けられるようにしました。オフセットだけでは分からない「どの地域の時計か」という情報を、追加で持たせられるようになったわけです。

地域の情報を文字列の外側に付けるだけでなく、そもそも型で分けてしまう、というアプローチもあります。ECMAScriptのTemporalはその1つで、MDNはPlainDateTimeを「地域を持たない、暦の日付と壁時計の時刻」と説明しています。

Temporalにはいくつかの型があります。PlainDateTimeは壁時計だけを表し、地域の情報を持ちません。Instantは瞬間だけを表し、生成するときには必ずオフセットが要ります。ZonedDateTimeは、瞬間と地域名の両方を持つ型です。

「PlainDateTime」「Instant」「ZonedDateTime」という3つの箱が並び、壁時計と瞬間の情報をそれぞれどう持つかを示す図
図 4: Temporalは壁時計と瞬間を別の型に分ける
型何を表すかオフセット無しの文字列を渡すと
PlainDateTime壁時計(地域を持たない日付と時刻)受け取れる
Instant瞬間(地域を持たない一点)例外になる
ZonedDateTime瞬間+地域名例外になる(角括弧の地域名が要る)

手元でも確かめてみました。環境はNode.js v22.12.0で、この環境にはTemporalがまだ組み込まれていないため、ポリフィル(仕様をJavaScriptだけで再現したライブラリ)の@js-temporal/polyfill(バージョン0.5.1)を使っています。オフセットの無い2026-04-15T10:30:00という文字列をPlainDateTime.fromに渡すと問題なく受け取れましたが、Instant.fromとZonedDateTime.fromはどちらも例外を投げました。これはポリフィルでの実測であり、Temporalのネイティブ実装を確かめたものではありません。

存在しない壁時計や2回ある壁時計をZonedDateTimeに変換するときは、disambiguationというオプションでどちらの瞬間を選ぶか指定できます。既定では黙ってどちらかに決まりますが、rejectを指定すると、あいまいな場合に例外を投げさせることもできます。手元の実測でも例外が投げられることは確認できましたが、例外メッセージの文言はポリフィルのバージョンに依存するため、ここでは引用しません。

Temporalは、JavaScriptの仕様を策定する委員会であるTC39の審査で、ステージ4に到達しています。tc39/proposal-temporalのREADMEには「本提案は現在ステージ4です」と書かれており、ステージ4は仕様として確定し、あとは各ランタイムへの実装を待つ段階を意味します。実際にFirefox 139、Chrome 144、Node.js 26で実装が出荷されています(ただし今回の実測に使ったNode.js v22.12.0にはまだ入っていません)。

壁時計と瞬間を型で分けてしまえば、書式と解釈の食い違いは起きなくなるのでしょうか。少なくとも、Instantにオフセット無しの文字列を渡そうとした時点で例外が飛ぶので、冒頭のAPIのような取り違えには、実行時点で気づけるようになりそうです。

書式と解釈は1つの契約

ここまでの話を、冒頭のAPIに当てはめ直してみます。データベースの列にはタイムゾーンを持たない壁時計の数字が入っていて、サーバーはそれをそのまま瞬間として送り出していました。表示のずれを直そうとして書式だけを変えたことで、送り出す表記と、受け取って突き合わせる側の解釈がずれ、更新や削除が軒並み失敗するようになったのでした。

直す側からすると、書式(どう書き出すか)を変えるなら、解釈(どう読み込むか)も同時に見直す必要があります。データベースの列がタイムゾーンを持たないなら、オフセット付きの壁時計として一貫させるか、いっそUTCの瞬間として保存し直すか、どちらかに決めて両端で揃えるのが安全です。

移行の途中でどうしても複数の表記が混在するなら、桁を読むだけの壁時計パーサを用意して、地域に依存しない形で揃えるのも一つの手です。長期的には、Temporalのように型そのもので壁時計と瞬間を分けてしまう方向へ進んでいくのでしょう。

自分が今扱っている日時の列は、壁時計でしょうか、それとも瞬間でしょうか。JSONで送り出す前に、一度確かめてみる価値はありそうです。

参考にした一次情報

  • RFC 3339(§5.6のABNFでtime-offsetが必須であること、§4.3の-00:00の意味、§4.4のオフセット無しローカル時刻についての記述に使用)
  • RFC 9557(角括弧によるタイムゾーン注釈の説明に使用)
  • ECMA-262 Date Time String Format(ECMAScriptの日時文字列形式の定義に使用)
  • MDN Date(オフセット無しの解釈規則の引用に使用)
  • MDN Date.parse()(仕様外の形式が実装依存であることの根拠として使用)
  • MDN Temporal(PlainDateTimeが壁時計を表すという説明に使用)
  • tc39/proposal-temporal(ステージ4への到達と実装状況の記述に使用)

この記事を書いた人

情報企画チーム 松村 晶(まつむら あき)

2024年11月廣瀬製紙株式会社入社。

書店・福祉・飲食業などを経験したのち、システム開発畑に転向。

転職をきっかけに生まれの地である高知市に移住し、現在は社内SEとして、社内のDBシステム開発やDX関連のシステム開発を担当している。

この著者の記事を見る →