メインコンテンツまでスキップ

日時とタイムゾーン: 同じ瞬間を別の名前で呼ぶ

日次の売上集計が、実際より 9 時間ぶん早い注文まで拾っていた。予約時刻を保存しておいたら、夏時間の切り替えで 1 時間ずれた。「1 か月後」を計算したら 3 月 3 日になった。

どれもエラーになりません。数だけが静かに違う種類の失敗です。原因はほとんどの場合、日時を 1 種類の値だと思っていることにあります。実際には少なくとも 3 種類あり、混ぜると壊れます。

この章で学ぶこと​

  • 日時が瞬間・地方時・期間の 3 つに分かれること
  • オフセットとタイムゾーンが別物である理由
  • 過去の記録と未来の予定で、保存の仕方が変わること
  • 日付の境界がタイムゾーンで動くこと
  • 同じ文字列でも、形によって解釈が変わること
  • カレンダーの計算が素直な足し算ではないこと
前提知識

特別な前提はありません。日次集計の話で インデックス の「効かなくなる書き方」に触れるので、先に読んでおくと範囲指定の話がつながります。

この章で扱わないこと​

観点参照先
API のレスポンスで日時をどう返すかLaravel API 開発ガイド — API リソース
定期実行をどのタイムゾーンで判定するかLaravel API 開発ガイド — タスクスケジューラ
列の型を TIMESTAMP WITH TIME ZONE にするかどうかの設計判断データベース設計ガイド — データベース設計の重要性
日付の範囲で検索するときのインデックスの効き方インデックス

3 つの別物を区別する​

「日時」と一括りにされるものは、性質の違う 3 つに分かれます。

種類何を指すか例
瞬間世界のどこから見ても同じ 1 点注文が確定した時刻、ログの記録時刻
地方時「その土地の時計で何時か」。場所が決まらないと瞬間にならない予約の 14 時、営業開始の 9 時
期間長さ。始点を持たない30 分、3 日、1 か月

混ぜると壊れるのはここです。「2026-08-28 14:00」は地方時であって瞬間ではありません。どこの 14 時かが決まってはじめて、世界で 1 点に定まります。

オフセットはタイムゾーンではない​

+09:00 のような表記を「タイムゾーン」と呼ぶことがありますが、この 2 つは別物です。

  • オフセット は「UTC から何時間ずれているか」という結果
  • タイムゾーン は「いつどのオフセットを使うか」という規則

規則のほうは変わります。夏時間の切り替えで年に 2 回変わる地域がありますし、法律の改正でオフセットそのものが変わることもあります。

const offsetOf = (tz: string, iso: string) =>
new Intl.DateTimeFormat('en-US', {timeZone: tz, timeZoneName: 'longOffset'})
.formatToParts(new Date(iso))
.find((p) => p.type === 'timeZoneName')!.value;

for (const tz of ['America/New_York', 'Asia/Tokyo']) {
console.log(tz.padEnd(17), '1 月', offsetOf(tz, '2026-01-15T12:00:00Z'), ' 8 月', offsetOf(tz, '2026-08-15T12:00:00Z'));
}
America/New_York 1 月 GMT-05:00 8 月 GMT-04:00
Asia/Tokyo 1 月 GMT+09:00 8 月 GMT+09:00

同じ America/New_York でも、1 月と 8 月でオフセットが違います。-05:00 を保存しても「ニューヨーク」という情報は復元できません。

この不足を埋めるために作られたのが RFC 9557 です。RFC 3339 の書式にタイムゾーン名を角括弧で足せるようにしました。

This is a pressing issue for applications that handle each such instant in time with an associated time zone name in order to take into account events such as daylight saving time transitions.

— RFC 9557 — Date and Time on the Internet: Timestamps with Additional Information (2024 年 4 月)

書式は 1996-12-19T16:39:57-08:00[America/Los_Angeles] のようになります。オフセットと名前の両方を運ぶ形です。

何を保存するかは、過去か未来かで変わる​

