timestamptz の罠まとめ — PostgreSQL の時刻処理で間違えやすいこと

読了 5分

基礎第 2 回の結論は「時刻は timestamptz」でした。その結論は有効ですが、timestamptz を使い始めた後にも踏む罠が別にあります。この記事はその罠のまとめです。共通の原理を 1 つだけ先に握っておけば全部解けます。timestamptz はタイムゾーンを保存しません。UTC に変換された絶対時点を保存し、見せるときにセッションの TimeZone 設定で変換するだけです。

罠 1: 「なぜ 9 時間ずれるのか」の大半はセッション設定 #

同じ行を取得したのに、ある環境では 18:00+09、ある環境では 09:00+00 と見えます。保存された値が違うのではなく、セッションの TimeZone が違うのです(同じ絶対時点の 2 つの表記です)。接続ツール、アプリケーションのドライバ、サーバー設定がそれぞれ別の TimeZone を持ちうるので、「環境ごとに値が違って見える」現象が起きます。定石は、保存・転送の層は UTC に統一し、人に見せる表示の層でだけ地域のタイムゾーンに変換することです。アプリケーションのコネクションの TimeZone を UTC に固定しておけば、この系統の混乱が消えます。

罠 2: AT TIME ZONE は型によって逆に動きます #

この演算子が時刻処理バグの最大の供給源です。入力の型によって意味がひっくり返ります。

AT TIME ZONE の二重性
-- timestamptz に適用: 「この絶対時点をそのタイムゾーンの壁時計で見せて」 → timestamp を返す
SELECT now() AT TIME ZONE 'Asia/Tokyo';          -- 2026-09-06 18:00:00 (タイムゾーンなしの値)

-- timestamp に適用: 「この壁時計の数字をそのタイムゾーンのものとして解釈して」 → timestamptz を返す
SELECT TIMESTAMP '2026-09-06 18:00' AT TIME ZONE 'Asia/Tokyo';  -- 2026-09-06 09:00:00+00

同じ構文が、片方では「変換して表示」、もう片方では「解釈して絶対時点化」です。実務ルールとして暗記するのが安全です。timestamptz → AT TIME ZONE → 壁時計(timestamp)、壁時計(timestamp)→ AT TIME ZONE → 絶対時点(timestamptz)。 2 回適用すると元の型に戻る往復の関係です。そして 'JST' のような略語より 'Asia/Tokyo' のような地域名を使ってください。略語は多義的だったり(同じ略語を複数のタイムゾーンが使います)、夏時間を反映できなかったりします。

罠 3: 「日別集計」の真夜中はどこの真夜中か #

日別集計の真夜中の基準
-- セッションの TimeZone 基準の「日」で切られます: 環境によって結果が変わりうる
SELECT date_trunc('day', created_at), count(*) FROM orders GROUP BY 1;

-- 明示的に「東京基準の日」で切る
SELECT date_trunc('day', created_at, 'Asia/Tokyo'), count(*) FROM orders GROUP BY 1;

日別売上のような集計は、「1 日」の境界、つまりどのタイムゾーンの真夜中で切るかによって数字が変わります。date_trunc や ::date キャストを timestamptz にそのまま適用するとセッションの TimeZone 基準で切られるので、コネクション設定によってレポートの数字が変わる事故が起きます。集計の基準タイムゾーンをクエリに明示すること(date_trunc('day', col, 'Asia/Tokyo'))が定石です。ちなみに、こうしてカラムを加工した値でフィルタ・グルーピングするクエリが多いなら、式インデックスの検討対象でもあります。

罠 4: 未来の予定は timestamptz だけでは足りません #

過去のイベントの記録には timestamptz が正解ですが、未来の約束は別の問題です。「2027 年 3 月 10 日 午前 10 時、東京で会議」を今 UTC に換算して保存すると、その間にタイムゾーンの規則(夏時間の導入・廃止など)が変わった瞬間、保存された絶対時点が約束とずれます。人の約束は「その地域の壁時計の 10 時」であって、特定の絶対時点ではないからです。カレンダー系の定石は、壁時計の値(timestamp)+ タイムゾーン名(text、‘Asia/Tokyo’)を別々に保存し、絶対時点が必要になるたびにその時点の最新の規則で計算することです。「過去の記録は timestamptz、未来の予定は壁時計 + 地域名」と覚えれば十分です。

罠 5: now() はトランザクション開始時刻です #

now()(= CURRENT_TIMESTAMP)はトランザクションの開始時刻を返し、1 つのトランザクションの中では何回呼んでも同じ値です。ほとんどの用途(created_at の既定値など)にはこれがむしろ望む動作ですが、長いバッチのトランザクションの中で「各ステップの実際の時刻」を刻みたいなら、文ごとに実際の時刻をくれる clock_timestamp() を使う必要があります。「長いトランザクションなのにログの時刻が全部同じ」というミステリーの答えがこれです。

まとめ #

  • 原理は 1 つです。timestamptz は UTC の絶対時点の保存 + セッション TimeZone の表示変換です。ずれて見えたら、まずセッション設定を疑ってください。
  • AT TIME ZONE は型によって意味がひっくり返ります。timestamptz→壁時計、timestamp→絶対時点の往復で覚え、タイムゾーンは略語ではなく地域名を使います。
  • 日別集計は真夜中の基準タイムゾーンをクエリに明示します。date_trunc(‘day’, col, ‘Asia/Tokyo’) の形が定石です。
  • 過去の記録は timestamptz、未来の予定は壁時計 + タイムゾーン名の保存です。タイムゾーンの規則は変わります。
  • now() はトランザクション開始時刻に固定です。ステップごとの実測時刻は clock_timestamp() です。
X