PostgreSQL『duplicate null key value violates unique constraint』の原因と対処

  • 作成日 2025.10.02
  • 更新日 2025.10.06
  • その他
PostgreSQL『duplicate null key value violates unique constraint』の原因と対処

NULLの扱いを「等しい」と見なすユニーク定義(NULLS NOT DISTINCT/部分ユニーク/式インデックス等)の下で、NULL(またはNULL相当の式値)が重複したときに発生する。デフォルトのUNIQUEはNULLを「互いに異なる」と扱うため複数NULLは許容だが、設定や定義次第で“NULLは1件まで”となる。発生条件の切り分け、最短復旧、設計の選び方、移行の手順をまとめる。

エラーメッセージの読み方(代表例)

ERROR:  duplicate key value violates unique constraint "uq_users_email"
DETAIL:  Key (email)=(NULL) already exists.

-- あるいは、式/部分ユニーク由来
ERROR:  duplicate key value violates unique constraint "uq_users_email_norm"
DETAIL:  Key (lower(btrim(email)))=(null) already exists.

・PostgreSQLの既定UNIQUEは複数NULLを許すため、上記が出るのは「NULLを同一視する定義」や「式インデックスでNULLに潰れている」ケースが中心

発生条件の早見表

□ UNIQUE ... NULLS NOT DISTINCT(PostgreSQL 15+)でNULLを同一視
□ 部分ユニーク:WHERE email IS NULL などで「NULLは1行だけ」を保証
□ 式ユニーク:coalesce(email,'')、lower(btrim(email)) 等でNULL→同一値に正規化
□ 複合ユニーク+NULLS NOT DISTINCT(複数列の全NULLが衝突)
□ INSERT ... ON CONFLICT / UPSERT が同一「NULL相当」キーを作ろうとしている

まず原因特定:どのユニーク定義が衝突を起こしているか

-- 失敗した制約/インデックス名をメッセージから控える(例:uq_users_email)

-- 1) インデックス/制約の定義を取得
SELECT i.relname AS index_name,
       pg_get_indexdef(i.oid) AS indexdef,
       ix.indisunique,
       ix.indnullsnotdistinct         -- trueなら「NULLを同一視」
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
WHERE i.relname = 'uq_users_email';

-- 2) 部分ユニークか(WHERE句が付いていないか)
SELECT pg_get_indexdef(indexrelid)
FROM pg_index
WHERE indexrelid = 'uq_users_email'::regclass;

-- 3) ビューで列定義と重複データを確認(例:emailがNULLの件数)
SELECT COUNT(*) FILTER (WHERE email IS NULL) AS null_rows,
       COUNT(*) FILTER (WHERE email IS NOT NULL) AS non_null_rows
FROM users;

-- 4) 式インデックスなら、式を再現して重複を探す
-- 例:lower(btrim(email)) がキーなら
SELECT lower(btrim(email)) AS k, COUNT(*)
FROM users
GROUP BY 1 HAVING COUNT(*) > 1;

再現パターン1:UNIQUE NULLS NOT DISTINCT(15+)

-- NULLを同一視するユニーク制約
CREATE TABLE users(id bigserial PRIMARY KEY, email text);
ALTER TABLE users
  ADD CONSTRAINT uq_users_email UNIQUE NULLS NOT DISTINCT (email);

INSERT INTO users(email) VALUES (NULL);  -- OK
INSERT INTO users(email) VALUES (NULL);  -- NG:duplicate ... (email)=(NULL)

再現パターン2:部分ユニークで「NULLは1件だけ」

CREATE UNIQUE INDEX uq_users_email_null_one
  ON users ((1)) WHERE email IS NULL;  -- email IS NULL 行は常にキー1で衝突
INSERT INTO users(email) VALUES (NULL);  -- OK
INSERT INTO users(email) VALUES (NULL);  -- NG:duplicate ... uq_users_email_null_one

再現パターン3:式ユニークでNULL/空白/大文字小文字が同一化