保存の規則は 1 つではありません。その値が固定された過去の事実か、これから起きる予定かで分かれます。

対象保存するもの理由
起きたこと (注文・ログイン・ログ)瞬間。UTC で持つもう動かない。どの時計で見るかは表示側の都合
これから起きることで、人の時計に紐づくもの (予約・会議・現地の締切)地方時 + タイムゾーン名規則が変われば、対応する瞬間も変わるべきだから
日付だけで意味が完結するもの (誕生日・記念日)日付のまま。時刻を付けない時刻を付けた時点で、どこの 0 時かという問題が生まれる

2 行目が直感に反します。「未来の予定も UTC に直して保存すればよい」と思えますが、タイムゾーンの規則が変わると壊れます。夏時間の廃止が決まったとき、UTC で保存した「来年の 10 時の会議」は 9 時や 11 時になります。人が期待しているのは「現地の時計で 10 時」のほうです。

3 行目も同じ理屈です。誕生日を 2000-05-03T00:00:00Z として保存すると、日本で表示したときに 5 月 3 日の 9 時になり、タイムゾーンを跨ぐと日付そのものがずれます。日付だけを持つ値には、時刻もタイムゾーンも付けないのが安全です。

2 行目に入るかどうかは「人の時計に紐づく約束か」で決まります。「現地の 10 時に集まる」は人の時計に紐づくので 2 行目です。一方「発行から 1 時間で失効する」は人の時計と関係なく決まるので、未来の値でも瞬間で持ちます。トークンの有効期限がこれに当たります (認証と認可)。

文字列は形によって読まれ方が変わる​

同じように見える文字列が、形の違いで別の意味に解釈されます。ECMAScript はこう定めています。

When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.

— ECMAScript — 21.4.3.2 Date.parse

日付だけなら UTC、時刻まで書いてオフセットが無ければ地方時、という非対称な規則です。

console.log('実行環境のタイムゾーン', Intl.DateTimeFormat().resolvedOptions().timeZone);

const dateOnly = new Date('2026-08-28'); // 日付だけ → UTC として読まれる
const dateTime = new Date('2026-08-28T00:00:00'); // 日付と時刻 → 地方時として読まれる
const withOffset = new Date('2026-08-28T00:00:00+09:00');

console.log('日付だけ ', dateOnly.toISOString());
console.log('時刻付き ', dateTime.toISOString());
console.log('オフセット付き', withOffset.toISOString());
console.log('差 (時間) ', (dateOnly.getTime() - dateTime.getTime()) / 3_600_000);
実行環境のタイムゾーン Asia/Tokyo
日付だけ 2026-08-28T00:00:00.000Z
時刻付き 2026-08-27T15:00:00.000Z
オフセット付き 2026-08-27T15:00:00.000Z
差 (時間) 9

時刻を書き足しただけで 9 時間ずれます。そして最後の行が示すとおり、このずれ幅は実行環境で変わります。UTC のサーバーで動かすと差はゼロになり、テストでは再現しません。

回避策は単純です。外から来る日時文字列には、必ずオフセットかタイムゾーン名を持たせること。それができないなら、どちらの意味で読むかを自分のコードで明示することです。省略された形をそのまま new Date へ渡すと、規則が環境依存の値と噛み合ってしまいます。

境界はタイムゾーンで動く​

「8 月 28 日の売上」を出すとき、切り取る範囲はどこの 8 月 28 日かで変わります。

同じ 2 件の注文を、日本時間の 1 日と UTC の 1 日で数えてみます。

-- 例は SQLite。created_at は UTC の文字列で保存してある
CREATE TABLE orders (id INTEGER PRIMARY KEY, created_at TEXT NOT NULL);
INSERT INTO orders (id, created_at) VALUES
(1, '2026-08-27 20:00:00'), -- 日本時間の 8/28 朝 5 時
(2, '2026-08-28 13:00:00'); -- 日本時間の 8/28 夜 10 時

