Article
🐘 PostgreSQLのNOT VALIDで制約追加を段階化する:CHECKと外部キーの実務手順
稼働中のデータベースに「注文金額は0以上」「注文に紐づく顧客が存在する」といった制約を追加したいとします。制約はデータ不整合を防ぐ仕組みですが、大きなテーブルにそのまま追加すると、既存行の検査に時間がかかり、書き込みが長時間待たされる場合があります。
PostgreSQLの NOT VALID → VALIDATE CONSTRAINT を使うと、制約の登録と既存データの全件検証を別々に行えます。この記事ではCHECK(同じ行の値に条件を課す制約)と外部キー(別テーブルの行を参照する制約)を例に、SQL・確認方法・ロックの注意点を解説します。
NOT VALIDは「無停止を保証する」機能ではありません。 本稿は2026年10月時点の現行安定版であるPostgreSQL 18の公式仕様に基づきます。
NOT VALIDで何が変わる?
通常の制約追加では、既存データがルールを守っているか検査されます。データ量が大きいと、その検査が長引くことがあります。
NOT VALID付きで追加すると、その時点で既存行を全件検査しません。ただし、追加後のINSERT・UPDATEには新しい制約が適用されます。つまり「過去の不整合は一時的に残せるが、新しい不整合は防ぐ」状態にできます。
| 処理 | 既存行の検査 | 追加後の新規・更新行 |
|---|---|---|
通常のADD CONSTRAINT |
追加時に実施 | 制約を適用 |
ADD CONSTRAINT ... NOT VALID |
追加時は省略 | 制約を適用 |
VALIDATE CONSTRAINT |
この段階で検査 | 引き続き制約を適用 |
例:過去に不整合がある注文テーブル
以下は学習用データベースで試すための例です。実サービスのデータには実行しないでください。
CREATE TABLE customers (
id bigint PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
total_cents integer NOT NULL
);
INSERT INTO customers (id, name)
VALUES (1, 'Alice');
INSERT INTO orders (id, customer_id, total_cents) VALUES
(101, 1, 1200),
(102, 999, -100);
注文102には二つの問題があります。顧客999は存在せず、金額はマイナスです。こうした既存の不整合がある状態で制約を導入します。
1. 検査を後回しにして制約を追加
ALTER TABLE orders
ADD CONSTRAINT orders_total_nonnegative
CHECK (total_cents >= 0) NOT VALID;
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id)
REFERENCES customers (id) NOT VALID;
制約名(orders_total_nonnegativeなど)を指定しておくと、後続の検証や障害調査で対象を間違えにくくなります。ここでは既存行102が不整合でも、登録時には全件走査しません。
一方、追加後の次のSQLはそれぞれエラーになります。
-- 存在しない顧客を参照するため外部キー違反
INSERT INTO orders VALUES (103, 999, 100);
-- 金額がマイナスなのでCHECK違反
INSERT INTO orders VALUES (104, 1, -1);
なお、CHECK (total_cents >= 0)だけではNULLは禁止できません。SQLのCHECKは式がFALSEの場合に違反と判定するためです。この例では列定義のNOT NULLでも保護しています。
2. 未検証であることを確認
PostgreSQL内部のpg_constraint(制約の管理情報を持つシステムカタログ)を見ると、検証状態が確認できます。
SELECT conname, contype, convalidated
FROM pg_constraint
WHERE conrelid = 'orders'::regclass
AND conname IN (
'orders_total_nonnegative',
'orders_customer_fk'
)
ORDER BY conname;
contypeはcがCHECK、fが外部キーです。convalidated = falseは「既存行を全件検証していない」を意味します。登録成功と既存データ検証完了を混同しないことが重要です。
3. 既存データの問題を調べる
-- CHECKに違反している既存注文
SELECT id, total_cents
FROM orders
WHERE total_cents < 0;
-- 参照先が存在しない既存注文
SELECT o.id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c ON c.id = o.customer_id
WHERE c.id IS NULL;
この状態でVALIDATE CONSTRAINTを実行すると、注文102の違反を検出して失敗します。これは期待した動作です。検証失敗後も登録した制約は残り、新規データの違反防止は続きます。
デモ用データなら次のように修正できます。
UPDATE orders
SET customer_id = 1, total_cents = 100
WHERE id = 102;
本番では金額・顧客IDを勝手に補正しないでください。 データの意味や監査上の要件を確認し、正しい値への修復、データ隔離、業務上の取消などを選びます。大量更新は処理を小分けにして、ロックとI/Oへの影響を監視します。
4. 既存データを検証して完了させる
ALTER TABLE orders
VALIDATE CONSTRAINT orders_total_nonnegative;
ALTER TABLE orders
VALIDATE CONSTRAINT orders_customer_fk;
両方のSQLが成功した後、pg_constraint.convalidatedがtrueになっていることを確認します。検証が失敗したら、対象行をさらに調査し、修正後に再実行します。
注意:NOT VALIDでもロックは発生する
ロックは、別々のSQLが同じテーブルへ同時に矛盾した操作をしないようにする仕組みです。PostgreSQL 18の公式仕様は、ロックの違いも明示しています。
| 操作 | ロックと注意点 |
|---|---|
ADD CHECK ... NOT VALID |
主にACCESS EXCLUSIVE。全件スキャンを省いても、取得中は他処理をブロックし得る |
ADD FOREIGN KEY ... NOT VALID |
参照元・参照先にSHARE ROW EXCLUSIVE。両テーブルの更新と競合し得る |
VALIDATE CONSTRAINT |
対象表にSHARE UPDATE EXCLUSIVE。通常の書き込みを一律に止めずに検査できる。外部キーでは参照先にROW SHAREも必要 |
追加するCHECKのロックが重い点は重要です。NOT VALIDにしただけでロックが消えるわけではありません。別の長いトランザクションがロックを保持していると、制約追加が待機し、その後ろのSQLにも影響が波及することがあります。
ロック待ち時間を制限するSQLの例です。
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders
ADD CONSTRAINT orders_total_nonnegative
CHECK (total_cents >= 0) NOT VALID;
COMMIT;
BEGIN:トランザクション(変更を一括で確定・取り消しする処理単位)を開始します。SET LOCAL lock_timeout = '3s':このトランザクション中のロック取得待ちを3秒までに制限します。SQL全体の実行時間の上限ではありません。COMMIT:正常時に確定してロックを解放します。エラーが出たらROLLBACKし、状況を確認します。
上の例は制約がまだ存在しないテーブルに対する独立した実行例です。先ほどのデモを同じデータベースで順番に実行している場合は、同じ制約名で再追加するとエラーになるため実行しないでください。
よくあるハマりどころ
外部キーの参照元にインデックスがない
外部キーを追加しても、参照元の列へインデックスが自動で作られるわけではありません。顧客行を削除・更新するときに参照元の注文を探す処理が重い場合は、アクセスパターンと負荷を確認してインデックスを検討します。
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON orders (customer_id);
CONCURRENTLYは書き込みへの影響を抑えながらインデックスを作る方法ですが、作業負荷がゼロになるわけではありません。また、明示的なトランザクションブロック内では実行できません。制約追加とは別のマイグレーションにします。
UNIQUEやPRIMARY KEYにも使える?
NOT VALIDはすべての制約に付けられるオプションではありません。PostgreSQL 18では外部キー・CHECK・NOT NULL制約が対象です。UNIQUEやPRIMARY KEYでは、適切な条件を満たす場合、CREATE UNIQUE INDEX CONCURRENTLYでインデックスを用意してからADD CONSTRAINT ... USING INDEXにする別手順を検討します。
「制約追加済み」と「検証完了」を混同する
NOT VALID追加直後は、将来の不整合を防ぎながらも、過去の不整合が残り得ます。convalidated = trueの確認までをマイグレーションの完了条件として管理すれば、途中で止まった状態にも気づけます。
どんな場面で使い分ける?
- 小さなテーブル・すでに全件が正しい:通常の制約追加の方が簡潔です。
- 大きな稼働中テーブル:登録と検証の時間帯を分ける
NOT VALID方式を検討します。 - 古い不整合がある:まず追加後の違反を防ぎ、既存データを業務的に正しく修復してから検証します。
- 高トラフィック環境:ロック待ち、更新競合、I/O負荷、失敗時の復旧をステージングで測定してから適用します。
結論は、新しい不整合を止めることと、既存データが正しいと検証することを分けることです。段階ごとの状態を明示すれば、移行作業を観測しやすくなり、安全性を高められます。