制約を足すマイグレーションを書き、同じファイルをもう一度流したら ERROR: constraint "..." already exists で止まった。ALTER TABLE ... ADD CONSTRAINT だけで書くと、再実行のたびにこれに当たる。ここを避けるには2つ要る。ひとつは「今この表にどんな制約が付いているか」を読めること。もうひとつは「何度流しても同じ結果になる」書き方を知ることだ。
前半で確認の道を2つ(psql の \d と、システムカタログ pg_constraint)、後半で冪等な張り直し(DROP CONSTRAINT IF EXISTS → ADD CONSTRAINT)を扱い、最後にどの制約から先に弾かれるかの判定順まで見る。要点を先に置くと、次の3つになる。
\d テーブル名は目で見る用。PRIMARY KEY/UNIQUE/CHECK/FOREIGN KEYとNOT NULLが1画面にまとまるpg_constraintはスクリプトから使う用。pg_get_constraintdef()で定義文をそのまま取り出せる。ただし PostgreSQL 17 ではNOT NULLはこのカタログに現れず、列の属性側にある- 同じ
ADD CONSTRAINTを2回流すと落ちる。ADD CONSTRAINT IF NOT EXISTSという構文は PostgreSQL に無い。冪等にするにはDROP CONSTRAINT IF EXISTS→ADD CONSTRAINTの2文にする
特定のプログラミング言語や ORM のマイグレーション機能は対象外とする。再現範囲は Docker + PostgreSQL + psql に絞る。制約そのものの意味(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY が何を保証するか)は PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY) にまとめてある。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
- PostgreSQL 17
psql
以降のコマンド実行場所は、特記がない限り WSL 側ターミナルだ。Windows 側から始める場合は、先に WSL へ入る。
wsl
1. 確認する・張り直す、の2つに分ける
やりたいことは2つに分けられる。
- 確認: 既存の表に付いている制約を読む。名前・種別・定義(どの列にどんな条件がかかっているか)を知る
- 張り直す: 制約を追加・削除・作り直す。しかもマイグレーションを再実行しても同じ結果になるように書く
確認には道が2つある。人が目で見るなら psql のメタコマンド \d、スクリプトから条件分岐したいならシステムカタログ pg_constraint を読む。張り直しでは、素朴に ADD CONSTRAINT だけを書くと再実行で落ちるので、DROP CONSTRAINT IF EXISTS を前に置く。この記事で扱うのはこの範囲で、制約を検証コスト無しで足す NOT VALID → VALIDATE CONSTRAINT の段階適用は PostgreSQLのマイグレーション設計(列追加・データ移行・切り替えの安全な流れ) に譲る。
2. 最小デモ環境を作る
記事用の空ディレクトリを用意する。
mkdir -p ~/projects/postgresql-inspect-constraints-demo
cd ~/projects/postgresql-inspect-constraints-demo
mkdir -p sql
code .
compose.yml を作成する。アプリケーションコンテナは置かず、DB の挙動だけを見る。
services:
db:
image: postgres:17
working_dir: /workspace
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
- ./:/workspace
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 20
volumes:
db-data:
.env.example を作成する。
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app
起動して、接続を確認する。
cp .env.example .env
docker compose up -d
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app -c "SELECT version();"
/var/run/postgresql:5432 - accepting connections
version
--------------------------------------------------------------------------------------------------------------------
PostgreSQL 17.9 (Debian 17.9-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)
3. 4種類の制約をそろえた表を作る
確認と張り直しの題材として、よく使う4種類の制約が1つの表に乗った状態を用意する。teams(親)と members(子)の2表にし、members に PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY と NOT NULL を持たせる。PostgreSQL にはこのほか EXCLUDE 制約もあるが、ここでは扱わない。sql/01-schema.sql を作成する。
CREATE TABLE teams (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE members (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id bigint NOT NULL REFERENCES teams (id),
email text NOT NULL,
age int,
CONSTRAINT members_email_key UNIQUE (email),
CONSTRAINT members_age_check CHECK (age >= 0)
);
UNIQUE と CHECK には CONSTRAINT 名前 を明示して付けている。あとで名前を指定して張り直すため、自動生成名に頼らず先に決めておく。適用する。
docker compose exec db psql -U app -d app -f sql/01-schema.sql
CREATE TABLE
CREATE TABLE
4. \d で目で見て確認する
まずは人が読む道。psql のメタコマンド \d テーブル名 を使う。
docker compose exec db psql -U app -d app -c "\\d members"
Table "public.members"
Column | Type | Collation | Nullable | Default
---------+---------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
team_id | bigint | | not null |
email | text | | not null |
age | integer | | |
Indexes:
"members_pkey" PRIMARY KEY, btree (id)
"members_email_key" UNIQUE CONSTRAINT, btree (email)
Check constraints:
"members_age_check" CHECK (age >= 0)
Foreign-key constraints:
"members_team_id_fkey" FOREIGN KEY (team_id) REFERENCES teams(id)
1つの出力に、この4種類と NOT NULL がまとめて現れている。読み方を分けて見る。
Nullable列のnot nullがNOT NULL制約。列ごとに表の上段へ出るIndexes:にPRIMARY KEYとUNIQUEが並ぶ。どちらも実体は索引(btree)で、members_email_keyは3章で付けた名前のまま出ているCheck constraints:にCHECK、Foreign-key constraints:にFOREIGN KEYが、それぞれ定義付きで出る
シェル経由で本文のように二重引用符で渡す場合、\d の前のバックスラッシュを \\d とエスケープする。psql に直接入って \d members と打つ形なら1つでよい。目で全体像をつかむにはこれで足りるが、「members_age_check があるかどうかでマイグレーションの分岐を変えたい」といった機械的な判断には向かない。次の道に移る。
5. pg_constraint でスクリプトから確認する
制約はシステムカタログ pg_constraint に1行ずつ入っている。SQL で読めるので、マイグレーションスクリプトや運用ツールから条件分岐に使える。表を 'members'::regclass で指し、pg_get_constraintdef() に制約の oid を渡すと、定義文がそのまま返る。
docker compose exec db psql -U app -d app -c "
SELECT conname, contype, pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'members'::regclass
ORDER BY contype, conname;"
conname | contype | definition
----------------------+---------+--------------------------------------------
members_age_check | c | CHECK ((age >= 0))
members_team_id_fkey | f | FOREIGN KEY (team_id) REFERENCES teams(id)
members_pkey | p | PRIMARY KEY (id)
members_email_key | u | UNIQUE (email)
(4 rows)
contype は1文字のコードで種別を表す。c は CHECK、f は FOREIGN KEY、p は PRIMARY KEY、u は UNIQUE だ。読みづらければ CASE でラベルに開くと、そのまま一覧として使える。ELSE contype::text を添えて、4種以外(例えば EXCLUDE 制約は x)が来ても種別欄が空にならないようにしておく。
docker compose exec db psql -U app -d app -c "
SELECT
conname AS name,
CASE contype WHEN 'p' THEN 'PRIMARY KEY'
WHEN 'u' THEN 'UNIQUE'
WHEN 'c' THEN 'CHECK'
WHEN 'f' THEN 'FOREIGN KEY'
ELSE contype::text END AS kind,
pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'members'::regclass
ORDER BY contype, conname;"
name | kind | definition
----------------------+-------------+--------------------------------------------
members_age_check | CHECK | CHECK ((age >= 0))
members_team_id_fkey | FOREIGN KEY | FOREIGN KEY (team_id) REFERENCES teams(id)
members_pkey | PRIMARY KEY | PRIMARY KEY (id)
members_email_key | UNIQUE | UNIQUE (email)
(4 rows)
\d に出ていた NOT NULL が、pg_constraint の一覧には1件も無い。\d の Nullable 列には3つの not null が並んでいたのに、カタログ側の4行はどれも NOT NULL ではない。
NOT NULL は pg_constraint に出ない(PostgreSQL 17)
この PostgreSQL 17 の pg_constraint は、列の NOT NULL を制約行として持たない。NOT NULL は列の属性 pg_attribute.attnotnull にあり、pg_constraint を contype = 'n' で絞っても0行になる。どの列が NOT NULL かは属性側を読む。
docker compose exec db psql -U app -d app -c "
SELECT attname, attnotnull
FROM pg_attribute
WHERE attrelid = 'members'::regclass AND attnum > 0 AND NOT attisdropped
ORDER BY attnum;"
attname | attnotnull
---------+------------
id | t
team_id | t
email | t
age | f
この差は次章の張り直しに直接効く。CHECK や FOREIGN KEY は pg_constraint にある名前付きの制約なので DROP CONSTRAINT 名前 で消せるが、NOT NULL は名前で消す対象ではなく、ALTER COLUMN ... DROP NOT NULL / SET NOT NULL で操作する。確認の道が2つに分かれるのと同じ理由で、消し方も分かれる。
6. 冪等に張り直す
マイグレーションは、同じファイルを2回流しても、途中まで流れて失敗した状態から流し直しても、最終結果が同じであってほしい。制約の追加でこれを崩す典型が、ADD CONSTRAINT だけを書くことだ。3章で作った members_age_check をもう一度足そうとすると止まる。
docker compose exec db psql -U app -d app -c "
ALTER TABLE members ADD CONSTRAINT members_age_check CHECK (age >= 0);"
ERROR: constraint "members_age_check" for relation "members" already exists
ADD COLUMN には IF NOT EXISTS があるので、制約にも同じ書き方が効きそうに見える。だが ADD CONSTRAINT IF NOT EXISTS という構文は PostgreSQL に無い。
docker compose exec db psql -U app -d app -c "
ALTER TABLE members ADD CONSTRAINT IF NOT EXISTS members_age_check CHECK (age >= 0);"
ERROR: syntax error at or near "NOT"
LINE 2: ...ALTER TABLE members ADD CONSTRAINT IF NOT EXISTS members_age...
^
DROP CONSTRAINT IF EXISTS → ADD CONSTRAINT
冪等にする定番は、追加の前に DROP CONSTRAINT IF EXISTS を置くことだ。あってもなくても、まず消してから足すので、実行前の状態に関わらず同じ結果に落ち着く。ただし DROP と ADD を素の2文で並べてはいけない。psql -f は既定でそれぞれを個別に確定(autocommit)するので、DROP が確定した後に ADD が失敗すると――例えば既存行が新しい条件に反する厳しい定義へ変えたとき――表は制約が1つも無い状態で残る。これを避けるため、2文を1つのトランザクションに包み、-v ON_ERROR_STOP=1 で途中失敗のとき止める。sql/02-constraints.sql を作成する。
BEGIN;
ALTER TABLE members DROP CONSTRAINT IF EXISTS members_age_check;
ALTER TABLE members ADD CONSTRAINT members_age_check CHECK (age >= 0);
COMMIT;
このファイルを2回続けて流す。
docker compose exec db psql -U app -d app -v ON_ERROR_STOP=1 -f sql/02-constraints.sql
docker compose exec db psql -U app -d app -v ON_ERROR_STOP=1 -f sql/02-constraints.sql
BEGIN
ALTER TABLE
ALTER TABLE
COMMIT
BEGIN
ALTER TABLE
ALTER TABLE
COMMIT
2回とも同じ結果に落ち着く。もし ADD が失敗しても、COMMIT に届かずトランザクション全体が巻き戻るので、元の制約がそのまま残る。制約の無い中間状態を残さないことが、この包み方の目的だ。
IF EXISTS が効くのは、消す対象が無いときにエラーにせず読み飛ばすからだ。制約が無い状態で DROP CONSTRAINT IF EXISTS を打つと、NOTICE を出して素通りする。
docker compose exec db psql -U app -d app -c "
ALTER TABLE members DROP CONSTRAINT IF EXISTS members_no_such_constraint;"
NOTICE: constraint "members_no_such_constraint" of relation "members" does not exist, skipping
ALTER TABLE
IF EXISTS を外すと、同じ状況がエラーになる。マイグレーションの途中でこれが出ると、以降が流れず止まる。
docker compose exec db psql -U app -d app -c "
ALTER TABLE members DROP CONSTRAINT members_no_such_constraint;"
ERROR: constraint "members_no_such_constraint" of relation "members" does not exist
NOT NULL はカラム側で冪等になる
5章で見たとおり NOT NULL は名前付きの制約ではないので、DROP CONSTRAINT の対象にならない。冪等化は ALTER COLUMN ... SET NOT NULL をそのまま使う。この文はもとから冪等で、既に NOT NULL の列へ再度流してもエラーにならない。
docker compose exec db psql -U app -d app -c "ALTER TABLE members ALTER COLUMN email SET NOT NULL;"
docker compose exec db psql -U app -d app -c "ALTER TABLE members ALTER COLUMN email SET NOT NULL;"
ALTER TABLE
ALTER TABLE
外すときも ALTER COLUMN email DROP NOT NULL を使う。整理すると、CHECK / FOREIGN KEY / UNIQUE は「DROP IF EXISTS してから ADD」、NOT NULL は「SET / DROP をそのまま」で使い分ける。
ただし前者には条件が2つある。ひとつは、名前で消すので制約に名前が付いていること。3章で CONSTRAINT 名前 を明示したのはこのためで、ADD UNIQUE (email) のような自動生成名だと環境ごとに名前が変わり、DROP CONSTRAINT 名前 を安定して書けない。もうひとつは、その制約が他表から参照されていないこと。別表の FOREIGN KEY が参照している PRIMARY KEY や UNIQUE を DROP CONSTRAINT しようとすると、cannot drop constraint ... because other objects depend on it で止まる。参照する外部キー側を先に処理する設計が要るので、この2文パターンは名前付きの単独制約に対して使う。
大きな表では「消して作り直す」の代償を意識する
DROP CONSTRAINT IF EXISTS → ADD CONSTRAINT は書き方として単純だが、行数の多い表では毎回の ADD CONSTRAINT が既存行の再検証を伴う。CHECK や FOREIGN KEY を足すと、PostgreSQL は「今あるデータがこの条件を満たすか」を確かめるためにテーブルを走査する。定義を変えていないのに毎回のマイグレーションで走査が走るのは無駄なので、「無ければ足す、あれば触らない」に寄せたい場合は DO ブロックで存在を確かめてから足す。
ここまでで members_age_check は既にあるので、初回追加の様子を見るために一度外し、無い状態から始める。
docker compose exec db psql -U app -d app -c "ALTER TABLE members DROP CONSTRAINT IF EXISTS members_age_check;"
docker compose exec db psql -U app -d app -c "
SELECT count(*) AS age_check_count
FROM pg_constraint
WHERE conrelid = 'members'::regclass AND conname = 'members_age_check';"
ALTER TABLE
age_check_count
-----------------
0
(1 row)
sql/03-guard.sql を作成する。
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'members'::regclass
AND conname = 'members_age_check'
) THEN
ALTER TABLE members ADD CONSTRAINT members_age_check CHECK (age >= 0);
END IF;
END $$;
1回目は制約が無いので ADD が走り、2回目は既にあるので何もしない。件数を挟んで確かめる。
docker compose exec db psql -U app -d app -f sql/03-guard.sql
docker compose exec db psql -U app -d app -c "
SELECT count(*) AS age_check_count
FROM pg_constraint
WHERE conrelid = 'members'::regclass AND conname = 'members_age_check';"
docker compose exec db psql -U app -d app -f sql/03-guard.sql
docker compose exec db psql -U app -d app -c "
SELECT count(*) AS age_check_count
FROM pg_constraint
WHERE conrelid = 'members'::regclass AND conname = 'members_age_check';"
DO
age_check_count
-----------------
1
(1 row)
DO
age_check_count
-----------------
1
(1 row)
出力の DO は2回とも同じだが、件数は 0 → 1 → 1 と動く。1回目で追加され、2回目は追加していない。このガードは名前の存在だけを見るので、複数のデプロイワーカーが同時に初回実行すると、片方の ADD が競合して already exists になりうる。マイグレーションを直列に流す前提で使う。
定義そのものを差し替えたいとき(条件式や参照先を変える)は、名前が同じでも中身が変わっており、このガードは「既にある」と判断して素通りしてしまう。その場合は古い制約を確実に消す DROP IF EXISTS → ADD(トランザクションで包んだ前掲の形)を採る。定義は変えず「初回だけ足したい」なら DO ブロックのガードで再検証を避ける。どちらも再実行で壊れない点は同じで、既存データを走査させるかどうかが違う。
7. どの制約から弾かれるか
制約違反のエラーは、一度に1件しか返らない。1つの INSERT が複数の制約に同時に違反するとき、どれが先に報告されるかは、PostgreSQL がどの順で検査するかで決まる。これは机上の話ではなく、エラーメッセージを読んで原因を切り分けるときに効く。
検査は大きく3つの段階に分かれ、種類によってどの段階で見るかが違う。
flowchart LR
A["INSERT / UPDATE"] --> B["行レベル検査<br/>NOT NULL → CHECK"]
B --> C["索引への挿入<br/>UNIQUE / PRIMARY KEY"]
C --> D["制約トリガ<br/>FOREIGN KEY"]
D --> E["行が確定"]
NOT NULL と CHECK は行そのものを組み立てる段階で見る。UNIQUE / PRIMARY KEY はそのあと索引へ入れる段階、FOREIGN KEY は参照先を確かめる制約トリガの段階だ。段階が後ろになるほどエラーは遅く出る。実際に、1つの INSERT で複数の制約を同時に破って確かめる。まず親と、既存メンバーを1件入れておく。
docker compose exec db psql -U app -d app -c "
INSERT INTO teams (name) VALUES ('team-a');
INSERT INTO members (team_id, email, age) VALUES (1, 'existing@example.com', 30);"
NOT NULL(email が NULL)・CHECK(age が負)・FOREIGN KEY(team_id が存在しない)を同時に破る行を入れる。
docker compose exec db psql -U app -d app -c "
INSERT INTO members (team_id, email, age) VALUES (999, NULL, -1);"
ERROR: null value in column "email" of relation "members" violates not-null constraint
DETAIL: Failing row contains (2, 999, null, -1).
3つ破っているのに、返るのは NOT NULL だけだ。NOT NULL を満たして CHECK と FOREIGN KEY だけを破ると、次は CHECK が出る。
docker compose exec db psql -U app -d app -c "
INSERT INTO members (team_id, email, age) VALUES (999, 'new@example.com', -1);"
ERROR: new row for relation "members" violates check constraint "members_age_check"
DETAIL: Failing row contains (3, 999, new@example.com, -1).
さらに CHECK も満たし、UNIQUE(email 重複)と FOREIGN KEY だけを破ると UNIQUE が出る。FOREIGN KEY はいちばん後ろだ。
docker compose exec db psql -U app -d app -c "
INSERT INTO members (team_id, email, age) VALUES (999, 'existing@example.com', 5);"
ERROR: duplicate key value violates unique constraint "members_email_key"
DETAIL: Key (email)=(existing@example.com) already exists.
観測される順は NOT NULL → CHECK → UNIQUE → FOREIGN KEY になった。この並びのうち、機構で決まるのは段階の境界だ。行レベル検査(NOT NULL と CHECK)は索引への挿入(UNIQUE)より前に走り、FOREIGN KEY は行を入れた後に走る制約トリガなので最後になる。一方、同じ行レベル検査の中で NOT NULL が CHECK より先に出るのは実装上の評価順で、ドキュメントで保証された順ではない。CHECK を2つ同時に破ったときどちらが出るか、NOT NULL の列が複数あるときどれが出るかも、列や制約の並び順に依存する。
実用上は「FOREIGN KEY のエラーが出たなら、少なくとも行レベル検査と UNIQUE は通っている」という段階の前後だけを当てにし、同段階内の細かい順序をアプリの判断に使わない。加えて、この観測は非 DEFERRABLE の既定での並びだ。FOREIGN KEY を DEFERRABLE INITIALLY DEFERRED で定義すると、参照確認は INSERT 時ではなく COMMIT(または SET CONSTRAINTS)まで遅れ、エラーが出る時点も後ろへ動く。
8. まとめ
確認したことは次のとおり。
- 確認は用途で選ぶ。目視は
\d テーブル名、スクリプトからの分岐はpg_constraintをpg_get_constraintdef()で読む。NOT NULLだけは PostgreSQL 17 のpg_constraintに出ないのでpg_attribute.attnotnullを見る - 冪等な張り直しは
DROP CONSTRAINT IF EXISTS→ADD CONSTRAINT(ADD CONSTRAINT IF NOT EXISTSは無い)。DROPとADDは1トランザクションに包み、途中失敗で制約の無い状態を残さない - この張り直しは名前付きの単独制約が前提。他表の
FOREIGN KEYに参照されるPRIMARY KEY/UNIQUEは依存があり単純には張り直せない。NOT NULLはSET/DROP NOT NULLで冪等 - 定義を変えないなら
DOブロックのガードで既存行の再検証を避けられる(直列実行が前提) - 複数の制約を同時に破ると、行レベル検査(
NOT NULL/CHECK)→UNIQUE→FOREIGN KEYの段階順でいちばん手前が報告される(非DEFERRABLEの既定)
「今どうなっているか」を先に読めれば、張り直しのマイグレーションは再実行で壊れない形に寄せられる。制約そのものの意味は PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY)、検証コストを抑えて制約を段階的に足す流れは PostgreSQLのマイグレーション設計(列追加・データ移行・切り替えの安全な流れ) にある。