-- 同じ「8 月 28 日」を、2 つのタイムゾーンで切る。列はそのまま、境界だけを変換する
SELECT '日本時間の 1 日' AS 範囲, count(*) AS 件数 FROM orders
WHERE created_at >= datetime('2026-08-28T00:00:00+09:00')
AND created_at < datetime('2026-08-29T00:00:00+09:00')
UNION ALL
SELECT 'UTC の 1 日', count(*) FROM orders
WHERE created_at >= datetime('2026-08-28T00:00:00Z')
AND created_at < datetime('2026-08-29T00:00:00Z');
範囲 件数
--------------- ----
日本時間の 1 日 2
UTC の 1 日 1

同じ日付を指定して、件数が違います。朝 5 時の注文は UTC ではまだ 8 月 27 日だからです。変換しているのは境界値の側だけで、created_at はそのまま条件に置いてあります。列を関数で包むとインデックスが使えなくなるので、この形が要点です (インデックス)。

境界値の書き方は、製品と列の型で変わります。SQLite は日時の型を持たず文字列を字句で比べるので、上のように datetime() で保存形式へ揃える必要があります。タイムゾーン付きの型の列なら、オフセット付きのリテラルをそのまま置けます。

やっかいなのは、タイムゾーンを持たない型の列です。リテラルに書いたオフセットの扱いが製品で分かれます。PostgreSQL は「timestamp without time zone と判定された値では、タイムゾーンの指定を黙って無視する」と定めています (PostgreSQL — Date/Time Types 8.5.1.3)。オフセットを書いたのに範囲が 1 秒も動かず、エラーも警告も出ません。

変わらないのは「列はそのまま、変換するのは境界値」という形のほうです。

境界値にオフセットを書かないとどう解釈されるかは、接続先の設定で決まります。インデックス で見たとおり、PostgreSQL はタイムゾーンを含まない入力文字列をセッションの設定で解釈します。UTC のままの接続で日本時間の 1 日を数えると、朝の 9 時間ぶんが前日に、深夜の 9 時間ぶんが当日に紛れ込みます。

エラーは出ず、数だけがずれます。しかも「だいたい合っている」ので、気づくのは月次の突き合わせのときになりがちです。

境界を決めるときの手順は 3 段です。

  1. 誰にとっての 1 日かを決める — 利用者の現地時間か、事業の基準時間か
  2. 境界値の側にオフセットを書く — 列の側を変換しない。列に関数を当てるとインデックスが効かなくなります
  3. 上端は「未満」で閉じる — <= '23:59:59' は秒未満の値を取りこぼします

カレンダーの計算は素直ではない​

「1 か月後」や「1 日後」は、固定の秒数ではありません。

// 月末に 1 か月足す
const d = new Date(2026, 0, 31); // 2026-01-31 (地方時)
d.setMonth(d.getMonth() + 1);
console.log('1/31 に 1 か月足すと', `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`);

// 「1 日後」を 24 時間で足すと、夏時間の切り替え日でずれる
const before = new Date('2026-03-08T00:00:00-05:00'); // ニューヨークの 3/8 0 時 (切り替え当日)
const plus24h = new Date(before.getTime() + 24 * 60 * 60 * 1000);
const show = (d: Date) =>
new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', dateStyle: 'short', timeStyle: 'short', hour12: false,
}).format(d);
console.log('3/8 0 時 + 24 時間 =', show(plus24h), '(現地の時計では 1 時間ずれる)');
1/31 に 1 か月足すと 2026/3/3
3/8 0 時 + 24 時間 = 2026-03-09, 01:00 (現地の時計では 1 時間ずれる)

上は、2 月に 31 日が無いので溢れた 3 日ぶんが 3 月へ回った結果です。下は、夏時間の開始でその日が 23 時間しかないために、24 時間足すと現地の時計が 1 時間進むという結果です。

「1 日」を 86,400 秒として扱ってよいのは、瞬間どうしの差を測るときだけです。カレンダー上の「翌日の同じ時刻」を出したいなら、地方時のまま日付を進めてから瞬間へ戻します。

