Article

🐘 PostgreSQLのNOT VALIDで制約追加を段階化する:CHECKと外部キーの実務手順

postgresql, database, sql, migration

稼働中のデータベースに「注文金額は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負荷、失敗時の復旧をステージングで測定してから適用します。

結論は、新しい不整合を止めることと、既存データが正しいと検証することを分けることです。段階ごとの状態を明示すれば、移行作業を観測しやすくなり、安全性を高められます。

参考資料(公式)