timestampは「書かれた日時をそのまま持つ型」、timestamptz(timestamp with time zone)は「世界共通のある一瞬をUTCで持ち、表示するときにセッションのタイムゾーンへ直す型」です。 どちらも8バイトで、精度も範囲も同じですが、タイムゾーンをまたぐと振る舞いがはっきり分かれます。

この記事では、PostgreSQL 16.15で実際に実行した結果を並べながら、2つの型の違い、AT TIME ZONE が型によって逆向きに働く仕組み、UTCと日本時間(JST)を行き来する定型SQL、JSTの日付で集計するときに起きる9時間の境界ずれ、インデックスが効く範囲条件の書き方までを順に確認します。

timestampとtimestamptzの違い早見表

比べる点 timestamp timestamptz
正式名 timestamp without time zone timestamp with time zone
持っているもの 書かれた年月日・時分秒そのもの ある一瞬(内部ではUTC)
サイズ 8バイト 8バイト
入力にオフセット(+09 など)を付けたとき 読み飛ばされる その分だけ換算してUTCで保存
入力にオフセットが無いとき そのまま保存 セッションのタイムゾーンの時刻として解釈
SELECTしたときの表示 いつも同じ セッションのタイムゾーンで変わる
元のタイムゾーン名は残るか 残らない 残らない(UTCに直した一瞬だけが残る)
now() の戻り値 (localtimestamp がこの型) この型
向いている用途 「毎朝9時」のような場所を問わない壁時計の時刻 作成日時・更新日時・ログなど「いつ起きたか」

変換の定型SQLも先にまとめます。

やりたいこと SQL 結果の型
timestamptzをJSTの日時として見る tstz_col AT TIME ZONE 'Asia/Tokyo' timestamp
UTCが入ったtimestampをJSTに直す ts_col AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Tokyo' timestamp
JSTが入ったtimestampをUTCに直す ts_col AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC' timestamp
JSTが入ったtimestampをtimestamptzにする ts_col AT TIME ZONE 'Asia/Tokyo' timestamptz
JSTの日付を取り出す (tstz_col AT TIME ZONE 'Asia/Tokyo')::date date
JSTの文字列にする to_char(tstz_col AT TIME ZONE 'Asia/Tokyo', 'YYYY-MM-DD HH24:MI:SS') text
JSTの10月3日分を範囲で絞る col >= timestamp '2026-10-03' AT TIME ZONE 'Asia/Tokyo' AND col < timestamp '2026-10-04' AT TIME ZONE 'Asia/Tokyo' (条件式)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQL timestampとtimestamptzの違いとJST化」

検証環境

項目 内容
PostgreSQL 16.15(Ubuntu 24.04、x86_64)
クライアント psql 16
サーバーの既定タイムゾーン Etc/UTC
セッションのタイムゾーン 各SQLの前に SET timezone = ... で明示

AT TIME ZONE の型ごとの戻り値と date_trunc の3番目の引数は、PostgreSQL 16公式ドキュメントの「日付/時刻関数と演算子」のページを今回開いて確認しました。

同じ値をタイムゾーンを変えてSELECTする

違いをいちばん早く理解できるのは、同じ行をセッションのタイムゾーンだけ変えて見比べる方法です。

テーブルを作って2行入れる

CREATE TABLE tz_demo (id int PRIMARY KEY, ts timestamp, tstz timestamptz);

SET timezone = 'Asia/Tokyo';
-- 1行目:オフセットなしの文字列
INSERT INTO tz_demo VALUES (1, '2026-10-03 09:00:00',    '2026-10-03 09:00:00');
-- 2行目:+00(UTC)のオフセットつきの文字列
INSERT INTO tz_demo VALUES (2, '2026-10-03 09:00:00+00', '2026-10-03 09:00:00+00');

Asia/Tokyoで見る

SET timezone = 'Asia/Tokyo';
SELECT * FROM tz_demo ORDER BY id;
 id |         ts          |          tstz          
----+---------------------+------------------------
  1 | 2026-10-03 09:00:00 | 2026-10-03 09:00:00+09
  2 | 2026-10-03 09:00:00 | 2026-10-03 18:00:00+09

UTCで見る