-- 空文字とNULLを同一視(coalesce)+前後空白を除去+小文字化
CREATE UNIQUE INDEX uq_users_email_norm
  ON users (lower(btrim(coalesce(email, ''))));

INSERT INTO users(email) VALUES (NULL);      -- OK
INSERT INTO users(email) VALUES (''   );      -- NG(NULLと同一視)
INSERT INTO users(email) VALUES ('  ' );      -- NG(btrimで空→同一)

方針選択:どれが望む仕様かを先に決める

( A ) 複数NULLを許可したい(既定のUNIQUEに戻す)
( B ) 「NULLは1件まで」を維持したい(正しいユニーク定義に集約)
( C ) NULLと空文字を厳密に区別/正規化したい(式を修正、CHECKで入口統制)
( D ) 同一視ルールを広げたい/狭めたい(lower/btrim/coalesce の再設計)

解決A:複数NULLを許可(NULLS DISTINCTへ切替)

-- 1) 競合回避のため、新定義のユニークを作成(オンライン切替)
CREATE UNIQUE INDEX CONCURRENTLY uq_users_email_distinct
  ON users (email) NULLS DISTINCT;   -- 15+ は明示可(既定はNULLS DISTINCT)

-- 2) 旧インデックス/制約をドロップして差し替え
DROP INDEX CONCURRENTLY uq_users_email;           -- 旧名(存在する方)
ALTER INDEX uq_users_email_distinct RENAME TO uq_users_email;

-- ※ 既存データに「式ユニーク」など別由来の衝突がある場合は、先に式をやめる/クリーニングする

・PostgreSQL 14以前なら「NULLS DISTINCT」は明示できないが既定動作は同じ(複数NULL許容)。単に「NULLS NOT DISTINCT を使わない」定義に戻す

解決B:「NULLは1件まで」を明確に維持(2通り)

-- 1) 15+:UNIQUE NULLS NOT DISTINCT を使う(推奨・明快)
ALTER TABLE users
  ADD CONSTRAINT uq_users_email UNIQUE NULLS NOT DISTINCT (email);

-- 2) 旧来:部分ユニークで厳密化(NULLのときだけ衝突)
DROP INDEX IF EXISTS uq_users_email_null_one;
CREATE UNIQUE INDEX uq_users_email_null_one
  ON users ((1)) WHERE email IS NULL;

・(2)は「NULL=1件」の意味が明瞭で、EXPLAIN上も軽い。既存の式ユニークでNULLを空文字に潰しているなら、こちらへ置き換える

解決C:NULLと空文字の混同をやめる(式ユニークを修正)

-- 悪い例(NULLを''に潰すと衝突を誘発)
-- CREATE UNIQUE INDEX uq_users_email_norm ON users (lower(btrim(coalesce(email,''))));

-- 改善:NULLはNULLのまま、空白除去と小文字化だけを同一視
CREATE UNIQUE INDEX CONCURRENTLY uq_users_email_norm2
  ON users (lower(btrim(email)));

-- 入口で空文字をNULLにしない/するかはポリシーで統一(CHECKやトリガで正規化)
ALTER TABLE users
  ADD CONSTRAINT email_not_empty CHECK (email IS NULL OR btrim(email) <> '');

解決D:UPSERTでの衝突処理(ON CONFLICT)の整理

-- NULL同一視のユニークを前提に、衝突時は既存行を更新する
INSERT INTO users(email, name)
VALUES (NULL, 'first')
ON CONFLICT ON CONSTRAINT uq_users_email  -- NULLS NOT DISTINCT の制約
DO UPDATE SET name = EXCLUDED.name;

-- 部分ユニークと併用するなら「衝突対象」を正しく指す
INSERT INTO users(email, name)
VALUES (NULL, 'first')
ON CONFLICT ON CONSTRAINT uq_users_email_null_one
DO UPDATE SET name = EXCLUDED.name;

・どのユニークが衝突源かを明示するのが安全(複数ユニークがあると誤解釈を招く)

衝突する既存データの洗い出しと修復

