何が起きているか
行を挿入または更新しようとすると、PostgreSQLが以下のエラーをスローします:
ERROR: duplicate key value violates unique constraint "users_pkey"
DETAIL: Key (id)=(42) already exists.
まずDETAIL行を確認してください — どの列(id)のどの値(42)が競合を引き起こしたかを正確に教えてくれます。これだけでデバッグの90%は完了です。残りの疑問は、なぜその値がすでに存在しているかということです。
よくある原因
SERIALまたはBIGSERIALのシーケンスが実際のテーブルデータと同期していない — 一括インポートやpg_restore後によく発生します- テーブルにすでに存在するIDを手動で指定している
- ユニーク列(email、username、slug)に重複した値が渡された
- 2つの同時リクエストが同じ行を挿入しようとして競合した
原因1:シーケンスのズレ(最も多い)
こんな状況を想像してください:50,000人のユーザーが入ったデータベースにpg_restoreを実行したとします。それらの行は明示的なIDで挿入されたため、PostgreSQLのシーケンスはインクリメントされていません。次のIDは1だと思っているのに、テーブルにはすでに50000までの行が存在しています。新しい挿入は毎回即座に失敗します。
シーケンスの値と実際の最大IDを確認する
-- シーケンスが次に生成する値は?
SELECT last_value FROM users_id_seq;
-- テーブルに現在存在する最大のIDは?
SELECT MAX(id) FROM users;
last_valueがMAX(id)より小さければ、それが問題の原因です。
修正:シーケンスをリセットする
-- max(id)にリセットして、次の挿入がmax(id) + 1を取得するようにする
SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));
シーケンス名をハードコードしたくない場合は、pg_get_serial_sequenceを使います:
SELECT setval(
pg_get_serial_sequence('users', 'id'),
(SELECT MAX(id) FROM users)
);
次にidを省略してINSERTすると、MAX(id) + 1が付与されます — クリーンで安全です。
ズレているシーケンスをすべて一括修正する
データベース全体をリストアした後は、複数のテーブルが同時に影響を受けている可能性があります。このスクリプトを一度実行するだけで、すべての主キーシーケンスを一括でリセットできます:
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
tc.table_name,
kc.column_name,
pg_get_serial_sequence(tc.table_name, kc.column_name) AS seq
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kc
ON tc.constraint_name = kc.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND pg_get_serial_sequence(tc.table_name, kc.column_name) IS NOT NULL
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE(MAX(%I), 1)) FROM %I',
r.seq, r.column_name, r.table_name
);
END LOOP;
END;
$$;
原因2:ユニーク列の重複値
制約は主キーだけに限りません。こちらはemail列に対するエラーです:
ERROR: duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(user@example.com) already exists.
競合している行を見つける
SELECT * FROM users WHERE email = 'user@example.com';
オプションA:重複をサイレントにスキップする
INSERT INTO users (email, name)
VALUES ('user@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING;
オプションB:アップサート — 既存の行を更新する
INSERT INTO users (email, name, updated_at)
VALUES ('user@example.com', 'Alice Updated', NOW())
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at;
EXCLUDEDは、挿入に失敗した行を保持する仮想テーブルです。書き込もうとした新しい値を参照するために使用します。
原因3:一括挿入時の明示的なIDの競合
別のシステムからデータを移行する場合、元のデータが独自のIDを持っており、それらのIDがターゲットテーブルにすでに存在していると、競合するすべての行が失敗します。
挿入前に競合を確認する
-- すでに存在する受け入れ側のIDを確認する
SELECT s.id
FROM staging_users s
INNER JOIN users u ON s.id = u.id;
競合しない行のみ挿入する
INSERT INTO users (id, email, name)
SELECT id, email, name FROM staging_users
ON CONFLICT (id) DO NOTHING;
原因4:同時挿入による競合状態
2つのAPIリクエストが数ミリ秒以内に到着します。両方がデータベースを確認し、既存の行がないことを確認してから、どちらも挿入を実行します。一方が成功し、もう一方が制約違反になります。
アプリケーションレベルのチェックではこれを防げません — チェックと挿入の間のわずかな時間に2つ目のリクエストが割り込める余地があります。データベースレベルで対処してください:
-- Python (psycopg2) — エラーをキャッチして「すでに存在する」として扱う
try:
cursor.execute(
"INSERT INTO users (email) VALUES (%s)",
(email,)
)
conn.commit()
except psycopg2.errors.UniqueViolation:
conn.rollback()
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
さらに良い方法は、競合処理をSQL自体に押し込んで、挿入を最初から冪等にすることです:
INSERT INTO users (email)
VALUES ('user@example.com')
ON CONFLICT (email) DO NOTHING
RETURNING id;
修正が機能したか確認する
-- シーケンスが現在の最大値より先に進んでいることを確認する
SELECT
last_value AS sequence_next,
(SELECT MAX(id) FROM users) AS table_max
FROM users_id_seq;
-- 通常の挿入をテストする — IDを指定しなくても成功するはず
INSERT INTO users (email, name) VALUES ('test@example.com', 'Test');
SELECT * FROM users WHERE email = 'test@example.com';
覚えておくべきこと
pg_restoreや明示的なIDを持つ一括インポートの後は — アプリケーションを稼働させる前にシーケンスをリセットしてください。リストアの手順書に追加して、忘れないようにしましょう。- **
ON CONFLICTはアプリケーションレベルの重複チェックより常に優れています。**データベース内でアトミックに実行されるため、アプリのコードでは保証できません。 - **
DETAIL行が最速のデバッグツールです。**制約名と競合した値が記載されています — スタックトレースより先にここから確認しましょう。 SERIALの代わりにGENERATED ALWAYS AS IDENTITYへの切り替えを検討してください(PostgreSQL 10以降)。PostgreSQLはIDENTITY列をより厳密に管理するため、シーケンスのズレが意図せず発生しにくくなります。