SET timezone = 'UTC';
SELECT * FROM tz_demo ORDER BY id;
 id |         ts          |          tstz          
----+---------------------+------------------------
  1 | 2026-10-03 09:00:00 | 2026-10-03 00:00:00+00
  2 | 2026-10-03 09:00:00 | 2026-10-03 09:00:00+00

America/New_Yorkで見る

SET timezone = 'America/New_York';
SELECT * FROM tz_demo ORDER BY id;
 id |         ts          |          tstz          
----+---------------------+------------------------
  1 | 2026-10-03 09:00:00 | 2026-10-02 20:00:00-04
  2 | 2026-10-03 09:00:00 | 2026-10-03 05:00:00-04

結果からわかること

  • ts列(timestamp)は、3回とも 2026-10-03 09:00:00 のままです。2行目に付けた +00 は読み飛ばされ、1行目と同じ値になりました。
  • tstz列(timestamptz)は、タイムゾーンを変えるたびに表示が変わります。 ただし指している一瞬は同じです。1行目は「JSTの9時」=「UTCの0時」=「ニューヨークの前日20時」です。
  • 1行目のtstzは、オフセットなしの文字列をINSERTした時点のセッション(Asia/Tokyo)の時刻として解釈しています。

保存されている中身が変わっていないことは、エポック秒(1970年1月1日0時UTCからの秒数)で確かめられます。

SELECT id,
       extract(epoch FROM tstz) AS epoch_tstz,
       pg_column_size(ts)       AS ts_bytes,
       pg_column_size(tstz)     AS tstz_bytes
FROM tz_demo ORDER BY id;
 id |    epoch_tstz     | ts_bytes | tstz_bytes 
----+-------------------+----------+------------
  1 | 1790985600.000000 |        8 |          8
  2 | 1791018000.000000 |        8 |          8

エポック秒はセッションを変えても同じ値で、2行の差は32,400秒(9時間)です。timestamptzは「保存はUTCの一瞬、表示だけがセッションで変わる」ということが確認できました。サイズはどちらも8バイトで、timestamptzを選んでも容量は増えません。

flowchart LR
  A["入力 '2026-10-03 09:00:00'<br>セッション Asia/Tokyo"] --> B["timestamptz 列<br>UTC 2026-10-03 00:00 として保存"]
  B --> C["セッション Asia/Tokyo<br>09:00:00+09 と表示"]
  B --> D["セッション UTC<br>00:00:00+00 と表示"]
  B --> E["セッション America/New_York<br>前日 20:00:00-04 と表示"]

オフセットつきの文字列をtimestampに入れると

型を明示したリテラルでも同じ動きです(セッションはUTC)。

SELECT timestamp   '2026-10-03 09:00+09' AS ignored,
       timestamptz '2026-10-03 09:00+09' AS kept;
       ignored       |          kept          
---------------------+------------------------
 2026-10-03 09:00:00 | 2026-10-03 00:00:00+00

timestampは +09 を読み飛ばして9時のまま、timestamptzは9時間引いたUTCの0時になりました。アプリケーションからオフセットつきの文字列を送る設計なら、受ける列はtimestamptzにしておくと情報が落ちません。

now()とlocaltimestamp

SELECT pg_typeof(now()), pg_typeof(localtimestamp), pg_typeof(current_timestamp);
        pg_typeof         |          pg_typeof          |        pg_typeof         
--------------------------+-----------------------------+--------------------------
 timestamp with time zone | timestamp without time zone | timestamp with time zone

now() と current_timestamp はtimestamptz、localtimestamp はtimestampです。Asia/Tokyoのセッションで同時に取ると次のようになりました。

SET timezone = 'Asia/Tokyo';
SELECT now() AS now_tstz, localtimestamp AS local_ts, now() AT TIME ZONE 'UTC' AS utc_ts;
           now_tstz            |          local_ts          |           utc_ts           
-------------------------------+----------------------------+----------------------------
 2026-10-03 13:34:13.208653+09 | 2026-10-03 13:34:13.208653 | 2026-10-03 04:34:13.208653