-- 1) 「NULLを同一視」した場合に衝突するグループを特定
SELECT 'email_is_null' AS key, COUNT(*) AS cnt
FROM users
WHERE email IS NULL
GROUP BY 1 HAVING COUNT(*) > 1;

-- 2) 式ユニーク(lower(btrim(email))) の重複候補
SELECT lower(btrim(email)) AS k, COUNT(*)
FROM users
GROUP BY 1 HAVING COUNT(*) > 1;

-- 3) 修復例:NULL重複のうち“古い方”をアーカイブへ退避
WITH d AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn
  FROM users WHERE email IS NULL
)
DELETE FROM users
WHERE id IN (SELECT id FROM d WHERE rn > 1);

マイグレーションの安全な切替手順(無停止志向)

-- 例:式ユニーク(coalesce) → 部分ユニーク(NULL1件)へ置換

-- 0) 重複現存の有無を確認し、必要ならアーカイブ/正規化
-- 1) 新インデックスを並行作成
CREATE UNIQUE INDEX CONCURRENTLY uq_users_email_null_one
  ON users ((1)) WHERE email IS NULL;

-- 2) アプリを短時間停止 ≒ 変更ウィンドウ(衝突するとDDLが止まるため)
-- 3) 旧インデックスを削除
DROP INDEX CONCURRENTLY uq_users_email_norm;
-- 4) 必要なら名称を統一
--    (または制約として付け直す)

複合ユニークでのNULL取り扱い(注意点)

-- デフォルト:どれか1列がNULLなら「異なる」と見なされ、複数可
ALTER TABLE sessions ADD CONSTRAINT uq_user_device UNIQUE (user_id, device_id);

-- 15+で「両方NULLも衝突」にしたい場合
ALTER TABLE sessions
  ADD CONSTRAINT uq_user_device_nd UNIQUE NULLS NOT DISTINCT (user_id, device_id);

・NULLS NOT DISTINCT は“IS NOT DISTINCT FROM”と同等の比較。要件に合うかを確認

運用チェックリスト

□ どのユニーク定義がエラー源か(制約/インデックス名・pg_get_indexdef)
□ NULLS NOT DISTINCT / 部分ユニーク / 式ユニークのどれか
□ 仕様:NULLは何件許容? 空文字や空白は同一視?(チームで合意)
□ 必要なら部分ユニーク or NULLS NOT DISTINCT へ集約し、式のcoalesceは避ける
□ ON CONFLICT で衝突先制約を明示
□ 変更は CREATE INDEX CONCURRENTLY → DROP CONCURRENTLY の順でオンライン化

まとめ:NULLの仕様を“定義”に落とし込む

-- 準備
DROP TABLE IF EXISTS users;
CREATE TABLE users(id bigserial PRIMARY KEY, email text, name text);

-- 1) NULLを同一視するユニーク(エラー再現)
ALTER TABLE users ADD CONSTRAINT uq_email UNIQUE NULLS NOT DISTINCT (email);
INSERT INTO users(email) VALUES (NULL);  -- OK
INSERT INTO users(email) VALUES (NULL);  -- NG

-- 解消(複数NULLを許可したい)
ALTER TABLE users DROP CONSTRAINT uq_email;
ALTER TABLE users ADD CONSTRAINT uq_email UNIQUE (email);  -- 既定(NULLは区別)

-- 2) 部分ユニークでNULL1件(再現)
CREATE UNIQUE INDEX uq_email_null_one ON users((1)) WHERE email IS NULL;
INSERT INTO users(email) VALUES (NULL);  -- OK
INSERT INTO users(email) VALUES (NULL);  -- NG

-- 3) 式ユニークの落とし穴(NULL=空文字=空白)
CREATE UNIQUE INDEX uq_email_norm ON users(lower(btrim(coalesce(email,''))));
INSERT INTO users(email) VALUES (NULL);   -- OK
INSERT INTO users(email) VALUES ('');     -- NG(NULL相当で衝突)
-- → 改善:coalesceを外す
DROP INDEX uq_email_norm;
CREATE UNIQUE INDEX uq_email_norm2 ON users(lower(btrim(email)));