うるう秒も同じ系統の落とし穴ですが、こちらは仕様の側で切り捨てられています。ECMAScript の時刻値は「UTC のうるう秒を考慮しない」と定められており (ECMAScript — 21.4.1.1 Time Values and Time Range)、正のうるう秒の中の瞬間を表す値が存在しません。JavaScript の中で 23:59:60 を作ることはできない、と考えてください。

表示は最後に 1 回だけ​

ここまでを 1 つの規則にまとめると、こうなります。内部では瞬間として持ち回り、人に見せる直前に 1 回だけ地方時へ変換する。

// 同じ瞬間を 3 つのタイムゾーンで表示する
const instant = new Date('2026-08-28T00:30:00+09:00');
const show = (tz: string) =>
new Intl.DateTimeFormat('ja-JP', {
timeZone: tz, dateStyle: 'short', timeStyle: 'short', hour12: false,
}).format(instant);

for (const tz of ['Asia/Tokyo', 'UTC', 'America/New_York']) {
console.log(tz.padEnd(17), show(tz));
}
Asia/Tokyo 2026/08/28 0:30
UTC 2026/08/27 15:30
America/New_York 2026/08/27 11:30

日付そのものが違う点に注目してください。同じ 1 つの瞬間が、日本では 8 月 28 日、ニューヨークでは 8 月 27 日です。「日付でグループ化する」処理を変換の前にやるか後にやるかで、結果が変わります。

途中で地方時へ変換してしまうと、以降の計算はその土地に固定されます。変換は出口で 1 回にすると、この事故が起きません。

よくある誤解​

「UTC で保存しておけば安全」 — 起きたことの記録なら安全です。未来の予定は違います。タイムゾーンの規則が変われば、正しい瞬間も変わるからです。予定は地方時とタイムゾーン名で保存します。

「+09:00 を保存すればタイムゾーンを保存したことになる」 — なりません。オフセットは規則の結果であって規則そのものではないので、別の日付でどのオフセットになるかは復元できません。

「日付だけの文字列も、時刻付きの文字列も、同じように地方時として読まれる」 — 読まれ方が違います。オフセットが無いとき、日付だけの形は UTC、時刻付きの形は地方時として解釈されます。

「サーバーを UTC にしておけばタイムゾーンの問題は起きない」 — 起きます。利用者が見るのは現地の時計なので、日次集計の境界も、締切の判定も、現地時間で決まります。サーバーの設定は表示と境界の問題を解決しません。

「1 日 = 86400 秒 として足せばよい」 — 瞬間どうしの差を測るときだけ成り立ちます。夏時間の切り替え日は 23 時間や 25 時間になるので、カレンダー上の「翌日」を出すには地方時のまま日付を進めます。

確認問題​

問 1. 日次の売上レポートが、経理の集計と毎日わずかに合いません。アプリは UTC のサーバーで動き、日付の範囲は WHERE created_at >= '2026-08-28' AND created_at < '2026-08-29' で切っています。何が起きていますか。

解答と解説

日本時間の 1 日ではなく、UTC の 1 日を集計しています。

オフセットを書かない '2026-08-28' は接続先の設定で解釈されるので、UTC のサーバーでは UTC の 0 時になります。これは日本時間の 8 月 28 日 9 時です。つまり集計されているのは日本時間の 8/28 9:00 から 8/29 9:00 で、次の 2 つが同時に起きます。

  • 8/28 の 0 時から 9 時までの売上が前日のレポートへ入る
  • 8/29 の 0 時から 9 時までの売上が当日のレポートへ紛れ込む

日をまたいで打ち消し合うので、総額はだいたい合います。日別の内訳だけがずれるため、月次で見ると気づきにくい形です。

直し方は境界値の側にオフセットを書くことです。

