PostgreSQLの timestamp と timestamptz は、どちらも1つの64ビット整数として格納されています。その値は1970-01-01 00:00:00 UTCからのマイクロ秒数です。違いが現れるのは、データを人間が読める形式にフォーマットするときだけです。
なぜハマりやすいのか?
- 2つのカラムに同じ日付を入れたのに、クエリ結果が違う
- あなたのアプリは
2025-07-29 10:00を挿入したのに、別のチームでは02:00と表示される - フロントエンドのISO文字列がバックエンドのログと一致しない
2つの桃缶:ラベルなしとラベル付き
| データ型 | 正式名称 | 格納値 | SELECT時の振る舞い |
|---|---|---|---|
timestamp | timestamp without time zone | 生のマイクロ秒カウント | そのまま返却 ― Postgresはタイムゾーンを推測しない |
timestamptz | timestamp with time zone | 同じマイクロ秒カウント | Postgresがセッションの TimeZone 設定を適用してからテキストを送信 |
たとえ話
timestamp= 産地ラベルのない桃の缶詰。中身がフルーツだとわかっても、どこで缶詰にされたかはわからない。timestamptz= 「原産地 UTC+9(JST)」と印刷された桃の缶詰。開ける人は自分で栄養表示を換算するかどうか決められる。
内部構造:ただの巨大な数値
2000-01-01 00:00:00 UTC → 0
2000-01-01 00:00:01 UTC → 1 000 000
- 単位:マイクロ秒(100万分の1秒)
- 範囲:紀元前4713年〜西暦294276年
timestampとtimestamptzのストレージは完全に同一。解釈の仕方だけが異なる
15秒で試せるデモ
-- クライアントは東京時間(JST)を使用
SET TimeZone = 'Asia/Tokyo';
CREATE TABLE demo (
created_ts timestamp,
created_tz timestamptz
);
INSERT INTO demo VALUES ('2025-07-29 10:00', '2025-07-29 10:00');
| クエリ | 結果 | 理由 |
|---|---|---|
SELECT created_ts FROM demo; | 2025-07-29 10:00:00 | 生の値がそのまま返される。タイムゾーン計算なし |
SELECT created_tz FROM demo; | 2025-07-29 10:00:00+09 | 出力時にタイムゾーンラベルが付与される |
SET TimeZone = 'UTC'; のあとSELECT | 2025-07-29 01:00:00+00 | 同じ瞬間を、別のレンズで見ている |
タイムスタンプの演算とインターバル
PostgreSQLのタイムスタンプで特に実用的なのが、インターバル演算だ。どちらの型もマイクロ秒のカウントを保存しているので、インターバルをそのまま足し引きできる。
-- 以下は Asia/Shanghai(+08)セッションの場合
-- 3時間30分を加算
SELECT '2025-07-29 10:00'::timestamptz + INTERVAL '3 hours 30 minutes';
-- → 2025-07-29 13:30:00+08
-- 2つのタイムスタンプの差を求める
SELECT '2025-07-30 09:00'::timestamptz - '2025-07-29 10:00'::timestamptz;
-- → 23:00:00 (インターバル値)
-- 特定のフィールドを取り出す
SELECT EXTRACT(EPOCH FROM '2025-07-29 10:00:00+08'::timestamptz);
-- → 1753754400.000000 (秒単位のUnixタイムスタンプ、PostgreSQL 14 以降は numeric 型)
-- 日の境界で切り捨てる(日次集計に便利)
SELECT date_trunc('day', '2025-07-29 15:42:19+08'::timestamptz);
-- → 2025-07-29 00:00:00+08
EXTRACT(EPOCH FROM ...) は、Unixエポック秒を前提とする外部システムにタイムスタンプを渡すときに特に役立つ。逆に、エポック値をタイムスタンプに戻すこともできる。
SELECT to_timestamp(1753754400);
-- → 2025-07-29 10:00:00+08 (Asia/Shanghai セッションの場合)
見落としやすいが重要な点がある。timestamp(タイムゾーンなし)のインターバル演算は夏時間の切り替えを一切考慮しないが、timestamptz は考慮する。そのため、夏時間の境界をまたぐ timestamptz の値に INTERVAL '1 day' を足すと、ちょうど24時間後ではなく、壁時計の上で同じ時刻が正しく返ってくる。
インデックスとパフォーマンスの考慮点
timestamp と timestamptz はどちらも8バイトの整数として保存されるので、保存でもインデックスでも両者に性能差はない。内部の比較は単なる整数比較であり、B-treeインデックスもどちらの型でまったく同じように動く。
ただし、実務上の注意点がいくつかある。
- 範囲クエリ:
WHERE created_at > '2025-07-01'は、どちらの型でもインデックスがあれば効率よく実行できる。timestamptzの場合、PostgreSQLは比較の前にリテラルをUTCに変換するので、インデックスはそのまま使われる。 - パーティションキー:タイムスタンプ列でレンジパーティショニングを行うなら、一般に
timestamptzのほうが安全だ。パーティション境界に曖昧さがない(常にUTC)からである。timestampでは、'2025-07-01 00:00'のような境界がセッションによって違う意味になりうる。 - 関数インデックス:日付だけで(時刻を無視して)検索することが多いなら、
date_trunc('day', created_at)へのインデックスを検討するとよい。日次集計クエリが速くなる。
よくある落とし穴と対処法
1. ユーザーごとに違う時刻が見える
- 原因:クライアントごとに異なる
TimeZone設定でtimestamptzを読んでいる - 対処:すべて
timestampにして1つのタイムゾーンで統一するか、接続初期化時にSET TimeZone = 'UTC'を強制する
アプリケーションコードでよく使われるのは、コネクションプールの初期化時に一度だけタイムゾーンを設定するパターンだ。
-- 接続設定の中で(例:pg のプール設定)
SET timezone = 'UTC';
こうすれば全セッションが同じUTC表現を見ることになり、表示用のローカル時刻への変換はアプリケーション層が受け持つ。
2.「壁時計の時刻」を保存するのに型を間違えた
- 業務カレンダー(営業時間、締切日)には
timestampを使う - 国際的なワークフロー(注文、ログ)は UTC で
timestamptzに格納する
判断基準は単純だ。問いが「これはどの瞬間に起きたのか?」なら timestamptz、「壁の時計は何時を指しているか?」なら timestamp を使う。
3. APIのタイムゾーンがずれる
timestamptzは常にISO-8601形式でオフセット付き(Zまたは+09:00)の文字列として送信する- UIはユーザーのローカルタイムゾーンに合わせてフォーマットする
4. 型をまたいでタイムスタンプを比較する
比較や結合で timestamp と timestamptz を混ぜると、気づきにくいバグの原因になりやすい。
-- 危険:暗黙のキャストでセッションのタイムゾーンが適用される
SELECT * FROM orders o
JOIN schedules s ON o.created_tz = s.start_ts;
-- PostgreSQL は s.start_ts をセッションのタイムゾーンで timestamptz にキャストする
-- セッションが違えば JOIN の結果も変わりうる!
対処:型をまたいで比較するときは必ず明示的にキャストするか、ドメインごとに型を1つに統一する。
5. ORMのデフォルト設定の落とし穴
多くのORM(Django、SQLAlchemy、ActiveRecord)は、デフォルトでタイムゾーンなしの timestamp を使う。マイグレーションファイルを確認し、複数のタイムゾーンのユーザーを相手にするアプリなら、デフォルトを timestamptz に変更すること。Djangoでは settings で USE_TZ = True を設定し、SQLAlchemyでは DateTime(timezone=True) を使う。
早見表:どちらを使うべき?
ローカルの予定表だけ → timestamp
グローバルに扱うもの → timestamptz(UTCで格納)
- 会計レポート、授業スケジュール →
timestamp - 監査ログ、ECの注文データ →
timestamptz
Go Toolsで手軽に検証
| やりたいこと | ツール | 使い方 |
|---|---|---|
| SQLのエポック値を確認 | タイムスタンプ変換ツール | 1753754400 を貼り付けて変換 |
| 時間フィールドを含むJSONを整形 | JSONフォーマッター | データを貼り付けて整形・確認 |
すべてのツールはブラウザ上で完全に動作します。データがあなたのマシンを離れることはありません。
よくある質問
PostgreSQL の timestamp と timestamptz の違いは何ですか?
timestamp(タイムゾーンなし)は日時値をそのまま保存し、タイムゾーン情報を持ちません。timestamptz(タイムゾーン付き)は入力を UTC に変換して保存し、取得時にセッションのタイムゾーンに再変換します。ほぼすべてのケースで timestamptz を使いましょう — 分散システムにおけるタイムゾーン関連のバグを防げます。
PostgreSQL は timestamptz にタイムゾーンを実際に保存していますか?
いいえ — 名前に反して、PostgreSQL はタイムゾーン自体を保存しません。入力を UTC に変換し、UTC 値(2000-01-01 からのマイクロ秒カウント)のみを保存します。取得時には、セッションの timezone 設定に基づいて UTC から変換します。元のタイムゾーン情報は破棄されます。
PostgreSQL セッションのタイムゾーンを変更するにはどうすればよいですか?
SET timezone = 'America/New_York'; を実行するとセッションのタイムゾーンが変更されます。これは timestamptz 値の表示と解釈に影響します。サーバー全体のデフォルトを変更するには postgresql.conf で timezone を設定します。曖昧さを避けるため、略称(JST など)ではなく IANA タイムゾーン名(Asia/Tokyo など)を常に使用してください。
イベント時刻の保存には timestamp と timestamptz のどちらを使うべきですか?
ほぼすべてのケースで timestamptz を使いましょう — ユーザー操作、API 呼び出し、監査ログ、スケジュールイベントなどに最適です。timestamp(タイムゾーンなし)は、特定の瞬間に紐づかない抽象的な時刻にのみ使用します。例えば「店舗は 09:00 に開店」はローカルタイムゾーンの午前 9 時を意味し、特定の UTC 時刻ではありません。
PostgreSQL の timestamptz は夏時間(DST)をどう処理しますか?
PostgreSQL は内部的にすべてを UTC で保存するため、timestamptz で夏時間を正しく処理します。値を取得する際、セッションタイムゾーンの現在の夏時間ルールに基づいて UTC から変換します。これにより、同じ UTC 時刻でも夏時間の切り替え前後で正しく異なるローカル時刻が表示されます。
精度の扱い、タイムゾーンのベストプラクティス、JavaScript・Python・Goのコード例まで含めてUnixタイムスタンプを詳しく知りたい場合は、Unixタイムスタンプ完全ガイドを参照してほしい。
まとめ
- PostgreSQLの2つの時間型はどちらもマイクロ秒カウンター。ラベルの有無だけが違い
- 型を間違えると、混乱するタイムスタンプと誤った計算の原因になる
- 適切なツールでテスト・変換・検証すれば、何時間ものデバッグ時間を節約できる