timestamp列に DEFAULT now() を付けると、INSERTしたセッションのタイムゾーンでの壁時計の時刻が入ります。接続ごとに設定が違うと値の基準がそろわなくなるので、timestamp列を使うなら「必ずUTCで入れる」などの取り決めが必要です。

AT TIME ZONEは型によって逆向きに働く

AT TIME ZONE は、左側の型によって意味が変わります。

左側の型 意味 戻り値の型
timestamp 「この日時は指定したタイムゾーンの時刻です」と意味づけする timestamptz
timestamptz 「この一瞬を指定したタイムゾーンの壁時計で読み取る」 timestamp

4パターンの実行結果(セッションはAsia/Tokyo)

SET timezone = 'Asia/Tokyo';

-- (1) timestamp を「JSTの時刻」と意味づけ
SELECT pg_typeof(timestamp '2026-10-03 09:00' AT TIME ZONE 'Asia/Tokyo') AS typ,
       timestamp '2026-10-03 09:00' AT TIME ZONE 'Asia/Tokyo' AS val;

-- (2) timestamp を「UTCの時刻」と意味づけ
SELECT pg_typeof(timestamp '2026-10-03 09:00' AT TIME ZONE 'UTC') AS typ,
       timestamp '2026-10-03 09:00' AT TIME ZONE 'UTC' AS val;

-- (3) timestamptz を JST の壁時計で読む
SELECT pg_typeof(timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'Asia/Tokyo') AS typ,
       timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'Asia/Tokyo' AS val;

-- (4) timestamptz を UTC の壁時計で読む
SELECT pg_typeof(timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'UTC') AS typ,
       timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'UTC' AS val;
           typ            |          val           
--------------------------+------------------------
 timestamp with time zone | 2026-10-03 09:00:00+09      -- (1)

           typ            |          val           
--------------------------+------------------------
 timestamp with time zone | 2026-10-03 18:00:00+09      -- (2)

             typ             |         val         
-----------------------------+---------------------
 timestamp without time zone | 2026-10-03 09:00:00      -- (3)

             typ             |         val         
-----------------------------+---------------------
 timestamp without time zone | 2026-10-03 00:00:00      -- (4)

(-- (1) などの番号は、対応がわかるよう出力に書き足したものです。)

番号 入力 指定 結果 動き
(1) timestamp 09:00 Asia/Tokyo timestamptz 09:00+09 JSTの9時という一瞬になった
(2) timestamp 09:00 UTC timestamptz 18:00+09 UTCの9時という一瞬になり、JST表示で18時
(3) timestamptz 09:00+09 Asia/Tokyo timestamp 09:00 JSTの壁時計で読んだ
(4) timestamptz 09:00+09 UTC timestamp 00:00 UTCの壁時計で読んだ(9時間戻る)

同じ AT TIME ZONE 'UTC' でも、(2)は表示が9時間進み、(4)は9時間戻っています。 これが「逆向きに働く」の正体です。

セッションをUTCにして同じ4つを実行する

SET timezone = 'UTC';
SELECT timestamp   '2026-10-03 09:00'    AT TIME ZONE 'Asia/Tokyo' AS a,
       timestamp   '2026-10-03 09:00'    AT TIME ZONE 'UTC'        AS b,
       timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'Asia/Tokyo' AS c,
       timestamptz '2026-10-03 09:00+09' AT TIME ZONE 'UTC'        AS d;
           a            |           b            |          c          |          d          
------------------------+------------------------+---------------------+---------------------
 2026-10-03 00:00:00+00 | 2026-10-03 09:00:00+00 | 2026-10-03 09:00:00 | 2026-10-03 00:00:00

aとb(結果がtimestamptz)は表示がセッションに合わせて変わり、cとd(結果がtimestamp)はセッションに関係なく同じです。画面や帳票に出す値を作るときは、最後をtimestamp(またはto_charの文字列)にしておくと、接続先の設定に左右されなくなります。

UTCとJSTを行き来するコピペSQL

列の型と、入っている値の基準によって書き方が変わります。

timestamptz列をJSTで表示する

-- 型:timestamptz → timestamp
SELECT created_at AT TIME ZONE 'Asia/Tokyo' AS created_jst FROM some_table;

文字列にしたいときはto_charと組み合わせます。

