timestamptz に interval '1 month' を足すとセッションTimeZoneで月末がずれる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
PostgreSQLでtimestamptzにinterval '1 month'を加算する際の月末境界は、値自身のオフセットではなくセッションのTimeZone設定で決まるため、JST月初から組んだ境界でもセッションTZがUTCだと月末側へずれ、取りこぼす日数が月により変わる。
結論
timestamptz型の値にinterval '1 month'を加算すると、月の加算処理はその値に埋め込まれたタイムゾーンオフセットではなく、現在の接続(セッション)のTimeZone設定を基準に行われる。 JST(Asia/Tokyo)の月初として構築した境界値でも、セッションのTimeZoneがUTCのまま+ interval '1 month'で翌月境界を求めると、UTCの暦で1か月進んだ時点が返る。これはJSTの月初から見るとわずかに手前(月末側)にずれており、結果として月末の数日分のレコードを集計から取りこぼす。ずれる日数は月によって変わるため、検証のタイミングによっては気づかないまま運用が続く。
症状
学校ごとの月次サマリを集計するgetMonthlySchoolSummaryは、JST暦月の窓[当月1日00:00 JST, 翌月1日00:00 JST)でイベントを絞り込む。修正前の実装は、月初の境界だけをmake_timestamptzでAsia/Tokyo基準に明示的に構築し、翌月の境界はそこにinterval '1 month'を加算して求めていた。
// 修正前(PR #341 時点の parent commit, lines 78-81)
const monthStart = sql`make_timestamptz(${year}::int, ${month}::int, 1, 0, 0, 0, 'Asia/Tokyo')`;
const nextMonthStart = sql`${monthStart} + interval '1 month'`;
const inMonth = and(gte(events.occurredAt, monthStart), lt(events.occurredAt, nextMonthStart));
monthStart自体はAsia/Tokyoの月初を正しく指すtimestamptz値になる。問題は次の行で、この値にinterval '1 month'を足して翌月初を求めている箇所にある。アプリケーション側の接続プールのセッションTimeZoneがUTCだと、集計窓の終端がJSTで想定していた「翌月1日00:00 JST」より手前に寄り、月末側の数日がinMonthの範囲から漏れる。Issue #341では、この取りこぼしが実際の運用データで検出された。取りこぼす日数は月(日数・UTCとJSTの時差15時間の組み合わせ)によって変わり、たまたまずれが表に出ない月(6月など)もあるため、特定の月だけ検証して「問題ない」と判断すると見逃す。
原因
PostgreSQLでtimestamptz型にintervalを加算する処理は、加算対象のtimestamptz値が持っているオフセット情報を使わない。timestamptzは内部的にはUTCの絶対時刻として保持されており、「何年何月何日」という暦の単位(年・月)での加算は、値を表示・解釈する際に使われるセッションのTimeZone設定でいったんローカルなカレンダー表現に変換し、そこに月を加えてから再びUTCの絶対時刻に変換する、という手順を踏む。
monthStartの値自体はAsia/Tokyoの「6月1日00:00」を指すUTC時刻として正しく構築されている。しかしmonthStart + interval '1 month'を評価する段になると、PostgreSQLはこのUTC時刻をセッションのTimeZone(アプリの接続がUTCのまま使っていた)でカレンダー表現に変換し、そこに1か月を加算してからUTCへ戻す。セッションTZがUTCの場合、「Asia/Tokyoの6月1日00:00」はUTC表現では「5月31日15:00」であり、そこに1か月を足すと「6月30日(ユリウス日基準)相当」のUTC時刻が返る。これをJSTに戻すと、本来欲しかった「7月1日00:00 JST」より数時間〜1日程度手前の時刻になる。月によって月初・月末の日数構成が変わるため、このずれの大きさも月ごとに変わり、ちょうど打ち消し合う月だけ症状が出ない。
つまりこの不具合は「timestamptzの内部表現がUTCである」ことと「intervalによる暦演算がセッションTZ基準で行われる」ことの間にある落差から生じる。monthStartを作る側だけAsia/Tokyoを明示しても、そこに暦単位の加算を行う限りセッションTZの影響から逃げられない。
直し方
修正は、翌月境界も加算では求めず、月初と同じようにmake_timestamptzでAsia/Tokyoを明示して直接構築する方式に変更した。年を跨ぐ12月→翌年1月のケースはアプリケーション側(TypeScript)で先に解決してから、年・月をそれぞれ整数のまま渡す。
// 修正後(lines 75-88)
const nextYear = month === MAX_MONTH ? year + 1 : year;
const nextMonth = month === MAX_MONTH ? 1 : month + 1;
const monthStart = sql`make_timestamptz(${year}::int, ${month}::int, 1, 0, 0, 0, 'Asia/Tokyo')`;
const nextMonthStart = sql`make_timestamptz(${nextYear}::int, ${nextMonth}::int, 1, 0, 0, 0, 'Asia/Tokyo')`;
const inMonth = and(gte(events.occurredAt, monthStart), lt(events.occurredAt, nextMonthStart));
両方の境界をmake_timestamptz(..., 'Asia/Tokyo')で独立に構築すれば、どちらもセッションTZの影響を受けない。intervalによる暦演算を一度も経由しないため、接続側のセッションTimeZone設定が何であっても同じ結果になる。年・月の整数はDateオブジェクトにbindせず、そのままSQL側のmake_timestamptzに渡している点も維持されている(JS側のDateを経由すると、今度はJS側のタイムゾーン解釈が別の落とし穴になるため)。
この修正に合わせて、7月末・12月末・年跨ぎの境界に実際のイベントを置いた回帰テストをUTCセッションで追加し、月末の取りこぼしが解消したことを確認している。
再発防止
timestamptzに対して暦単位(年・月)の加算・減算をするコードを見つけたら、その計算がどのセッションTimeZoneで評価されるかを確認する。恒久的な対策は、「暦の境界が欲しい場面ではinterval加算を使わず、make_timestamptzのように明示的にタイムゾーンを指定する関数で両端を独立に構築する」という方針に統一することで、セッションTZという暗黙の前提そのものを計算から取り除くことになる。
よくある質問
Q1timestamptz + interval '1 month' はなぜセッションTimeZoneに依存しますか?
PostgreSQLはtimestamptzへのinterval加算をいったんそのセッションのTimeZone設定でカレンダー換算してから行うため、値に埋め込まれたUTCオフセットではなく実行時のセッションTZが暦月の境界を決める。セッションTZがUTCなら、JST基準で組んだ月初に1か月を足してもUTCのカレンダーで加算されるため、月末側にずれる。
Q2この症状はどうすれば検知できますか?
月によって取りこぼす日数が変わるため、特定の月(この事例では6月)だけ偶然一致してずれが出ないことがあり、通常のテストでは気づきにくい。月末日の直前・直後にイベントを置いた回帰テストを複数の月で用意し、UTCセッションで実行して検証する必要がある。
Q3正しい直し方は何ですか?
加算に頼らず、月初と翌月初の両方をmake_timestamptz(year, month, 1, 0, 0, 0, 'Asia/Tokyo')のようにAsia/Tokyoを明示して直接構築する。12月から翌年1月への年跨ぎはアプリケーション側で年と月を計算してから渡し、セッションTZに依存する加算処理を一切使わない。
確認した環境
- PostgreSQL 16(Cloud SQL, database_version=POSTGRES_16)
- drizzle-orm ^0.45.2 / postgres(postgres.js) ^3.4.5
- 2026-06-01 に修正コミット(発生時期はそれ以前の実装当初から)
この記事の根拠
- TypeScriptファイル 75〜88行目コミット cb9fca1
- TypeScriptファイル 78〜81行目コミット ed75f41
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。