公開日 2026-09-03

PostgreSQLの制約を確認する・冪等に張り直す(\d / pg_constraint / DROP CONSTRAINT IF EXISTS)

PostgreSQL の制約を `\d` と `pg_constraint` で確認し、`DROP CONSTRAINT IF EXISTS` で何度流しても壊れない形に張り直す書き方を psql の実行結果で確認する。

目次

  1. 前提環境
  2. 1. 確認する・張り直す、の2つに分ける
  3. 2. 最小デモ環境を作る
  4. 3. 4種類の制約をそろえた表を作る
  5. 4. \d で目で見て確認する
  6. 5. pg_constraint でスクリプトから確認する
  7. NOT NULL は pg_constraint に出ない(PostgreSQL 17)
  8. 6. 冪等に張り直す
  9. DROP CONSTRAINT IF EXISTS → ADD CONSTRAINT
  10. NOT NULL はカラム側で冪等になる
  11. 大きな表では「消して作り直す」の代償を意識する
  12. 7. どの制約から弾かれるか
  13. 8. まとめ

制約を足すマイグレーションを書き、同じファイルをもう一度流したら 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のマイグレーション設計(列追加・データ移行・切り替えの安全な流れ) にある。