-- 例は PostgreSQL。列が timestamp with time zone ならオフセット付きのリテラルをそのまま置ける
WHERE created_at >= '2026-08-28 00:00:00+09:00'
AND created_at < '2026-08-29 00:00:00+09:00'

列の側を AT TIME ZONE で変換してはいけません。列に関数を当てると、その列のインデックスが使われなくなります (インデックス)。

先に列の型を確かめてください。PostgreSQL の timestamp without time zone のようにオフセットを捨てる型では、この直し方は効きません。範囲は修正前と変わらないまま、直したつもりになります。解釈の規則は製品で違うので、使っている環境で確かめてください。

問 2. オンライン会議の予約機能で、「来年 3 月 15 日 10 時 (ニューヨーク)」の予約が、当日になると 9 時に表示されました。保存しているのは UTC の瞬間です。原因を説明してください。

解答と解説

予約した時点の規則で UTC へ変換し、その結果だけを保存していたためです。

ニューヨークの 3 月 15 日は、現行の規則ではすでに夏時間 (-04:00) です。予約した時点でこの規則に沿って「3/15 10:00」を 14:00Z へ変換し、その結果だけを保存したとします。その後に夏時間が廃止されて通年 -05:00 になると、同じ 14:00Z は現地の 9 時として表示されます。保存したのは規則の適用結果なので、規則が変われば表示も一緒に動きます。

未来の予定を UTC の瞬間だけで保存すると、この壊れ方をします。人が約束しているのは「現地の時計で 10 時」であって、特定の UTC 時刻ではないからです。

直すには、地方時とタイムゾーン名の両方を保存します。2027-03-15T10:00 と America/New_York を持っておけば、表示や通知のたびに最新の規則で瞬間へ変換できます。瞬間の側も併せて持つ場合は、規則が更新されたら計算し直す前提にします。

問 3. 「30 日間の無料トライアル」の終了日を計算するコードが、ごくまれに 1 日ずれると報告されました。実装は new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000) です。何を疑い、どう直しますか。

解答と解説

「1 日 = 24 時間」という前提を疑います。

この式が測っているのは、開始の瞬間から 720 時間後という瞬間です。ところが利用者が期待しているのは「カレンダー上で 30 日後の同じ時刻」です。夏時間のある地域では 2 つが一致しません。切り替えを挟むと現地の時計で 1 時間ずれ、開始時刻が 0 時前後なら日付が変わります。

「ごくまれに」という報告の仕方も、この仮説と合います。ずれるのは切り替えをまたぐ期間だけだからです。

直し方は 2 つあり、どちらを選ぶかは何を約束したかで決まります。

  • カレンダー上の 30 日後を約束した場合: 地方時のまま日付に 30 を足してから瞬間へ戻します。時計の見た目は保たれます
  • 720 時間の利用を約束した場合: いまの式が正しいので、期待値のほうを直します

併せて、終了の判定を日付の比較で行うか瞬間の比較で行うかも揃えます。片方を地方時の日付、もう片方を瞬間で比べると、境界の 1 日でまた食い違います。

まとめ​

  • 日時は瞬間・地方時・期間の 3 つに分かれます。「2026-08-28 14:00」は地方時であって瞬間ではありません
  • オフセットは結果、タイムゾーンは規則です。+09:00 を保存しても、どの地域かは復元できません
  • 起きたことは瞬間 (UTC)、これから起きることは地方時 + タイムゾーン名で保存します。規則が変われば正しい瞬間も変わるからです
  • オフセットが無いとき、日付だけの形は UTC、時刻付きの形は地方時として解釈されます。ずれ幅は実行環境で変わるので、UTC のサーバーでは再現しません
  • 日付の境界はタイムゾーンで動きます。境界値の側にオフセットを書き、列の側は変換しません
  • カレンダーの計算は固定の秒数ではありません。月末の加算は溢れ、夏時間の切り替え日は 24 時間ではありません
  • 変換は出口で 1 回にします。途中で地方時にすると、以降の計算がその土地に固定されます
関連リファレンス

次に読む​