SET timezone = 'UTC';
SELECT to_char(timestamptz '2026-10-03 00:00:00+00' AT TIME ZONE 'Asia/Tokyo',
               'YYYY-MM-DD HH24:MI:SS') AS jst_text;
      jst_text       
---------------------
 2026-10-03 09:00:00

AT TIME ZONE を付けずにto_charだけを使うと、セッションのタイムゾーンで文字列になります。

SET timezone = 'UTC';
SELECT to_char(timestamptz '2026-10-03 00:00:00+00', 'YYYY-MM-DD HH24:MI:SS TZ OF') AS s;
SET timezone = 'Asia/Tokyo';
SELECT to_char(timestamptz '2026-10-03 00:00:00+00', 'YYYY-MM-DD HH24:MI:SS TZ OF') AS s;
              s              
-----------------------------
 2026-10-03 00:00:00 UTC +00

              s              
-----------------------------
 2026-10-03 09:00:00 JST +09

UTCが入っているtimestamp列をJSTに直す

timestamp列にUTCの時刻を入れている設計はよくあります。この場合は2段階で書きます。

-- 1段目:UTCの時刻だと意味づけ(timestamptzになる)
-- 2段目:JSTの壁時計で読む(timestampに戻る)
SELECT timestamp '2026-10-03 00:00:00' AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Tokyo' AS jst;
         jst         
---------------------
 2026-10-03 09:00:00

JSTが入っているtimestamp列をUTCに直す

順番を入れ替えるだけです。

SELECT timestamp '2026-10-03 09:00:00' AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC' AS utc;
         utc         
---------------------
 2026-10-03 00:00:00

1段だけにすると逆方向へ9時間ずれる

UTCが入ったtimestampに AT TIME ZONE 'Asia/Tokyo' を1つだけ付けると、「この値はJSTの時刻です」という意味づけになり、意図と逆に動きます(セッションはUTC)。

SELECT timestamp '2026-10-03 00:00:00' AT TIME ZONE 'Asia/Tokyo' AS oops;
          oops          
------------------------
 2026-10-02 15:00:00+00

9時間進めたかったのに、9時間戻った値になりました。timestamp列をJSTに直すときは、必ず「いまの基準」→「ほしい基準」の順に2つ並べると覚えておくと取り違えにくくなります。

9時間足す方法との比較

SELECT timestamp '2026-10-03 00:00:00' + interval '9 hours' AS plus9;
        plus9        
---------------------
 2026-10-03 09:00:00

日本時間は夏時間が無いので、結果は AT TIME ZONE の2段と同じになります。ただし、夏時間のある地域では時期によって差が変わるため、固定の時間を足す方法はそのまま流用できません。タイムゾーン名で書いておくと、地域が増えても同じ形で対応できます。

変換パターンの判断表

列の型 入っている値 JSTで見たい UTCで見たい
timestamptz (一瞬) col AT TIME ZONE 'Asia/Tokyo' col AT TIME ZONE 'UTC'
timestamp UTCの時刻 col AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Tokyo' そのまま
timestamp JSTの時刻 そのまま col AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC'

タイムゾーン名の書き方

AT TIME ZONE に渡せる名前はいくつかあります。UTCの0時をJSTで読む例で比べました。

SET timezone = 'UTC';
SELECT timestamptz '2026-10-03 00:00+00' AT TIME ZONE 'Asia/Tokyo'       AS tokyo,
       timestamptz '2026-10-03 00:00+00' AT TIME ZONE 'JST'              AS jst,
       timestamptz '2026-10-03 00:00+00' AT TIME ZONE '+09'              AS plus09,
       timestamptz '2026-10-03 00:00+00' AT TIME ZONE 'UTC+9'            AS utcplus9,
       timestamptz '2026-10-03 00:00+00' AT TIME ZONE interval '9 hours' AS iv;
        tokyo        |         jst         |       plus09        |      utcplus9       |         iv          
---------------------+---------------------+---------------------+---------------------+---------------------
 2026-10-03 09:00:00 | 2026-10-03 09:00:00 | 2026-10-02 15:00:00 | 2026-10-02 15:00:00 | 2026-10-03 09:00:00
