Duplicate Key Value Violates Unique Constraint, Permanently
This article may contain affiliate links. Its content is not affected by advertising.
In short
Publish a current version as a non-atomic demote-then-insert and a concurrent publish strands one update, after which every attempt computes the same version number and fails on duplicate key forever.
The short version
Implement publishing a new contract-template version as two separate writes — demote the current row’s is_current to false, then INSERT the new one with is_current = true — and two concurrent publishes leave one demotion stranded, after which every publish computes the same version number and fails permanently with duplicate key value violates unique constraint. The cause is expressing the state “the current version” through two non-atomic writes spanning multiple rows. The fix is to drop the mutable flag entirely and derive the current version each time from the maximum of the version column. Make publishing a single INSERT of one new row, and a numbering collision is simply rejected on the spot by the unique constraint — retry and you get the right number.
What it looks like — found in code review
A code review revisiting the contract-template publishing feature flagged this design. At the time it was flagged the table was still empty, with zero actual publishes. So no “can never publish again” incident actually occurred in production; the defect was caught as a design flaw before it could.
The finding was this. Publishing was built in these steps.
- Read the current version (the row with
is_current = true) - UPDATE that row to
is_current = false(demote) - INSERT the new version with
version = current + 1andis_current = true
Steps 2 and 3 are separate SQL statements, with no single transaction binding them. Run two publishes concurrently and it breaks like this.
- Both publishes read the same “current version” and compute the same “next version number”
- One publish’s INSERT succeeds first and consumes that number
- The other INSERT aims at the same version number, is rejected by the
unique(kind, version)constraint, and fails withduplicate key value violates unique constraint - If it stopped there it would only be “an error, the publish failed”. But if the demotion in step 2 had already gone through, there is now no row at all with
is_current = truefor that kind. Anything reading the body in that gap falls back to the baseline v1 - And the next publish request computes the same version number again — because the current version is missing, or because it computes from a broken one — so it repeats the same collision. Retrying by hand does not fix it unless the number shifts
Expressing one single state, “the current version”, through two independent writes — an UPDATE and an INSERT — is the entrance to this failure.
Why
The root cause is managing “which one is current right now” with a mutable flag (is_current). A flag-based design is consistent only when both of these succeed.
- The old version’s flag becomes false
- The new version is INSERTed with its flag true
Unless those two are wrapped in one transaction, a moment where only one has happened is inevitable. Here the computation of “the next version number” was itself anchored on that flag, so when the flag broke, the numbering broke with it. And once the numbering is broken, the same number is recomputed every time — so a retry cannot recover.
Fixing it
A migration dated 2026-07-24 dropped the is_current column itself. The current version is now derived each time as “the row with the maximum version for that kind”, and publishing became a single INSERT of one new row. With no UPDATE at all, there is no gap in which the state can be split.
If a version number collides, the unique(kind, version) constraint simply rejects that INSERT, so re-running the publish (re-reading the current version and recomputing) inserts at the right number. The path where “the same number is computed forever and you are stuck” is gone from the design.
A version >= 2 CHECK constraint was added at the same time. This version table holds v2 onward only, on the premise that v1 is the baseline bundled in the code and is authoritative — so slipping a v1 into this table would let the code’s baseline win and the row be silently ignored. The constraint prevents that separate accident structurally.
Preventing a repeat
What this migration actually changed is the design principle itself: do not rely on a mutable flag. The old design’s comments record exactly why demote-then-insert is dangerous. Whether anywhere else expresses “the current state” through two writes spanning multiple rows is now the first thing to suspect when designing the next table that carries versioning or a state transition.
Frequently asked questions
Q1When does duplicate key value violates unique constraint appear?
When PostgreSQL is asked to INSERT a value that already exists in a column with a unique constraint. Here the new version was inserted as "the current version's number + 1", so after a concurrent publish the same number gets computed every time, and it is already taken.
Q2What is dangerous about an is_current flag for "the current version"?
Expressing it as two separate writes — an UPDATE demoting the old row and an INSERT adding the new one — leaves a gap for something else to slip between them. A reader can observe a moment with no current version, or two publishes can compute the same next number.
Q3Did this actually stop a publish in production?
It did not. When code review flagged the design, the table was still empty with zero publishes. Had a concurrent publish happened after publishing began, that kind's versions would have become permanently unpublishable — but the design was fixed before it became an incident.
Q4After dropping is_current, how is the current version decided?
It is derived each time as the row holding the maximum value of the version column for that kind. With no mutable flag there is no flag to forget to flip, and publishing completes as a single INSERT of one new row.
Environment verified
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 (Supabase Postgres, via PostgREST)
- Found in code review on 2026-07-24 and fixed by a migration the same day (with zero publishes on the table)
What this article is based on
- TypeScript file lines 157-196commit 0a88871
- SQL filecommit 0a88871
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.