Postgres 18 から来た方へ:Postgres 17 で変えるべきこと

Relyt One は PostgreSQL 17 で動いています。スキーマや習慣が Postgres 18 由来の場合に、17 で失敗するもの、代わりに書くべきもの、18 で取得したダンプを読み込む方法を紹介します。すべてのスニペットは両方のバージョンで実行しました。

Zhan Lu約 11 分で読めます
目次

Relyt One は PostgreSQL 17 で動いています。Postgres 18 は 2025 年 9 月にリリースされたため、新しいプロジェクト、チュートリアル、AI が生成したスキーマの多くが、いまでは 18 専用の機能に頼っています。uuidv7()、仮想生成列、RETURNING での OLD と NEW、時間的主キーなどです。17 では、これらはどれもエラーになります。この記事では、失敗するものを挙げ、それぞれについて今日の 17 で動く代替手段を示し、18 で取得したダンプを読み込む手順を順に説明します。すべてのスニペットは PostgreSQL 17.5 と 18.6 で実行しており、示している出力は実際に得られたものです。

どれも特殊なものではありません。ほとんどのスキーマはこれらの機能をまったく使っておらず、使っているスキーマでもたいていは 1 つか 2 つです。大切なのは、マイグレーションが途中で失敗した後ではなく、その前に見つけることです。

Postgres 18 の 4 つの機能と Postgres 17 での代替:uuidv7() は SQL 関数に、仮想生成列は STORED またはビューに、RETURNING old/new は FOR UPDATE 付きの CTE に、WITHOUT OVERLAPS は btree_gist を使った排他制約になります

まず、接続先を確認する

SELECT version();
SHOW server_version_num;   -- 170005 は 17.5 を意味します。17 で始まるものはすべて Postgres 17 です

実行時にバージョンで分岐する必要がある場合は、version() の文字列ではなく、server_version_num を数値として比較してください(>= 180000)。

1. uuidv7()

Postgres 18 では、時刻順に並ぶ UUID を生成する uuidv7() が追加されました。新しい行がインデックスのあちこちではなく末尾に入るため、ランダムなバージョン 4 の UUID よりも主キーに適しています。17 では次のようになります。

ERROR:  function uuidv7() does not exist

同じ形式は SQL で生成できます。バージョン 7 の UUID は、ミリ秒単位の 48 ビットの Unix タイムスタンプの後にランダムなビットが続き、バージョンビットとバリアントビットが設定されたものです。gen_random_uuid()(Postgres 13 から組み込み、拡張機能は不要)がランダムなビットとバリアントをすでに提供しているので、この関数は先頭の 6 バイトをタイムスタンプで上書きし、2 ビットを反転させてバージョンを 4 から 7 に変えます。

CREATE FUNCTION public.uuidv7() RETURNS uuid
LANGUAGE sql VOLATILE
AS $$
  -- ミリ秒単位の 48 ビット Unix 時刻を置き、v4 UUID のランダムなビットの上にバージョン 7 を設定
  SELECT encode(
    set_bit(set_bit(
      overlay(uuid_send(gen_random_uuid())
              PLACING substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3)
              FROM 1 FOR 6),
      52, 1), 53, 1),
    'hex')::uuid;
$$;

CREATE TABLE events (
  id   uuid PRIMARY KEY DEFAULT public.uuidv7(),
  body text
);

Postgres 15 以降では、デフォルトで public にオブジェクトを作成できるのはデータベースのオーナーだけなので、これはオーナーとして実行してください(または、自分が所有するスキーマに関数を置いてください)。生成した 10,000 個の値を確認したところ、すべてがバージョン 7 と RFC 9562 のバリアントを持ち、埋め込まれたタイムスタンプはサーバーの時計と一致しました。now() ではなく clock_timestamp() を使っているので、同じトランザクション内で後から挿入した行にも、より後のタイムスタンプが付きます。

本当の違いが 1 つあります。18 の組み込み関数はミリ秒未満の部分も埋めるため、私たちのテストでは、18 で 1 つの文の中で生成した 1,000 個の値が厳密な昇順になりました。SQL 版は、ミリ秒をまたぐ値は順序付けますが、1 ミリ秒の中では順序付けません。同じミリ秒に生成された値はランダムな順序になります。インデックスの局所性にとっては問題ありません。1 つのセッションから得られる ID が厳密に増加することにコードが依存している場合は、順序付けにはシーケンスを使ってください。