書き方 結果 コメント
'Asia/Tokyo' 09:00(+9時間) 地域名。いちばん読み違えにくい
'JST' 09:00(+9時間) 略称。今回の環境では認識された
interval '9 hours' 09:00(+9時間) UTCからの差を直接指定
'+09' 前日15:00(-9時間) 文字列で書いたオフセットは符号が逆に解釈される
'UTC+9' 前日15:00(-9時間) 同上

文字列の '+09' や 'UTC+9' は、POSIX形式として「UTCより9時間西」と解釈され、期待と逆向きになりました。 意図が伝わりやすく取り違えも起きにくい 'Asia/Tokyo' で統一するのがおすすめです。

名前を打ち間違えるとエラーになります。

SELECT timestamptz '2026-10-03 00:00+00' AT TIME ZONE 'Asia/Tokio';
ERROR:  time zone "Asia/Tokio" not recognized

使える名前は pg_timezone_names ビューで調べられます。

SELECT name, abbrev, utc_offset, is_dst
FROM pg_timezone_names
WHERE name IN ('Asia/Tokyo','UTC','America/New_York') ORDER BY name;
       name       | abbrev | utc_offset | is_dst 
------------------+--------+------------+--------
 America/New_York | EDT    | -04:00:00  | t
 Asia/Tokyo       | JST    | 09:00:00   | f
 UTC              | UTC    | 00:00:00   | f

なお、AT TIME ZONE は関数の形でも書けます。timezone('Asia/Tokyo', timestamptz '2026-10-03 00:00+00') は 2026-10-03 09:00:00 を返しました。

セッションのタイムゾーンを確認・変更する

SHOW timezone;                 -- 現在の設定を確認
SET timezone = 'Asia/Tokyo';   -- このセッションだけ変更
SET TIME ZONE 'UTC';           -- 同じ意味の標準SQLの書き方
  TimeZone  
------------
 Asia/Tokyo

 TimeZone 
----------
 UTC
範囲 書き方 実行
いまのセッション SET timezone = 'Asia/Tokyo'; 実行済み
いまのトランザクションだけ SET LOCAL timezone = 'Asia/Tokyo'; トランザクションの外で実行し、下の警告を確認
ロール単位 ALTER ROLE ロール名 SET timezone = 'Asia/Tokyo'; 未実行(共有環境のため)
データベース単位 ALTER DATABASE DB名 SET timezone = 'Asia/Tokyo'; 未実行(同上)
サーバー全体 postgresql.conf の timezone 未実行(同上)

SET LOCAL をトランザクションの外で実行すると、次の警告が出て設定は反映されません。

WARNING:  SET LOCAL can only be used in transaction blocks

表示がセッション設定に左右されないSQLにしたいときは、SETに頼らず AT TIME ZONE 'Asia/Tokyo' をSQLに書くと、どの接続から実行しても同じ結果になります。

JSTの日付で集計するときの境界ずれ

日別集計で「件数が合わない」ときは、日付の区切りがUTCの0時(JSTの9時)になっていることがよくあります。JSTの時刻で6件の注文を入れて確かめます。

CREATE TABLE orders (
  id         bigint PRIMARY KEY,
  ordered_at timestamptz NOT NULL,
  amount     int NOT NULL
);
INSERT INTO orders VALUES
  (1, '2026-10-02 23:30+09', 1000),
  (2, '2026-10-03 00:10+09', 2000),
  (3, '2026-10-03 08:59+09', 3000),
  (4, '2026-10-03 09:00+09', 4000),
  (5, '2026-10-03 23:59+09', 5000),
  (6, '2026-10-04 00:00+09', 6000);

JSTの日付で数えると、10/02が1件、10/03が4件(id 2〜5)、10/04が1件になるはずです。

直す前:セッションがUTCのまま ::date で集計

SET timezone = 'UTC';
SELECT ordered_at::date AS day, count(*), sum(amount)
FROM orders GROUP BY 1 ORDER BY 1;
    day     | count |  sum  
------------+-------+-------
 2026-10-02 |     3 |  6000
 2026-10-03 |     3 | 15000

::date はセッションのタイムゾーンで日付に切るため、UTCの0時(JSTの9時)が境界になりました。JSTの10/03 00:10と08:59の注文が、10/02に入っています。

