日時とタイムゾーン — 同じ瞬間を別の名前で呼ぶ
日次の売上集計が、実際より 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.
日付だけなら 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 日かを決める — 利用者の現地時間か、事業の基準時間か
- 境界値の側にオフセットを書く — 列の側を変換しない。列に関数を当てるとインデックスが効かなくなります
- 上端は「未満」で閉じる —
<= '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 で変換してはいけません。**列に関数を当てると、その列のインデックスが使われなくなります (インデックス)。
**先に列の型を確かめてください。**タイムゾーンを持たない型なら、リテラルに書いたオフセットは捨てられるので、この直し方は効きません。範囲は修正前と変わらないまま、直したつもりになります。
問 2. オンライン会議の予約機能で、「来年 3 月 15 日 10 時 (ニューヨーク)」の予約が、当日になると 9 時に表示されました。保存しているのは UTC の瞬間です。原因を説明してください。
予約した時点の規則で UTC へ変換し、その結果だけを保存していたためです。
ニューヨークは 3 月に夏時間へ切り替わります。切り替え前のオフセットは -05:00、切り替え後は -04:00 です。予約時に「3/15 10:00」を -05:00 として 15:00Z に変換して保存すると、当日は -04:00 で解釈されるので現地の 11 時になります。逆向きの取り違えなら 9 時になります。どちらにせよ、規則が変わる境目をまたぐと 1 時間ずれます。
**未来の予定を 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 回にします。途中で地方時にすると、以降の計算がその土地に固定されます
- RFC 9557 — Date and Time on the Internet: Timestamps with Additional Information — タイムゾーン名を添えるための RFC 3339 の拡張
- RFC 3339 — Date and Time on the Internet: Timestamps — オフセット付きの基本書式
- ECMAScript — 21.4.3.2 Date.parse — オフセットが無い文字列の解釈規則
- PostgreSQL — Date/Time Types — 保存される値と、入力文字列の解釈
- Laravel API 開発ガイド — API リソース — レスポンスで日時をどう返すか