関連する落とし穴があります。uuid_extract_timestamp() は 17 にもありますが、17 ではバージョン 1 の UUID しか理解せず、バージョン 7 には NULL を返します。バージョン 7 のサポートは Postgres 18 で追加されました。17 では、タイムスタンプを自分で取り出してください。

CREATE FUNCTION public.uuidv7_timestamp(u uuid) RETURNS timestamptz
LANGUAGE sql IMMUTABLE STRICT
AS $$
  SELECT to_timestamp(('x' || substr(replace(u::text, '-', ''), 1, 12))::bit(48)::bigint / 1000.0);
$$;

SELECT public.uuidv7_timestamp('019535d9-3df7-79fb-b466-fa907fa17f9e');
-- 2025-02-24 02:46:24.503+00。18 の uuid_extract_timestamp() が返すのと同じ値です

組み込み関数と違い、このヘルパーはバージョンを確認しません。バージョン 4 の UUID を渡すと、NULL ではなく意味のない時刻を返します。バージョン 7 だとわかっている ID に対してだけ呼び出してください。

2. 仮想生成列

18 では、特に指定しない限り、生成列は読み取られるときに計算されます。VIRTUAL が新しいデフォルトで、STORED は従来の動作です。17 には格納生成列しかなく、キーワードが必須です。次の 2 つはどちらも 17 で失敗します。

CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0) VIRTUAL);
-- ERROR:  syntax error at or near "VIRTUAL"

CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0));
-- ERROR:  syntax error at or near ")"

2 つ目は見た目以上に重要です。18 ではこれは有効で、仮想を意味するため、18 向けに書かれたスキーマにはキーワードがまったくないことがよくあります。代替手段は 2 つあります。

-- 選択肢 A:格納する。列もクエリも同じ。ディスクと書き込み時間を消費する。
CREATE TABLE product (
  price_cents int,
  price       numeric GENERATED ALWAYS AS (price_cents / 100.0) STORED
);

-- 選択肢 B:テーブルを軽く保ち、ビューで計算する。
CREATE TABLE product_base (price_cents int);
CREATE VIEW product AS
  SELECT *, price_cents / 100.0 AS price FROM product_base;

このような単純なビューは自動的に更新可能なので、INSERT INTO product (price_cents) VALUES (1999) はビューを通しても動作します。17 で試しました。式が大きい場合やテーブルへの書き込みが多い場合を除き、A を選んでください。

3. RETURNING での OLD と NEW

18 では、RETURNING で変更前と変更後の行を参照できます。

UPDATE acct SET balance = balance - 30 WHERE id = 1
RETURNING old.balance AS before, new.balance AS after;

17 では、これは missing FROM-clause entry for table "old" で失敗します。以前の値を取得するには、CTE で行を読み取ってロックし、UPDATE でそれと結合します。

WITH old AS (
  SELECT id, balance FROM acct WHERE id = 1 FOR UPDATE
)
UPDATE acct a SET balance = a.balance - 30
FROM old
WHERE a.id = old.id
RETURNING old.balance AS before, a.balance AS after;
 before | after
--------+-------
    100 |    70

これを正しくしているのは FOR UPDATE です。更新が行を読み取る前に行をロックするので、その間に並行する変更が入り込んで before が嘘になることはありません。これがないと、並行実行下では、更新の時点で一度も行の状態ではなかった「before」の値を返すことがあります。DELETE では、普通の RETURNING * がすでに古い行を返します。INSERT … ON CONFLICT DO UPDATE では、古い値を得るためのきれいな 17 の同等手段はありません。必要な場合は、先に FOR UPDATE で SELECT するか、監査トリガーを用意してください。行がまだ存在しないときは、先に SELECT しても役に立ちません。ロックする対象がないため、並行する挿入が間に入り込む可能性が残ります。

4. 時間的制約:WITHOUT OVERLAPS

18 では、「同じ部屋について時間範囲が重なる 2 行は存在しない」ことを主キーとして強制できます。

