PostgreSQL Error 23505: Unique violation
An INSERT or UPDATE produced a value that already exists in a column covered by a UNIQUE constraint or index. Use INSERT ... ON CONFLICT, or return 409 to the client.
Identify: does your log match?
ERROR: duplicate key value violates unique constraint "users_email_key" DETAIL: Key (email)=(ada@example.com) already exists.
- High
Check-then-insert race condition
Two requests both SELECT, see no row, then both INSERT. The second one hits the unique index.
- Medium
Sequence out of sync after import
Rows were bulk-loaded with explicit ids, so the serial sequence still returns ids that already exist. Every new INSERT collides on the primary key.
- Medium
Retried request without idempotency
A client or queue retried a successful INSERT after a timeout. The original write already committed.
Fix
Use an atomic upsert instead of check-then-insert
SELECT id FROM users WHERE email = $1; -- app code: if no row, then... INSERT INTO users (email, name) VALUES ($1, $2);
INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name RETURNING id;
ON CONFLICT resolves the race inside the database in a single statement. Use DO NOTHING if you only want to skip duplicates.
Verify: reproduce it on purpose
$ psql -c "CREATE TEMP TABLE u(email text UNIQUE); INSERT INTO u VALUES ('a@x.io'),('a@x.io');"Run it against a dev environment to trigger 23505 on purpose and test your error handler.
Prevent
- Enforce uniqueness in the database, never only in app code.
- Map 23505 to HTTP 409 with the constraint name from err.constraint.
- Send an Idempotency-Key on retried POSTs.
FAQ
How do I get the constraint name from 23505 in node-postgres?
The pg error object has err.code === '23505' and err.constraint (e.g. users_email_key). err.detail contains the conflicting key.
Does ON CONFLICT DO NOTHING still consume a sequence value?
Yes. nextval() is evaluated before the conflict check, so gaps appear in serial ids. That is expected and harmless.
Is 23505 safe to retry?
Not as-is: the same values will collide again. Retry only after changing the input or switching to an upsert.
Related errors
Last verified 2026-10-18 · Source: Official Appendix A error codes (errcodes.txt in the postgres repo) · Official docs ↗ · ~12,100 searches/mo