直した後:JSTに直してから日付にする

SELECT (ordered_at AT TIME ZONE 'Asia/Tokyo')::date AS jst_day, count(*), sum(amount)
FROM orders GROUP BY 1 ORDER BY 1;
  jst_day   | count |  sum  
------------+-------+-------
 2026-10-02 |     1 |  1000
 2026-10-03 |     4 | 14000
 2026-10-04 |     1 |  6000

期待どおり1件・4件・1件になりました。この書き方はセッションの設定に関係なく同じ結果になります。

date_truncの3番目の引数を使う

timestamptzに対する date_trunc は、3番目の引数にタイムゾーンを渡せます。結果は「JSTでその日の0時」を指すtimestamptzです。

SELECT date_trunc('day', ordered_at, 'Asia/Tokyo') AS jst_day_start, count(*), sum(amount)
FROM orders GROUP BY 1 ORDER BY 1;
     jst_day_start      | count |  sum  
------------------------+-------+-------
 2026-10-01 15:00:00+00 |     1 |  1000
 2026-10-02 15:00:00+00 |     4 | 14000
 2026-10-03 15:00:00+00 |     1 |  6000

グループ分けは正しく、表示がUTCになっているだけです(2026-10-02 15:00:00+00 はJSTの10/03 0時)。月単位や週単位で切りたいときも 'month'、'week' に変えるだけで同じ形が使えます。

範囲で絞るときの境界

「JSTの10月3日分」をWHEREで取り出すとき、日付の文字列だけを書くとセッションのタイムゾーンで解釈されます。

SET timezone = 'UTC';
-- 直す前:UTCの10/03 0時〜10/04 0時になる
SELECT id FROM orders
WHERE ordered_at >= '2026-10-03' AND ordered_at < '2026-10-04' ORDER BY id;
 id 
----
  4
  5
  6

id 2と3が抜け、翌日分のid 6が入っています。境界をJSTで指定すると期待どおりです。

-- 直した後(1):タイムゾーン名で指定
SELECT id FROM orders
WHERE ordered_at >= timestamp '2026-10-03' AT TIME ZONE 'Asia/Tokyo'
  AND ordered_at <  timestamp '2026-10-04' AT TIME ZONE 'Asia/Tokyo'
ORDER BY id;

-- 直した後(2):オフセットを文字列に書く
SELECT id FROM orders
WHERE ordered_at >= '2026-10-03 00:00+09' AND ordered_at < '2026-10-04 00:00+09'
ORDER BY id;
 id 
----
  2
  3
  4
  5

終わりは「翌日0時未満」と書くのがコツです。BETWEEN ... AND '2026-10-03 23:59:59+09' でも今回のデータでは同じ4件でしたが、23:59:59.5のような小数秒のデータがあると取りこぼすので、半開区間(以上・未満)のほうが確実です。

インデックスが効く書き方

集計や検索の対象が増えてくると、条件の書き方で速度が大きく変わります。先ほどのordersに約40万行を足し、ordered_at にインデックスを作って比べました。

INSERT INTO orders
SELECT n, timestamptz '2026-01-01 00:00+09' + (n || ' minutes')::interval, 100
FROM generate_series(100, 400000) n;

CREATE INDEX orders_ordered_at_idx ON orders (ordered_at);
ANALYZE orders;

列を加工する書き方

EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders
WHERE (ordered_at AT TIME ZONE 'Asia/Tokyo')::date = '2026-10-03';
 Finalize Aggregate
   ->  Gather
         Workers Planned: 1
         ->  Partial Aggregate
               ->  Parallel Seq Scan on orders
                     Filter: (((ordered_at AT TIME ZONE 'Asia/Tokyo'::text))::date = '2026-10-03'::date)

列に AT TIME ZONE と ::date をかけているため、ordered_at のインデックスは使われず、全件を読むSeq Scanになりました。

列はそのまま、境界のほうを変換する書き方

EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders
WHERE ordered_at >= timestamp '2026-10-03' AT TIME ZONE 'Asia/Tokyo'
  AND ordered_at <  timestamp '2026-10-04' AT TIME ZONE 'Asia/Tokyo';
 Aggregate
   ->  Index Only Scan using orders_ordered_at_idx on orders
         Index Cond: ((ordered_at >= '2026-10-02 15:00:00+00'::timestamp with time zone) AND (ordered_at < '2026-10-03 15:00:00+00'::timestamp with time zone))

境界の値が実行前にUTCへ計算され(2026-10-02 15:00:00+00 以上、2026-10-03 15:00:00+00 未満)、インデックスだけで答えが出ています。

実行時間の比較

書き方 実行計画 件数 時間(psqlの \timing、各1回)
列を加工:(ordered_at AT TIME ZONE 'Asia/Tokyo')::date = '2026-10-03' Parallel Seq Scan 1444 65.4 ms
境界を変換:ordered_at >= ... AND ordered_at < ... Index Only Scan 1444 0.4 ms

同じ1444件を返しながら、約40万行のテーブルで100倍以上の差になりました。「列には手を加えず、比べる相手の値のほうをタイムゾーン変換する」のが基本です。

どうしてもJSTの日付で検索したいときは式インデックス

jst_day = '2026-10-03' の形を保ちたい場合は、同じ式でインデックスを作れます。

CREATE INDEX orders_jst_day_idx
  ON orders (((ordered_at AT TIME ZONE 'Asia/Tokyo')::date));
ANALYZE orders;

EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders
WHERE (ordered_at AT TIME ZONE 'Asia/Tokyo')::date = '2026-10-03';
 Aggregate
   ->  Index Scan using orders_jst_day_idx on orders
         Index Cond: (((ordered_at AT TIME ZONE 'Asia/Tokyo'::text))::date = '2026-10-03'::date)

一方、タイムゾーンを書かない ordered_at::date では式インデックスを作れません。

CREATE INDEX orders_bad_idx ON orders ((ordered_at::date));
ERROR:  functions in index expression must be marked IMMUTABLE

timestamptzからdateへのキャストは、セッションのタイムゾーンによって結果が変わるためです。タイムゾーンを式の中に固定で書けば、結果が一意に決まるのでインデックスにできます。

timestamp列をtimestamptzに変更する

UTCの時刻を入れてきたtimestamp列を、あとからtimestamptzに変えることもできます。USINGで「いまの値はUTCです」と意味づけします。

CREATE TABLE logs (id int, created_at timestamp);
INSERT INTO logs VALUES (1, '2026-10-03 00:00:00');

ALTER TABLE logs
  ALTER COLUMN created_at TYPE timestamptz
  USING created_at AT TIME ZONE 'UTC';

SET timezone = 'UTC';        SELECT * FROM logs;
SET timezone = 'Asia/Tokyo'; SELECT * FROM logs;
 id |       created_at       
----+------------------------
  1 | 2026-10-03 00:00:00+00

 id |       created_at       
----+------------------------
  1 | 2026-10-03 09:00:00+09

USINGを省略した場合は、ALTERを実行したセッションのタイムゾーンの時刻として解釈されるのが一般に知られた仕様です(省略時の動作は今回は未実行)。意図をはっきりさせるために、USINGで基準を明示するのがおすすめです。JSTが入っている列なら USING created_at AT TIME ZONE 'Asia/Tokyo' と書きます。型の変更はテーブルの書き換えを伴うので、行数の多いテーブルでは所要時間とロックを事前に検証環境で確かめておくと安心です(大きなテーブルでの所要時間は今回は未計測です)。

どちらの型を選ぶか

状況 選ぶ型 理由
作成日時・更新日時・ログの時刻 timestamptz 「いつ起きたか」という一瞬を表す。接続のタイムゾーンが違ってもずれない
複数の地域から使われる、またはサーバーがUTC timestamptz 表示側で AT TIME ZONE するだけで各地の時刻になる
アプリケーションがオフセットつきの文字列を送る timestamptz オフセットを換算して保存できる
「毎朝9時に通知」のような、場所ごとの壁時計の時刻 timestamp(+別列にタイムゾーン名) 一瞬ではなく「その土地での時刻」を表したい
既存のtimestamp列にUTCを入れる取り決めがある timestampのままでも可 変換は2段の AT TIME ZONE で行う。新しい列はtimestamptzにしておくと扱いやすい

