はじめに
この記事上の「スキーマ」という単語は PostgreSQL の CREATE SCHEMA 文によって作成される名前空間のことではなく、DDL の集合としてのデータベーススキーマを指しています。
この記事では、PostgreSQL 用の宣言的なスキーマ管理ツールである pgschema について、同じく宣言的なスキーマ管理ツールであり、広く利用されている sqldef (psqldef) と比較し、pgschema を選択した理由について語っていきます。主に RLS への対応状況が決め手となったので、その点について特に重点的に触れていきます。また、Online DDL のサポートについても魅力的に感じたので、その点についても軽く触れます。
なお、筆者はこれまで MySQL や Spanner を主に触ってきたため、PostgreSQL 歴は約1ヶ月です。誤りを見つけた場合はコメントや編集リクエストを送っていただけると喜びます。
記事中で使用している各ツールのバージョンは以下の通りです:
- pgschema: v1.5.1
- psqldef: v3.8.13
宣言的なスキーマ管理とは?
旧来のスキーマ管理は、データベースの変更を時系列に並んだマイグレーションファイルとして管理し、それらを順番に適用していく、いわゆる「命令的」な手法が一般的でした。例えば、 <日時>_<操作内容>.sql のようなファイルをスキーマ変更のたびに追加し、どこまで適用されているかを専用のテーブルで管理する、といった形です。
この手法は一見すると単純で分かりやすいものの、運用が長期化したり、環境数が増えてくるにつれて、いくつかの問題が顕在化してきます。
- データベースの中身を見ないとどこまで適用されているかわからない
- 最終的にどのようなスキーマ構造になることが期待されているのか、実際に適用してみないと把握しづらい
- プルリクエストごとに検証環境を動的に作成するような運用では、長年積み重なったマイグレーションを最初からすべて適用する必要があり、セットアップに時間がかかる
こうした問題に対する1つの解が「宣言的なスキーマ管理」です。宣言的なスキーマ管理では、期待されるスキーマ構造のみを管理し、ツールが現在のデータベースの状態との差分を計算します。そして、その差分を SQL として提示し、必要に応じて適用します。Terraform を使ったことがある方であれば、「現在の状態」と「あるべき状態」を比較して差分を反映する、という流れをイメージしやすいのではないでしょうか。
pgschema の使い方
0. インストール
基本的には公式ドキュメントを参照していただくのが確実ですが、いくつかインストール例をピックアップして載せます。
brew
brew tap pgschema/pgschema
brew install pgschema
go install
go install github.com/pgschema/pgschema@latest
mise
GitHub リリースが存在するため、 mise.toml に以下のように記述することで管理できます。筆者は最近 mise を使用しているため、この方法で管理しています。
[tools]
"ubi:pgschema/pgschema" = "v1.5.1"
1. 既存スキーマをダンプ
$ PGPASSWORD=testpwd1 pgschema dump \
--host localhost \
--db testdb \
--user postgres \
--schema public > schema.sql
2. ダンプしたスキーマを編集
--- a/schema.sql
+++ b/schema.sql
@@ -12,5 +12,6 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
- username varchar(50) NOT NULL UNIQUE
+ username varchar(50) NOT NULL UNIQUE,
+ age INT NOT NULL
);
3. 差分を確認
$ PGPASSWORD=testpwd1 pgschema plan \
--host localhost \
--db testdb \
--user postgres \
--schema public \
--file schema.sql \
--output-human stdout \
--output-json plan.json
Plan: 1 to modify.
Summary by type:
tables: 1 to modify
Tables:
~ users
+ age (column)
Transaction: true
DDL to be executed:
--------------------------------------------------
ALTER TABLE users ADD COLUMN age integer NOT NULL;
4. 変更を適用
$ PGPASSWORD=testpwd1 pgschema apply \
--host localhost \
--db testdb \
--user postgres \
--schema public \
--plan plan.json
Plan: 1 to modify.
Summary by type:
tables: 1 to modify
Tables:
~ users
+ age (column)
Transaction: true
DDL to be executed:
--------------------------------------------------
ALTER TABLE users ADD COLUMN age integer NOT NULL;
Do you want to apply these changes? (yes/no): yes
Applying changes...
Changes applied successfully!
RLS
PostgreSQL の強力な機能として、特定のカラムの値を元に読み書き可能な行を制限する RLS (Row Level Security) があります。SaaS を開発する上では非常に便利な機能で、簡単にマルチテナンシーを実現することができます。
当初は sqldef (psqldef) を使用しようと考えていたのですが、この RLS の対応状況に問題がありました。
以下のような old.sql を適用済みのデータベースに対して、RLS を有効化する new.sql を用意し、pgschema と psqldef のそれぞれで差分を確認するコマンドを実行してみます。
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
username TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
username TEXT NOT NULL
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
psqldef の例
$ psqldef -h $PGHOST -p $PGPORT -U $PGUSER $PGDATABASE --dry-run < new.sql
2025/12/21 15:38:07 WARN Generic parser failed on full SQL, using pgquery fallback error="found syntax error when parsing DDL \"ALTER TABLE users ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY tenant_isolation_policy ON users\n USING (tenant_id = current_setting('app.current_tenant_id')::UUID)\": syntax error at line 1, column 26 near 'ENABLE'\n ALTER TABLE users ENABLE ROW LEVEL SECURITY;\n ^"
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x28 pc=0x1014dbf14]
(スタックトレース省略)
psqldef は組み込みのパーサーである GenericParser でパースできなかった場合、pgquery にフォールバックします1が、どちらでもパースできていません。
pgschema の例
$ pgschema plan \
--host "$PGHOST" \
--db "$PGDATABASE" \
--user "$PGUSER" \
--schema public \
--file new.sql \
--output-human stdout
Plan: 1 to modify.
Summary by type:
tables: 1 to modify
Tables:
~ users
+ tenant_isolation_policy (policy)
+ users (rls)
DDL to be executed:
--------------------------------------------------
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON users TO PUBLIC USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
pgschema では、上記のように RLS の構文が正しくパースされ、差分が出力されています。
FORCE ROW LEVEL SECURITY の対応状況
執筆時点で最新版のリリースである pgschema v1.5.1 では、 ENABLE ROW LEVEL SECURITY に対応しているものの、 FORCE ROW LEVEL SECURITY に対応していませんでした。対応策として、スキーマに変更を加えるテーブル所有者のユーザーとアプリケーションから使用されるユーザーを別にしておく方法があります。これは最小権限の原則に基づいており、この問題への対応以外にもよい管理方法です。
この問題について issue を作成したところ、2~3 時間ほどで PR が作成されて、マージされていました。対応が早いのは好印象です。
- issue: https://github.com/pgschema/pgschema/issues/214
- PR: https://github.com/pgschema/pgschema/pull/215
FORCE ROW LEVEL SECURITY については前述の通り対応していただけたのですが、サポートされていない構文が含まれていた際エラーにならず、それを無視して他の変更を通してしまうという挙動には不安感が残ります。サポートされていないことに気づかずそのような構文を書いてしまい、無視されていることを見落として本番で事故が起こってしまう事態にも繋がりかねないので、この点に関しては今後うまくフィードバックしたいところです。
Online DDL
PostgreSQL では、ほとんどの ALTER TABLE 文がテーブルロック(ACCESS EXCLUSIVE ロック)を取得します。
ALTER TABLEは既存のテーブルの定義を変更します。 以下のようにいくつかの副構文があります。 要求されるロックレベルはそれぞれの副構文によって異なることに注意してください。 特に記述がなければACCESS EXCLUSIVEロックを取得します。 複数のサブコマンドが使われるときは、それらのサブコマンドが要求するうち、もっとも高いレベルのロックを取得します。
https://www.postgresql.jp/docs/17/sql-altertable.html
また、インデックスの新規作成時も、 CONCURRENTLY を指定しなければロックを取得します。
CONCURRENTLY
このオプションを使用すると、PostgreSQLは、対象テーブルに対する同時挿入、更新、削除を防止するようなロックを獲得せずにインデックスを作成します。 通常のインデックス作成処理では、完了するまで対象テーブルへの書き込みはできません(読み取りは可能です)。
https://www.postgresql.jp/document/17/html/sql-createindex.html
これらの制約により、気軽に稼働中のデータベースに DDL を実行するとダウンタイムが発生する可能性があり、人間が差分を書くことによって、これを誘発する可能性が高まります。
pgschema では、既存テーブルへのインデックス作成時にロックを取得しないように CONCURRENTLY を自動で付与したり、できるだけロック期間が短くなるような差分を生成することで、この問題に対処しています。
例として、以下のような old.sql と new.sql を用意します
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
username TEXT NOT NULL
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
username TEXT NOT NULL
);
CREATE INDEX idx_users_tenant_id ON users (tenant_id);
old.sql 適用済みのデータベースに対して new.sql との差分を psqldef / pgschema でそれぞれ確認すると、以下のようになります。
BEGIN;
CREATE INDEX idx_users_tenant_id ON users (tenant_id);
COMMIT;
-- Transaction Group #1
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_tenant_id ON users (tenant_id);
-- Transaction Group #2
-- pgschema:wait
SELECT
COALESCE(i.indisvalid, false) as done,
CASE
WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total
ELSE 0
END as progress
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE c.relname = 'idx_users_tenant_id';
psqldef では CONCURRENTLY 指定がないのでロックを取得しますが、pgschema では CONCURRENTLY を追加してくれています。さらに、インデックスの作成完了まで待つような処理を行ってくれているので、インデックスの作成が完了してからアプリケーションをデプロイすることができて安心です。
もちろん、このような自動変換に対応している構文と対応していない構文があるため、詳しくはドキュメントを参照してください。
https://www.pgschema.com/workflow/online-ddl
まとめ
pgschema はまだ出たばかりで枯れていないツールですが、RLS や Online DDL のサポートが強力で、私たちのユースケースでは採用する価値があると判断しました。枯れていない部分については、私たちが枯らしていくという気持ちで還元していければよいかなという心構えでいます。
結局のところ、任意の枯れているツールは誰かが初期に利用してフィードバックし、改善を積み重ねていったものです。新しいツールを過度に敬遠せず、メリットがあると判断すれば導入して自らの手で改善していきましょう。