日付が一日ずれるのはなぜ?タイムゾーンとカレンダー日付
誕生日を 5 月 1 日で保存したのに、4 月 30 日と表示される。イベントを 7 月 15 日に入れたのに、カレンダーは 14 日を映す。日次レポートが、真夜中の直前の行を誤った日に数える。原因はほぼ同じだ——カレンダー日付は時間の一点ではなく、バグは「ゾーンのない日付」が「ゾーンのある瞬間」と出会う境界で起きる。
ユーザーの誕生日を 5 月 1 日として保存したのに、4 月 30 日と表示される。イベントを 7 月 15 日に入れたのに、カレンダーは 14 日を映す。日次レポートを走らせると、ちょうど真夜中の直前に起きた行が、誤った日の下に数えられる。どれも同じバグが姿を変えて現れただけで、いつもの対応——数時間足す、一日引く、もう一段の変換で包む——は目の前の一件を直し、別の二件を壊す。
これら全部の底にある事実はこうだ。カレンダー日付は時間の一点ではない。「2026-07-15」は一つの時刻を指すのではなく、まる一日——現地の真夜中から次の現地の真夜中までの一区間——を指す。そしてその区間は、タイムゾーンごとに別々の瞬間から始まる。15 日の東京の真夜中は、15 日のロサンゼルスの真夜中より十六時間早く訪れる。これを地球全体に広げると、一つのカレンダー日付が地球のどこかで「今日」である時間は、合計でおよそ 50 時間——最も東の UTC+14 の地域でそれが始まってから、UTC−12 でようやく終わるまで——になる。日付は一区間、瞬間は一点だ。「一日ずれる」バグは、この二つの境界で起きる——ゾーンのない日付をゾーンのある瞬間に変えるとき、あるいはゾーンのある瞬間を日付へ絞り戻すとき、意図しないタイムゾーンを使ってしまったときに。
この記事は、その境界を両方向から歩く。日付を瞬間に変える(昨日になってしまう日付のみの文字列)。瞬間を日付に変える(toISOString が明日を告げる)。さらに深い直し方——誕生日や締め切りのような、そもそも瞬間であるべきでない日付を見抜くこと。そして夏時間。同じゾーン内の日付計算さえ滑らせる。さらにレポートの日単位の集計。どのタイムゾーンで切り捨てるかが、一つの行がどの日に落ちるかを決める。これはUnix タイムスタンプがずれる理由とUTC・GMT・ISO 8601 の違いに続く三枚目のピースだ。あの二つは瞬間とゾーンを説明し、この記事は、それらが繰り返しぶつかるカレンダー日付を扱う。
唯一の核心:日付は一区間であって、一瞬ではない
二つを分けて持てば、混乱のほとんどは晴れる。
- 瞬間はタイムライン上の一点だ——Unix タイムスタンプ、
Zで錨を下ろした時刻。全員に、どこでも同じ。 - カレンダー日付——「2026-07-15」——は、現地の真夜中から次の現地の真夜中までの一区間を指す、民用カレンダー上のラベルだ。ふつうはおよそ 24 時間だが、夏時間の切り替えによって短くなったり長くなったりし、23 時間や 25 時間になることもある。唯一の瞬間を持たない。タイムゾーンごとに別々の時刻で始まり、別々の時刻で終わるからだ。
この二つは種類の違うものであり、あらゆる日付ずれのバグは、その間の失敗した変換だ。厄介なのは、その変換がたいてい見えないことだ。日付を Date オブジェクトへ、時刻を伴う ISO 文字列へ、あるいは型や ORM がゾーンの意味を持ち込む timestamp 列へ入れた途端、タイムゾーンがそっと関わってくることがある——ローカルのつもりで UTC を、UTC のつもりでローカルを使えば、日付はどこかの真夜中の反対側へ落ちる。(データベースによってここは大きく異なる——timestamp with time zone、without time zone、そして各 ORM の挙動はそれぞれ違うので、使っているものを確認すること。)何も「壊れて」はいない。ただ、日付と瞬間の境界を、間違ったゾーンで越えただけだ。
方向その一:日付を瞬間に変える
いちばんよくある形だ。手元にはただの日付——"2026-07-15"——があるのに、それを時刻ベースの型に通してしまう。JavaScript では:
new Date("2026-07-15"); // 2026-07-15T00:00:00Z として解釈 —— UTC の真夜中
JavaScript の Date パーサーでは、日付のみの ISO 文字列は UTC の真夜中として解釈される(他の言語・データベース・ライブラリでは異なるので、スタックごとに確認すること)。それは特定の瞬間であり、そのカレンダー日付は、どこで描画するかに左右されるようになる。負のオフセットを持つ場所ならどこでも、その瞬間はまだ前日の夜だ:
// ロサンゼルスのブラウザ(夏は UTC−7):
new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"
15 日を保存したのに、ユーザーは 14 日を見る。瞬間そのものは正しい——2026-07-15T00:00:00Z はまさに頼んだとおりだ——が、「15 日の UTC の真夜中」を南北アメリカから見れば、壁の上ではまだ 14 日だ。直し方は、ただの日付を瞬間に通して洗わないことだ。文字列 "2026-07-15"(または本物の日付型)のまま保ち、ゾーン変換せずに整形する。本当に瞬間が要るなら、タイムゾーンを意識したライブラリ(または Temporal)で、明示的に選んだ IANA ゾーンとしてローカルの壁時計時刻を解釈する——2026-07-15T00:00:00 とだけ書けば、実行環境自身のゾーン(サーバーで動くならサーバーの)で読まれるのであって、どこかのユーザーのゾーンではない。これはタイムスタンプの記事が解析の側から扱う、同じ罠だ。ここは同じコインの保存の側である。
方向その二:瞬間を日付に変える
今度は鏡像で、「言われたとおり全部 UTC でやった」人の不意を突く。正しい瞬間があり、その日付が欲しいので、手早い方法に頼りたくなる:
// 実在の時刻:ニューヨークの 7 月 15 日 22:00(夏は UTC−4)
const d = new Date("2026-07-15T22:00:00-04:00");
d.toISOString(); // "2026-07-16T02:00:00.000Z"
d.toISOString().slice(0,10);// "2026-07-16" —— 明日
toISOString() は常に UTC で表示するので、先頭の十文字を切り取れば得られるのは UTC の日付だ。ニューヨークの夕方にとって、UTC はすでに翌日へ進んでおり、あなたの「日付」は明日になる。瞬間から日付を取り出すのは変換であり、既定のゾーンは、頼もうが頼むまいが UTC だ。直し方は、本当に意図するゾーンで瞬間を整形することだ:
new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(d);
// "2026-07-15" —— ニューヨークでのその日の日付
(en-CA はたまたま実際には YYYY-MM-DD で出力されるが、Intl の出力はロケール依存の文字列であって、保証された機械可読形式ではない——安定した YYYY-MM-DD が要るなら、formatToParts() の各パーツから自分で組み立てる。)ゾーンは意図して選ぶ——ユーザーの、業務の、あるいは本当に UTC ならそれでいい——が、toISOString().slice(0,10) に代わりに決めさせない。
より深い直し方:そもそも瞬間でなかった日付
上の二つの方向は、どちらも日付と瞬間のあいだの変換だ。だが最も強い直し方は、しばしば、ある値は日付でしかないと気づき、瞬間の型に触れさせないことだ。誕生日、締め切り、祝日、請求日、「終日」イベント——これらは浮動するカレンダー日付だ。1990 年 5 月 1 日は、東京でもシカゴでも同じ 5 月 1 日であり、時刻もゾーンも持たない。
そういう値をタイムスタンプとして保存すれば、そのずれを自分の手で作り出したことになる。1990-05-01 は 1990-05-01T00:00:00Z になり、グリニッジより西では 4 月 30 日の夕方であって、誕生日は一部のユーザーに一日早く表示される。直し方は変換ではなく型の問題だ。カレンダー日付は date 型(SQL の DATE、あるいは単に文字列 "1990-05-01")に保存し、日付として比較・表示し、ゾーン付きの瞬間を経由させて往復させない。こうしたバグの大半を防ぐ規則は、早めに一つ問うことだ——この値は瞬間(ある時刻に起きたこと)か、それともカレンダー日付(民用カレンダー上のある一日)か。それが何であるかに従って保存する:
誕生日 → DATE "1990-05-01" (カレンダー日付 —— 時刻もゾーンもない)
作成日時 → timestamp "2026-07-15T22:00:00-04:00" (瞬間)
この「まず種類を見極め、それから何を保存するかを決める」は、日時はタイムスタンプか ISO 文字列かがまるごと扱う問いそのものだ——整数のタイムスタンプ、ISO 文字列、ネイティブの列型のあいだのトレードオフとともに。
そして未来や繰り返しのイベント——「来年の 3 日の朝 9 時」——には、固定オフセットではなく、IANA ゾーン名(America/New_York)をローカル時刻とともに保存する。そのとき実際に効くオフセットは、ゾーンの夏時間の規則が変われば変わりうるからだ。
夏時間:24 時間に満たない一日
同じゾーンの中でさえ、日付計算は滑る。「一日」はカレンダーの概念であって、固定の 86,400 秒ではないからだ。時計を一時間進める地域では、その日のローカル時間は 23 時間しかない。一時間戻す地域では 25 時間ある。だから「一日足す」を24 時間足すとして実装すると、夏時間(DST)の境界で漂う:
// ニューヨークは 2026-03-08 に時計を進める
const before = new Date("2026-03-07T12:00:00-05:00"); // 土曜の正午
new Date(before.getTime() + 86400000);
// → ニューヨークの日曜 2026-03-08 13:00 —— 一時間遅い。その日は一時間短かったからだ
これを積み重ねるか、真夜中の近くに一つ落とせば、日付そのものが滑りうる。関連する危険が二つ付いてくる。時計を進めるとき、一部の壁時計時刻は存在しない(飛ばされた一時間なので、素朴な「真夜中」や「2:30」は無効かもしれない)。戻すとき、一部は二度起きる(曖昧だ)。だから「一日の始まり」「一か月足す」「来週の同じ時刻」は、タイムスタンプに秒を足すのではなく、カレンダーの上で、タイムゾーンの規則を正しく扱えるライブラリで計算しなければならない。日常のそれ——二つの日付の間は何日か、30 日後は何日か——には、日付計算ツールが、途中に DST の境界が挟まったかを気にせず数えてくれる。
日単位の集計:どの日に落ちるかはゾーンが決める
最後のよくあるずれは、レポートと分析に現れる。タイムスタンプを「日ごと」にまとめる——そして一つの行がどの日に落ちるかは、どのゾーンで切り捨てるかで完全に決まる:
const evt = new Date("2026-07-15T23:30:00-05:00"); // シカゴの 23:30
evt.toISOString().slice(0,10); // "2026-07-16" ← UTC で集計
// だがシカゴでは、まだ:2026-07-15
そのイベントのタイムスタンプを UTC で切り捨てれば 16 日に数えられ、業務上は 15 日として扱う。これをデータセット全体でやると、ローカルの真夜中に近い行はすべて隣の日へ落ち、「昨日の合計」が静かに狂う——大きくは狂わない。それこそが本番まで生き延びる理由だ。直し方は、レポート用のゾーンを一つ選び(たいてい業務の、ときに各ユーザーの)、すべてのタイムスタンプをそのゾーンで一貫して切り捨てることだ。一つの瞬間が、ユーザーが実際に暮らす各ゾーンでどの日付に対応するかを見るには、タイムゾーン変換ツールへ放り込み、瞬間は動かないままカレンダー日付が変わるのを見ればいい。
日付が動いたときのチェックリスト
日付が一日早く、あるいは遅く出たら、時間を足しはじめる前に、この順で確かめる。
- この値は日付か、瞬間か?誕生日、祝日、締め切りはカレンダー日付だ——
DATE/文字列で保存し、瞬間の型からは徹底して遠ざける。「作成日時」は瞬間だ。ずれのバグの大半は、日付が瞬間として保存または解析されたものだ。 - 日付を瞬間に変えているか?JavaScript では、日付のみの文字列は UTC の真夜中として解釈されるので、負オフセットのゾーンではどこでも前日として読まれる。ただの日付を
new Date(...)に通さない。通すなら、ゾーンを明示的に設定する。 - 瞬間を日付に変えているか?
toISOString().slice(0,10)が返すのは UTC の日付で、アメリカの夕方のタイムスタンプには明日になる。意図するゾーンで整形する——new Intl.DateTimeFormat("en-CA", { timeZone }).format(d)、またはformatToParts()からYYYY-MM-DDを組み立てる。 - 日付計算をしているか?DST の切り替えをまたぐとき、「一日足す」は「86,400 秒足す」ではない。タイムゾーンの規則を正しく扱えるライブラリを使うか、単純な日数の数え上げはツールに任せる。
- 日ごとに集計しているか?すべてのタイムスタンプを、意図して選んだレポート用ゾーンで切り捨てよ。さもないと真夜中付近の行が誤った日へ散らばる。
この五つに共通するのは、次の区別だ。瞬間は一点、カレンダー日付は一区間、そして両者はタイムゾーンを通してのみ出会う。日付の形をしたどの値についても、それが二つのどちらかをまず決める——そして両者を越えるたびに、ゾーンを明示する。そうすれば日付は漂わなくなる。既定のゾーンに、その日を代わりに選ばせないからだ。