PostgreSQLのINSERT ... ON CONFLICTで、対象に合う一意制約などがないというエラーが出たときは、SQLが指定した衝突対象と、実際の表の制約・indexを照合します。AIが作ったupsert文を繰り返し送る前に、何を同じレコードとみなす設計だったかへ戻ってください。
この記事はPostgreSQL 18の公式資料を基にした切り分けです。DBで実行した復旧事例ではありません。表・値・SQLは説明用の例で、実行する前に対象DBの版、スキーマ、既存データ、権限を確認する必要があります。
目次
conflict_targetと実際の一意性を対応付ける
INSERTの公式説明(新しいタブで開く)では、ON CONFLICTは指定に合う一意indexを推論するか、制約を明示して扱います。推論に合うものがなければエラーになります。列名が表に存在することと、その列の組合せが一意と保証されていることは別です。
たとえば、表に主キーidと列external_idがあっても、主キーがあるという理由だけでON CONFLICT (external_id)を使えるとは判断しません。また、通常の検索用indexがあるだけでは、一意性を保証するindexの代わりになりません。
表は横にスクロールできます
| 確認するもの | 照合内容 |
|---|---|
| 接続先 | DB、スキーマ、実際に使う表 |
| SQL | 衝突対象の列・式、条件、DO UPDATEかDO NOTHINGか |
| 定義 | 主キー、一意制約、一意indexとその条件 |
| 業務の同一性 | 全体で一意か、取引先・tenantごとか |
| 既存データ | 重複、NULL、移行漏れの有無 |
接続先やスキーマが違えば、手元で正しい定義を見つけても失敗箇所の説明にはなりません。まず失敗した接続の対象を固定し、実際の定義を読みます。
複合キーを一列へ縮めていないか
次は、取引先ごとに外部番号を管理する架空の例です。同じ外部番号が別の取引先でも使われる設計とします。
CREATE TABLE example_orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id text NOT NULL,
external_id text NOT NULL,
amount integer NOT NULL,
UNIQUE (tenant_id, external_id)
);
この定義で同一とする組合せはtenant_idとexternal_idです。外部番号だけのupsertへ変更すると、DBが保証する範囲とSQLの指定がずれます。説明用の対応するSQLは次の形です。実行済みの成功例ではありません。
INSERT INTO example_orders (tenant_id, external_id, amount)
VALUES ('TENANT-A', 'ORDER-7', 2400)
ON CONFLICT (tenant_id, external_id)
DO UPDATE SET amount = EXCLUDED.amount;
同じ外部番号を別tenantが持つことを許すなら、エラーを消す目的でexternal_id単独の一意制約を足すのは設計変更になります。実際には何を一意にすべきかを先に確認してください。
Constraintsの公式説明(新しいタブで開く)には、複数列の一意性、主キーとNULLの扱いが記載されています。「同じ番号を更新する」という自然文だけでは、会社や種別などの条件が省略されがちです。AIへSQLを依頼するときも、同じ番号が共存してよい範囲を具体例で示す必要があります。
制約名・indexの条件・式を混同しない
単純な列の組合せで説明できない場合は、実際の定義まで確認します。部分一意indexなら対象となる条件、式indexなら元の式が照合対象です。制約名とindex名を同じものと決め、名前だけをSQLへ貼り付けないでください。
PostgreSQL 18の公式説明では、DO UPDATEには衝突対象が必要で、対象として使える制約にも条件があります。延期可能な制約や除外制約などの扱いを、一意制約一般の説明から推測しないようにします。
また、DO NOTHINGで対象指定を外し、エラーが消えたとしても、意図した既存行を更新する処理を実現したことにはなりません。更新すべき金額や状態を保存せず、新規行を追加しないだけになっていないかを確認します。SQLが通ることと、要件どおりに反映されることを分けて判断してください。
制約を追加する前に重複の意味を決める
既存の表に必要な一意性がない場合でも、すぐ制約を追加するのではなく、既存データに同じ組合せがあるかを調べます。次は説明用の読み取り例です。実行時には表名、対象範囲、件数、負荷を確認します。
SELECT tenant_id, external_id, COUNT(*) AS rows_per_key
FROM app.orders
GROUP BY tenant_id, external_id
HAVING COUNT(*) > 1;
重複があれば、なぜ複数行があるかを担当者が確認します。履歴を残すための複数行なら、そのまま削除して一意にすることはできません。誤登録なら、残す行、関連する参照、集計への影響を決めます。AIに「最新の一行だけ残して」と処理させる前に、その判断が業務に合うかを確かめてください。
NULLも別に確認します。PostgreSQLの一意性でNULLをどう扱うかは定義に依存するため、NULLを許す列を、必ず一件へまとまる業務キーだとみなしません。空文字への置換などで無理に合わせず、欠落値の意味と採用する制約を決めます。
再送の前に入力内の重複も見る
一意性がDB側でそろっていても、一回の複数行入力に同じキーが重なっている場合は別のエラーになることがあります。公式INSERT説明は、DO UPDATEで同じ既存行へ一つの文から複数回作用することを認めないと説明しています。
元の問題が衝突対象の不一致なら、まずその原因を直します。その後に入力重複のエラーが出た場合は、同じ失敗の継続と決めず、入力件数とキー別件数を確認します。二件の更新値が違うなら、どちらを採用するかをAIに推測させないでください。
通信が切れた場合は、反映済みか不明な状態も残ります。upsertという名前だけで、関連する通知・履歴・外部処理まで何度送っても安全だとは判断しません。保存結果と周辺処理を確認してから再開します。
修正後は更新対象と保護範囲を確認する
検証用データでは、同じtenant・同じ外部番号、別tenant・同じ外部番号、新しい外部番号、欠落値のケースを用意します。上の架空設計なら、同じ組合せは既存行の更新、別tenantは別行として扱う期待を先に記録できます。これは設計上の期待で、ここでDB実行した結果ではありません。
修正後に見るのは、行数だけでなく、更新されたキー、更新前後の値、他tenantの行が変わらないことです。制約を変更する場合は、migration、稼働中の処理への影響、戻せる範囲を別途確認します。
AIへ渡す資料は、秘密情報を除いたエラー全文、対象SQL、実際の制約・index定義、同じと扱う条件、期待する入力例です。既存制約を読まず新しい一意制約を足す修正や、エラーを避けるためだけのDO NOTHINGへの変更は、その理由と業務上の影響を説明できてから採用してください。
確認した公式情報
確認日:2026-10-20。参照範囲はPostgreSQL 18です。掲載SQLのDB実行・migration・本番更新は未実施です。
