Errore PostgreSQL: duplicate key value violates unique constraint
ERROR: duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=([email protected]) already exists.
Un INSERT o UPDATE ha provato a creare una seconda riga con lo stesso valore in una colonna (o insieme di colonne) coperta da un vincolo di unicità o da un indice unique. La riga DETAIL ti dice esattamente quale vincolo e quale valore — la diagnosi parte da lì.
Cosa significa questo errore
Il vincolo ha funzionato: PostgreSQL garantisce l'unicità anche in concorrenza, e questo errore è quella garanzia che scatta. Due cose che vale la pena sapere oltre l'ovvio:
- Il nome tra virgolette è il nome del vincolo o dell'indice (
users_email_key,users_pkey, …).\d usersin psql mostra quali colonne copre — inclusi gli indici su espressione comelower(email), dove il duplicato può non essere letteralmente identico. - Come ogni errore, interrompe la transazione corrente: ogni statement successivo nella stessa transazione fallirà con "current transaction is aborted" finché non fai il rollback.
Cause comuni
- Race check-then-insert: due richieste eseguono entrambe
SELECT, entrambe non vedono righe, entrambe fannoINSERT. In concorrenza una delle due deve ricevere 23505 — il controllo non può risolverlo, solo l'insert stesso può (vedi la soluzione con upsert qui sotto). - Una sequence non allineata con la tabella — il classico dopo un import o un restore di dati che ha inserito ID espliciti senza far avanzare la sequence. I nuovi insert pescano ID già usati e falliscono sulla primary key, una volta per ogni ID obsoleto.
- Retry del client che rigiocano un INSERT che in realtà è riuscito la prima volta (timeout dopo il commit, code at-least-once).
- Bulk load che contengono duplicati genuini.
Come diagnosticarlo
-- Quale vincolo, quali colonne:
\d users
-- Sospetto "sequenza rimasta indietro" (errore sulla _pkey con id seriali):
SELECT max(id) FROM users;
SELECT last_value FROM users_id_seq;
-- se last_value <= max(id), è questa la causa
Se il vincolo è su una chiave di business (email, SKU, …) anziché sulla primary key, guarda da dove viene il valore in DETAIL: stesso utente che invia due volte, logica di retry, o due percorsi di codice che creano la stessa entità.
Come risolverlo
- Race sugli insert → upsert. Lascia decidere all'insert stesso, in modo atomico:
INSERT INTO users (email, name) VALUES ('[email protected]', 'Alice') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name; -- oppure DO NOTHING se il duplicato va semplicemente ignorato - Sequence obsoleta → risincronizzala:
SELECT setval(pg_get_serial_sequence('users', 'id'), (SELECT max(id) FROM users)); - Retry → idempotenza: dai alle operazioni una chiave unica generata dal client e usa
ON CONFLICT DO NOTHINGsu di essa, così i replay diventano no-op. - Bulk load: carica in una tabella di staging, deduplica (
SELECT DISTINCT ON (key) ...), poi inserisci. - Evita il catch-and-ignore dentro una transazione più grande: lo statement fallito l'ha già interrotta. Preferisci
ON CONFLICT; se devi davvero tentare-e-recuperare, avvolgi lo statement in unSAVEPOINT.
🔍
Lettura correlata: Scegliere l'indice giusto — indici unique, parziali e su espressione, e quanto costano in scrittura.