-- 18 でも、room が普通の int なので btree_gist が必要
CREATE TABLE booking (room int, during tstzrange, PRIMARY KEY (room, during WITHOUT OVERLAPS));
-- on 17: ERROR:  syntax error at or near "WITHOUT"

この保証自体は、排他制約として何年も前から利用できます。普通の等値列(room)と範囲を 1 つの GiST インデックスで組み合わせるには btree_gist 拡張機能が必要です。これは trusted とマークされているので、データベースに対する CREATE 権限を持つロール(オーナーは持っています)であれば、スーパーユーザー権限なしでインストールできます。

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE booking (
  room   int       NOT NULL,
  during tstzrange NOT NULL CHECK (NOT isempty(during)),
  EXCLUDE USING gist (room WITH =, during WITH &&)
);

INSERT INTO booking VALUES (101, '[2026-11-10 09:00Z, 2026-11-10 10:00Z)');
INSERT INTO booking VALUES (101, '[2026-11-10 10:00Z, 2026-11-10 11:00Z)');  -- OK:接しているだけで、重なってはいない
INSERT INTO booking VALUES (101, '[2026-11-10 09:30Z, 2026-11-10 10:30Z)');
-- ERROR:  conflicting key value violates exclusion constraint "booking_room_during_excl"

CHECK は重要です。18 は WITHOUT OVERLAPS のキーで空の範囲を拒否しますが、空の範囲は何とも重ならないため、このチェックがないと排他制約は空の範囲をいくつでも受け入れてしまいます。拡張機能がデータベースで利用可能かどうかは、SELECT * FROM pg_available_extensions WHERE name = 'btree_gist'; で確認してください。私たちがテストした Relyt One のコンピュートイメージには、バージョン 1.7 で含まれていることを確認しました。17 でできないのは、この機能のもう半分である時間的外部キー(FOREIGN KEY (…, PERIOD …))です。宣言的な同等手段はありません。トリガーかアプリケーションで強制し、テストしてください。範囲の端で間違えやすいからです。

5. 変更する必要がないもの

18 の目玉機能のいくつかは、SQL ではなくパフォーマンスや運用を変えるものです。スキーマやクエリでこれらに言及することはないので、書き直すものはありません。

  • 非同期 I/O は、18 でシーケンシャルスキャン、ビットマップヒープスキャン、バキュームを高速化します。17 でも同じクエリは動作し、同期 I/O を使います。
  • スキップスキャンにより、18 ではクエリが先頭の列で絞り込んでいなくても、複数列の B-tree インデックスを使えます。17 で (tenant_id, created_at) の 2 番目の列だけで絞り込むクエリは、そのインデックスを使わないことがあります。EXPLAIN で計画を確認し、重要であれば、絞り込む列を先頭にしたインデックスを追加してください。
  • OAuth クライアント認証は 18 の新機能です。Relyt One には、コンソールから取得した sslmode=require 付きの接続文字列を使い、ロールのパスワードで TLS 経由で接続します。
  • 18 での MD5 パスワードの非推奨化は、17 のスキーマには影響しません。

Postgres 18 で取得したダンプを読み込む

pg_dump のドキュメントには明記されています。その出力はより新しいサーバーに読み込めることが想定されており、より古いメジャーバージョンへの読み込みは「保証されない」うえに「ダンプファイルを手作業で編集する必要がある場合がある」とされています。18 から 17 への移行はまさにこのケースです。以下は私たちが使った手順と、その途中で壊れたものです。

読み込みには 17.6 以降の psql を使います。 現在の pg_dump のダンプは \restrict の行で始まります。これはセキュリティ修正(CVE-2025-8714)のために 17.6 で追加された psql のメタコマンドで、Postgres 18 のすべてのリリースに含まれています。psql 17.5 はこれを invalid command \restrict で拒否します。ON_ERROR_STOP=1 を付けていると読み込みはその場で中断し、付けていないと psql はそのまま続行し、\restrict が提供するはずの保護を静かにスキップします。18.x のクライアントか 17.6 以降を使ってください。psql 18 は問題なく 17 のサーバーに接続できます。

