PostgreSQLで「主キーが重複エラー(23505)」が出たときの確認と対処(Supabase)

Supabaseでデータベース登録時に発生したエラー

Supabase(PostgREST)経由でテーブルにデータを登録しようとしたところ、エラーが発生して登録ができなかった。

DevToolsで確認(network→エラー選択→response)したところ、次のエラーが発生していた。

{
  "code": "23505",
  "details": null,
  "hint": null,
  "message": "duplicate key value violates unique constraint \"onidazo_pkey\""
}

※仮に「onidazo」というテーブル名とする。

エラーコード 23505 とは?

23505 はPostgreSQLの unique_violation(一意性制約違反) を示すエラーコード。

今回のケース:主キーの重複

エラーメッセージの "duplicate key value violates unique constraint \"onidazo_pkey\"" から、以下のことが分かる。

  • onidazo_pkey という制約に違反している(_pkey は主キー制約の接尾辞)
  • つまり、既に存在する主キー値で新規追加(INSERT)しようとしているため、失敗している

どの列がユニーク制約(重複禁止)の対象かを確認する

制約定義を確認

エラーには ... violates unique constraint "onidazo_pkey" とあり、どの列が重複しているのかは制約定義を見ないと分からない。

そこで、該当の制約がどの列に設定されているかを確認した。

select
  conname,
  pg_get_constraintdef(oid) as def
from pg_constraint
where conname = 'onidazo_pkey';

_pkeyPostgreSQLで主キー制約名を省略した場合に自動で付く接尾辞(例:onidazo_pkey

結果が以下のように返ってきた。

conname def
onidazo_pkey PRIMARY KEY (onidazo_id)

つまり、重複しているのはonidazo_id(主キー列)である。

その主キー値で既存行があるか確認

select *
from public.onidazo
where onidazo_id = '<いま登録しようとしている値>';

結果:

Success. No rows returned

この結果から、現時点では該当する主キー値のデータは存在しないことが確認できた。しかし、それでも23505エラーが発生している。

payloadに主キーが無いのに、なぜ重複するのか?

payload(リクエストボディ)に主キーを含めずに INSERT した場合でも、23505エラーが発生することがある。

例えば、今回実際に送っていたpayloadは以下のようなもので、主キー(id)が含まれていなかった。

[
  {
    "code": "999998",
    "name": "テスト",
    "updated_by": 0001,
    "updated_machine": "machine1"
  }
]

この場合、典型的には以下のどちらかである。

  • 主キーが DB側で自動採番されている(serial / identity)
  • INSERT 前トリガーなどで主キーを埋めている

主キーが自動採番されているか確認するために、以下のクエリを実行した。

select
  column_name,       -- 列名
  is_identity,       -- IDENTITY列かどうか
  identity_generation, -- IDENTITY生成方式
  column_default     -- デフォルト値(シーケンス採番の設定)
from information_schema.columns
where table_schema = 'public'
  and table_name = 'onidazo'
  and column_name = 'onidazo_id';

結果:

column_name is_identity identity_generation column_default
onidazo_id NO null nextval('onidazo_id_seq'::regclass')

column_defaultnextval('onidazo_id_seq'::regclass) とあることから、主キーは onidazo_id_seqnextval() によって採番されることが分かる。

is_identity = NO だが自動採番されている?

  • is_identityNO だが、column_defaultnextval() があるため、実際には自動採番されている
  • これは 別のDBからの移行時によく見られるパターン(従来の SERIAL 型を使用している)
  • 新規にテーブルを作成する場合は、SQL標準の IDENTITY 列を使う方がベストプラクティス

補足:nextval() とは?

  • nextval() はPostgreSQLのシーケンス(連番生成器)から次の値を取得する関数
  • onidazo_id_seq はシーケンスの名前
  • ::regclass はシーケンス名を内部IDに変換するキャスト(型変換)
  • つまり、この設定により INSERT 時に主キーが自動で連番採番される

補足:SERIAL型とIDENTITY列の違い

PostgreSQLで自動採番を実現する方法には主に2つある。

SERIAL型(従来の方式)

CREATE TABLE onidazo (
  onidazo_id SERIAL PRIMARY KEY,
  -- 他の列...
);
  • PostgreSQL独自の疑似型
  • 内部的には INTEGER + nextval('シーケンス名') の組み合わせ
  • 別のDBから移行した場合、この形式になっていることが多い
  • is_identity = NO と表示される

IDENTITY列(SQL標準、推奨)

CREATE TABLE onidazo (
  onidazo_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  -- または GENERATED BY DEFAULT AS IDENTITY
  -- 他の列...
);
  • SQL標準に準拠(PostgreSQL 10以降で利用可能)
  • is_identity = YES と表示される
  • GENERATED ALWAYS:値の手動指定を許可しない(より厳密)
  • GENERATED BY DEFAULT:通常は自動採番、必要に応じて手動指定も可能
  • 新規テーブル作成時はこちらを推奨

どちらの方式でも自動採番は機能するが、今回のようなシーケンスのズレはどちらでも発生する可能性がある。

原因:シーケンスがテーブルの最大IDより古い(ズレ)

次に、テーブルの最大 onidazo_id と、シーケンスの現在値を確認した。

テーブルの最大 onidazo_id

select max(onidazo_id) as max_id from public.onidazo;

シーケンスの現在値:

select last_value, is_called
from public.onidazo_id_seq;

今回は以下のように、番号がバラバラの結果だった。

  • max_id = 1005
  • last_value = 985(is_called = true)

この状態だと、次の INSERT でシーケンスが 986, 987, ... と進み、既存行の onidazo_id = 1005 に到達したタイミングで 主キー重複(23505エラー) が発生する。

補足:なぜこのようなズレが発生するのか?

シーケンスとテーブルの最大IDがズレる典型的な原因:

  • データの手動投入(IDを直接指定してINSERT)
  • バックアップからの復元
  • シーケンスの手動リセット
  • テーブルのTRUNCATE後にシーケンスをリセットし忘れた場合

解決方法:setvalでシーケンスを最大IDに合わせる

シーケンスをテーブルの最大IDに合わせることで解決できる。

select setval(
  'public.onidazo_id_seq'::regclass,
  (select max(onidazo_id) from public.onidazo),
  true
);

※ 第3引数の true は「この値を使用済みとする」という意味。これにより、次の nextval() は 最大ID + 1(例:1006)から始まり、重複が発生しなくなる。

まとめ

  • 23505 duplicate keyRLS(権限)ではなく DB 制約(主キー重複) で落ちている
  • payloadに主キーが無い場合でも、DB側の シーケンス採番が原因 で重複することがある
  • max(onidazo_id)onidazo_id_seqlast_value を比較するとズレが分かる
  • setval でシーケンスを最大IDに合わせれば解決できる