Skip to main content

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

日次の売上集計が、実際より 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 で変換してはいけません。**列に関数を当てると、その列のインデックスが使われなくなります (インデックス)。

**先に列の型を確かめてください。**タイムゾーンを持たない型なら、リテラルに書いたオフセットは捨てられるので、この直し方は効きません。範囲は修正前と変わらないまま、直したつもりになります。

問 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:00America/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 回にします。途中で地方時にすると、以降の計算がその土地に固定されます
関連リファレンス

次に読む