スキーマとデータを別々にダンプします。 ターゲット側のロールは異なるので、オーナーと権限は含めません。

pg_dump "$SOURCE_URL" --schema-only --no-owner --no-privileges --quote-all-identifiers > schema.sql
pg_dump "$SOURCE_URL" --data-only   --no-owner --no-privileges --quote-all-identifiers > data.sql

--quote-all-identifiers は、バージョンをまたぐ読み込みのために pg_dump のドキュメントが推奨しているものです。一方のバージョンでは予約語で、もう一方ではそうでない語による思わぬ問題を避けられます。スキーマ全体の後にデータを読み込むと、読み込み中にインデックス、外部キー、トリガーがすでに存在することになります。これは単一のダンプより遅く、独自のトリガーが行ごとに発火します。大きなデータベースでは、代わりに --section=pre-data、data、post-data で分割し、pre-data のファイルだけを編集してください。

何かを読み込む前に、18 専用の構文を探します。

grep -nE 'uuidv7\(|uuidv4\(|WITHOUT OVERLAPS|PERIOD ' schema.sql
grep -nE 'GENERATED ALWAYS AS \(' schema.sql | grep -v STORED     # 仮想列
grep -niE 'RETURNING +(old|new)\.|RETURNING +WITH *\(' schema.sql  # 関数とプロシージャ内

私たちのテストスキーマでは、これで 2 行が見つかりました。

56:    id uuid DEFAULT uuidv7() NOT NULL,
58:    price numeric GENERATED ALWAYS AS (((cents)::numeric / 100.0))

58 行目に注目してください。pg_dump 18 は仮想列をキーワードなしで書き出すので、VIRTUAL で検索しても見逃します。後ろに STORED がない GENERATED ALWAYS AS (…) は、NOT NULL が続くものも含めてすべて仮想列です。STORED を追加してください。

置き換えた関数を修飾します。 ダンプは先頭で search_path を空に設定し、18 では uuidv7() が組み込み関数なので、ダンプはそれを修飾なしで呼び出します。読み込み中にあなたの public.uuidv7() は見つかりません。

ERROR:  function uuidv7() does not exist

デフォルトを public.uuidv7() に書き換えてください。そのうえで、置き換え用の関数を先に作成し、最初のエラーで停止する 1 つのトランザクションですべてを読み込みます。

psql "$TARGET_URL" -v ON_ERROR_STOP=1 --single-transaction \
  -f uuidv7.sql -f schema.sql -f data.sql

$TARGET_URL は、?sslmode=require で終わる Relyt One の接続文字列です。この 2 つの編集で、私たちのテストデータベースは 17 にきれいに読み込まれました。ID 列は正しい値から続き、生成列は読み込み時に再計算され、新しい行には置き換え用の関数からバージョン 7 の UUID が付きました。生成列の値はデータダンプにまったく含まれないため、列を仮想から格納に切り替えてもデータの変更は必要ありません。

移行前のチェックリスト

  • ターゲットで SHOW server_version_num を実行し、どのメジャーバージョンなのかを把握している
  • psql 17.6 以降または 18.x と -v ON_ERROR_STOP=1 で読み込む
  • スキーマのダンプを grep し、uuidv7(、uuidv4(、STORED のない GENERATED ALWAYS AS、WITHOUT OVERLAPS、PERIOD、大文字小文字を問わない RETURNING old. / new. を探す
  • スキーマより前に置き換え用の関数を作成し、修飾する(public.uuidv7())
  • WITHOUT OVERLAPS を排他制約に置き換え、時間的外部キーはトリガーに移す
  • アプリケーションのコードも検索する。ORM やクエリビルダーは RETURNING を生成し、リポジトリ内のスキーマファイルが 18 を対象にしていることもある
  • 本番環境の接続先を切り替える前に、ブランチに対してテストスイートを実行する

最後のステップは、Relyt One なら手軽にできます。ブランチを作成してそこに読み込み、その接続文字列に対してテストを実行し、何かおかしければ破棄してください。

ブランチで試す

タグPostgresmigration

執筆者

Zhan Lu

次のデータベースは、ブランチひとつ先に。

1 分以内に無料プロジェクトを作成。クレジットカードもセットアップも不要です。

ブログに戻る