あなたのタイムスタンプは嘘をついている
電車の切手が発端——データベースに時間を保存する方法をめぐる考察
ニューヨークからシカゴ行きの電車を予約していたとき、Postgresのタイムスタンプ型がなぜこれほど混乱を招くのか、その理由がふと頭に降りてきた。切手にはこう書かれていた:
- 出発: 午前8時 EST
- 到着: 午後7時30分 CST
- 所要時間: 11時間30分
同じ切手の中に、時間について語る3つの異なる方法が並んでいる。そしてそれぞれをデータベースでは別のかたちで保存する必要がある。
誰も最初に問わない質問
PostgresのTIMESTAMPとTIMESTAMPTZは、どちらもちょうど8バイトを使い、同じマイクロ秒精度を持つ。それなのになぜ2つの型が存在するのか?
「今何時?」という問いは、あなたが誰に何を伝えたいかによってまったく変わってくるからだ。
ニューヨークで電車に乗り込むとき、私は東部時間午前8時に出発することを知っておく必要がある。それは私が合わせなければならない駅の時計に表示される数字だ。シカゴで友人が迎えに来てくれるなら、彼女は中部時間午後7時30分に到着することを知る必要がある——それは彼女の時計に表示される数字だ。そして本を読む時間があるかどうか知りたいなら、11時間半の旅程であることを知る必要がある。
同じ電車。同じ旅。しかし時間の表現は3つ、まったく異なる。
TIMESTAMPTZが実際にやっていること
TIMESTAMPTZのトリック——そしてそれは多くの人が思っているものではない。これはタイムゾーンを保存しない。名前が誤解を招く。
何が起きるかと言うと、与えた時間をUTCに変換してから保存し、読み出すときにセッションのタイムゾーンに逆変換する。「TZ」という部分は保存のためではなく、変換サポートのためなのだ。
あの電車の出発を保存する場合を考えてみよう。東京にいる人がデータベースを照会すると、JSTで出発時間が表示される。ロンドンにいる人はGMTで見る。誰もが同じ絶対的な瞬間を見ている——ただ、設定されたタイムゾーンで表現されているだけだ。これはイベントの記録に完璧だ。「この決済はいつ処理された?」や「このAPIリクエストはいつ起きた?」といった問いに答えるのに使える。
だが電車の切手の場合はどうか。出発時間が、異なるタイムゾーンから照会された人によって変わってほしくはない。電車は東部時間午前8時に出発する、それだけだ。それは絶対的な瞬間ではない——グランド・セントラルの時計が何を示すかについての約束なのだ。
実際に意味するものを保存する
あの電車の旅の場合、目的に応じて異なるものを保存する必要がある:
- 絶対的な瞬間 (
departs_atとarrives_atをTIMESTAMPTZとして) - 表示用の文脈 (
origin_timezoneとdestination_timezoneをテキストとして) - 所要時間 (2つの瞬間の間の
INTERVAL)
これでアプリケーションは電車の切手と同じことができる: 絶対的な瞬間を出発地タイムゾーンに変換して「EST午前8時出発」と表示し、目的地タイムゾーンに変換して「CST午後7時30分到着」と表示し、所要時間はインターバルから直接「11時間30分」と表示する。
東京から切手を予約する人も、各駅で同じ現地時間を見ることができる。それこそが彼らに必要な情報だ。
フライト追跡アプリが間違えた理由
フライト追跡アプリの中には、飛行中にあなたのタイムゾーンを表示するものがあることに気づいたことがあるだろうか。大西洋上空を飛んでいるときに「現在の時刻: GMT午後4時32分」と表示される。誰が気にする? グリニッジにいるわけではない。高度38,000フィートのどこか、海洋上空にいるのだ。
実際に欲しい情報はこれだ:
- 離陸からの経過時間
- 目的地までの残り時間
- 到着したときの現地時刻
これらはどれもタイムゾーン変換ではない。最初の2つはインターバル——持続時間であって瞬間ではない。最後の一つは、特定の場所へのタイムゾーン変換だ。「あなたの現在のタイムゾーン」ではない。
わかるだろうか? 2つのインターバル計算 (NOW() - actual_departure と estimated_arrival - NOW())、そして1つの特定場所へのタイムゾーン変換 (AT TIME ZONE destination_timezone)。あなたの現在のタイムゾーンは関係ない。
壁時計の時間が必要なとき
ホテルは絶対的な瞬間を気にしない。彼らが気にするのは、その場所での時計の読みだ。
「チェックインは午後3時以降」は「UTCの午前0時から15時間後」という意味ではない。「ロビーの時計が午後3時を示したとき、チェックインできます」という意味だ。サーバーがバージニアにあってもホテルがパリにあっても、このルールはパリ時間の午後3時に発動してほしい。
TIME型 (日付やタイムゾーンなし) はまさにこれを表現する: 「時計の読み」。タイムゾーンテキストフィールド (「Europe/Paris」) と組み合わせれば、サーバーがどこにあっても壁時計のポリシーを適用できる。ただし、個々のゲストが実際にチェックイン・チェックアウトした時刻も保存したい——それらはバックエンドが追跡すべき絶対的な瞬間なので、TIMESTAMPTZカラムも必要になる。
カレンダーの問題
午前9時に「日々の優先事項を確認」という繰り返しリマインダーを設定している。どこにいても午前9時にこのリマインダーが欲しい。旅行中なら、依然として現地時間の午前9時に発火してほしい。
だがカレンダーイベントもある: 「チームスタンドアップ、EST午前10時」。ベルリンにいるチームメイトには同じイベントが「CET午後4時」と表示される必要がある。同じミーティングだが、表示時刻が異なる——これは誰もが参加する絶対的な瞬間だからだ。
2種類のイベント、2つの保存戦略。ミーティングには TIMESTAMPTZ を使う。リマインダーには TIME と現在のタイムゾーン設定を使う。両方を同じフィールドに押し込もうとするのは避けよう。
本番で壊れるもの
正しい型を使っていても、精度が問題になることがある。Postgresはマイクロ秒を保存する: 10:00:00.123456。JavaScriptのDateオブジェクトはミリ秒を使う: 10:00:00.123。
そのため、このクエリは不可解に行を返さないことがある:
SELECT * FROM orders WHERE created_at = '2026-01-15 10:00:00.123';データベースには 10:00:00.123456 があり、コードは 10:00:00.123 を渡している。ドライバーがどう処理するかによるが、これらは一致しないかもしれない。
タイムスタンプの完全一致検索は避ける。範囲クエリを使うか、さらに良いのは——レコードを作成タイムスタンプで検索しないこと。適切なユニーク制約またはべき等性キーを使う。
実践的なルール
デフォルトは TIMESTAMPTZ。 迷ったら TIMESTAMPTZ を使う。マルチリージョンデプロイメント、夏時間、将来のタイムゾーン変更を自動的に処理する。TIMESTAMP と同じストレージサイズなので、ペナルティはない。
文脈は別に保存する。 「EST午前8時出発」と実際の瞬間の両方を表示する必要があるなら、TIMESTAMPTZ と origin_timezone を別々のカラムとして保存する。すべてを1つのフィールドにエンコードしようとしてはいけない。
インターバルについて考える。 時間に関する要件の多くは、実際には瞬間ではなく持続時間についてだ。「これはどれくらい保留状態にある?」「これはいつ期限切れになる?」タイムゾーン変換ではなく INTERVAL 演算を使う。
すべてをUTCで動かす。 サーバーはUTCに設定する。データベースセッションはデフォルトでUTCにする。ユーザーに表示するときにのみ現地タイムゾーンに変換する——そして、どのタイムゾーンが重要かを知っている場合に限る。
クライアントにタイムゾーン情報を要求する。 クライアントがオフセットなしの 2026-01-15T10:00:00 を送信したら、拒否する。Z または -05:00 のような明示的なオフセット付きのISO-8601形式を要求する。推測してはいけない。
良いデフォルトを強制する
TIMESTAMPTZ がデフォルトであるべきだとして、データベースレベルでそれを強制することを検討しよう。TIMESTAMP WITHOUT TIME ZONE カラムを拒否するトリガーは極端に聞こえるかもしれないが、スキーマ作成時に「TZの付け忘れ」を検知するほうが、誰かが新しいテーブルを追加して忘れて、6ヶ月後にデバッグするよりましだ。
電車の切手が教えてくれたこと
データベースにおける時間の難しさは、タイムスタンプが複雑だからではない。通常、複数の関心を1つのフィールドに保存しようとしているか、ユーザーに実際に何を見せたいかを考えていないからだ。
あの電車の切手は正しかった: 出発地タイムゾーンでの出発時刻、目的地タイムゾーンでの到着時刻、そしてまったく別のものとしての所要時間。3つの異なる情報、それぞれが独自の意味を持つ。
データベースも同じことができる。絶対的な瞬間を TIMESTAMPTZ として保存する。表示用の文脈 (タイムゾーン、場所) を別のカラムとして保存する。必要なときにPostgresに変換を行わせるが、どの目的にどのタイムゾーンが重要かを明示的にする。
ほとんどの場合、それは至る所で TIMESTAMPTZ とUTCを使い、表示時のみタイムゾーン変換を行うことを意味する。だが壁時計の時間や繰り返しスケジュールが必要なときのために、TIMESTAMP や TIME 型が存在する。
鍵は、あなたが答えようとしている問いを知ることだ: 「これはいつ起きた?」 vs. 「何時にそこに行けばいい?」 vs. 「これはどれくらいかかる?」これらは時間に関するすべて異なる問いで、それぞれ異なる保存戦略が必要なことが多い。
ユーザーは何を見る必要があるかを考える。そして、まさにそれを表示できるデータを保存する。
リソース
- PostgreSQL Date/Time Types Documentation
- PostgreSQL Timestamp Best Practices
- ISO 8601 Date and Time Format
- Time Zone Database (IANA)
- Dealing with Timestamps in Distributed Systems