迷ったときはtimestamptzを選び、表示するときだけJSTに直す形にすると、集計や比較で迷う場面が減ります。

1日と24時間の違い(夏時間のある地域)

timestamptzにintervalを足すときは、セッションのタイムゾーンの規則が使われます。夏時間が始まる日をまたぐニューヨーク時間で比べました。

SET timezone = 'America/New_York';
SELECT timestamptz '2026-03-07 12:00-05' + interval '1 day'    AS plus_1day,
       timestamptz '2026-03-07 12:00-05' + interval '24 hours' AS plus_24h;
       plus_1day        |        plus_24h        
------------------------+------------------------
 2026-03-08 12:00:00-04 | 2026-03-08 13:00:00-04

「1日後」は翌日の同じ時刻、「24時間後」は1時間ずれた時刻になりました。日本時間だけを扱う場合は差が出ませんが、海外の時刻を扱うときは意識しておくと役立ちます。

つまずきやすい点の一覧

症状 原因 対処
JSTに直したつもりが9時間戻った timestampに AT TIME ZONE 'Asia/Tokyo' を1つだけ付けた AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Tokyo' の2段にする
日別集計が9時を境に切れている ::date がセッション(UTC)で日付に切っている (col AT TIME ZONE 'Asia/Tokyo')::date にする
範囲検索で深夜〜朝の分が抜ける 日付文字列がセッションのタイムゾーンで解釈された 境界に AT TIME ZONE 'Asia/Tokyo' か +09 を付ける
インデックスがあるのに遅い 列側を加工している 列はそのまま、境界の値を変換する
'+09' を渡したら逆にずれた 文字列のオフセットは符号が逆に解釈される 'Asia/Tokyo' を使う
接続によって表示が違う timestamptzの表示はセッション設定に従う SQLに AT TIME ZONE を書く
functions in index expression must be marked IMMUTABLE タイムゾーンに依存する式でインデックスを作ろうとした 式の中にタイムゾーンを固定で書く
time zone "..." not recognized タイムゾーン名のつづり違い pg_timezone_names で名前を確認する

よくある質問

Q. timestamptzには、入力したときのタイムゾーンが保存されますか? A. 保存されません。入力時にUTCへ換算した一瞬だけが残ります。「どのタイムゾーンで入力されたか」も必要な場合は、タイムゾーン名を別の列に持たせます。

Q. timestampとtimestamptzでサイズや精度に違いはありますか? A. どちらも8バイトで、今回 pg_column_size でも8と表示されました。違いは、入出力のときにタイムゾーンの換算をするかどうかだけです。

Q. JSTに変換するとき、9時間足すのと AT TIME ZONE のどちらがよいですか? A. 日本時間だけなら結果は同じです。AT TIME ZONE 'Asia/Tokyo' は意図が読み取りやすく、夏時間のある地域にも同じ書き方で対応できるので、こちらをおすすめします。

Q. PostgreSQL 17の AT LOCAL は使えますか? A. 今回の16.15では syntax error at or near "LOCAL" となり使えませんでした。17以降での動作は今回は未実行です。16以前では AT TIME ZONE にタイムゾーン名を書きます。

まとめ

  • timestampは書かれた日時そのもの、timestamptzはUTCで持つ一瞬。サイズはどちらも8バイト
  • timestamptzは「保存はUTC、表示だけがセッションのタイムゾーンで変わる」。エポック秒で見ると中身は変わらない
  • AT TIME ZONEは、timestampには意味づけ(→timestamptz)、timestamptzには読み取り(→timestamp)として働く
  • UTCが入ったtimestampをJSTにするには AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Tokyo' の2段
  • JSTの日付で集計するときは (col AT TIME ZONE 'Asia/Tokyo')::date か、date_truncの3番目の引数を使う
  • 範囲検索は列を加工せず、境界の値をJSTで指定する。今回の計測では65.4 msが0.4 msになった
  • タイムゾーン名は 'Asia/Tokyo' で統一する。文字列の '+09' は逆向きに解釈される

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PostgreSQL timestampとtimestamptzの違いとJST化」

株式会社RJCでは、PostgreSQLの日時型やタイムゾーンの扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?