A table has a unique constraint on (list_id, position). A user reorders two items, so the application issues one UPDATE that exchanges their position values. Why can that statement fail, and what are the ways to make the reorder work?
answer
- Unique index maintained per row, not at statement end
- Intermediate duplicate → error, valid end state irrelevant
- Deferrable unique → check at COMMIT
- Sentinel/negative position three-step swap
- Sparse or fractional order key = single-row reorder
basics
~20 sMany engines validate uniqueness row by row as the statement runs, so the moment the first row takes the other's value there is a duplicate and it errors. Fixes: a deferrable unique constraint checked at COMMIT, a temporary sentinel value, or a non-unique ordering key.
solid answer
~60 sUniqueness is enforced by an index, and most engines insert each new index entry as the row is updated rather than at the end of the statement. Halfway through the swap both rows hold the same `(list_id, position)`, so the index rejects the second entry — even though the finished statement would have been valid. The SQL standard says checks happen at statement end, but per-row index maintenance means real engines (PostgreSQL notably) fail here. Options, roughly in order of preference: - **Deferrable unique constraint.** Declare it `DEFERRABLE INITIALLY IMMEDIATE` and `SET CONSTRAINTS ... DEFERRED` in the reorder transaction. The check runs at COMMIT, the intermediate duplicate is fine. - **Sentinel value.** Three statements in one transaction: move row A to an impossible value (a negative position), move B into A's slot, move A into B's slot. - **Delete and reinsert** both rows inside one transaction. - **Stop making order unique.** Use a fractional or lexicographic ordering key with a plain index. Reorders become single-row updates and full renumbering disappears. Note that a deferrable unique constraint in PostgreSQL cannot be used for `ON CONFLICT` inference, so check no upsert depends on it.
code
sql · 4 linesUPDATE items
SET position = CASE id WHEN 11 THEN 2 WHEN 12 THEN 1 END
WHERE id IN (11, 12);
-- ERROR: duplicate key value violates unique constraint "items_list_position_key"go deeper
Explain that the swap creates a temporary duplicate and that the classic fix is a temporary sentinel value in one transaction.
Explain per-row index maintenance versus statement-end checking, and give the deferrable-unique fix with SET CONSTRAINTS.
Weigh the options for a production list — deferral cost, index churn from sentinel shifts, and the ON CONFLICT restriction on deferrable unique constraints in PostgreSQL.
Challenge the model: a dense unique position makes every insert an O(n) renumber; propose sparse or fractional ordering keys with a deterministic tiebreak and a rebalancing story.
## Why a valid final state still errors A unique constraint is backed by a unique index. When you update a row's indexed columns, the engine maintains the index as part of updating that row — it inserts the new key and marks the old one dead. It does not wait for the whole statement to finish and then re-check the table. So an UPDATE that exchanges two rows' `position` values has an intermediate moment: row A now holds position 2, row B still holds position 2. The index insert for the second entry collides and the statement aborts. The end state you asked for was perfectly valid; you were rejected for the path, not the destination. The SQL standard actually describes constraint checking as happening at the *end of the statement*, which would make the swap legal. Implementations differ in how close they get to that: some optimisers reorder or buffer index maintenance so certain shapes of the statement happen to succeed, which produces the worst kind of behaviour — a query that works in staging with three rows and fails in production with a different plan. Do not rely on it. The same problem appears in the shift form, which is more common than the swap: `UPDATE items SET position = position + 1 WHERE list_id = 7 AND position >= 3` collides with itself as soon as two rows are processed in ascending order. ## Option 1 — defer the unique check Declare the constraint `DEFERRABLE`, and in the reorder transaction switch it to deferred (or declare it `INITIALLY DEFERRED` if reorders dominate). The pending uniqueness checks are evaluated at COMMIT against the final state, so the intermediate duplicate never matters, and the single-statement swap works as written. Shifts work too. Two caveats. First, the usual deferral costs: a duplicate that survives to the end raises its error at COMMIT, not at the statement. Second, an engine-specific but important one — in PostgreSQL a deferrable unique constraint cannot be used to infer the conflict target of `INSERT ... ON CONFLICT`, so if any code path upserts on `(list_id, position)`, making that constraint deferrable will break it. ## Option 2 — a sentinel value Without deferral (MySQL, SQL Server), route around the collision with a value nobody else uses: 1. Move A to a position that cannot collide — a negative number, or an offset well beyond the list. 2. Move B into A's old position. 3. Move A into B's old position. Three statements in one transaction; every intermediate state satisfies the unique index. It generalises to a whole-list reshuffle: offset every affected row by a large constant, then write the final numbers. The cost is more round-trips and more index churn, and you need the sentinel range to be genuinely impossible for real data (a `CHECK (position >= 0)` and negatives as sentinels do not mix). ## Option 3 — delete and reinsert Inside one transaction, delete both rows and insert them with the swapped values. It works because the deletes clear the index entries first. It is heavier than it looks — foreign keys pointing at those rows may cascade, triggers fire, and identity/surrogate keys have to be preserved explicitly — so it is a fallback, not a first choice. ## Option 4 — stop making the order column unique The deeper question is whether `position` needs to be unique at all. A unique dense integer sequence is the most expensive possible representation of "order": every insertion in the middle renumbers the tail, and every reorder is a multi-row write under a constraint that fights you. The common alternative is a sparse or fractional ordering key: store positions as 1000, 2000, 3000 and insert between two neighbours at their midpoint; or use a lexicographic string key (the technique behind ranking schemes used by issue trackers) that always has room between any two values. Order the query with `ORDER BY position, id` for a deterministic tiebreak, index the column non-uniquely, and a reorder becomes a **single-row UPDATE** with no constraint interaction at all. Occasional rebalancing handles key exhaustion. That is usually the right production answer: the interviewer is often listening for whether you reach past the mechanical fix to the modelling decision that removed the problem. ## Summary of the tradeoff Deferral is the smallest change and keeps the invariant strict. Sentinels are portable and need no schema change but add statements. Fractional keys drop strict positional uniqueness — you trade an exact invariant for cheap, contention-free reordering, which for a user-facing list is almost always the better bargain.
- Why does inserting an item in the middle of the list cause the same trouble?Making room means shifting every following row up by one, and that shift collides with itself as soon as two rows are updated in an order where the first lands on the second's current value. The same three fixes apply: defer the unique check, shift through an offset range, or use a sparse/fractional key so no shift is needed at all.
- What do you lose by dropping uniqueness on the order column?The database no longer guarantees that two items cannot share a position, so ties become possible after a bug or a race. You mitigate it by always sorting with a deterministic tiebreak such as ORDER BY position, id, and by treating position as a hint rather than an identity. For user-facing lists that is usually an acceptable trade for contention-free single-row reorders.
saying these in an interview costs you the question
- Claiming the statement must succeed because the final state is valid
- Assuming the behaviour is identical on every engine and every plan
- Reaching for dropping the constraint instead of deferring or using a sentinel
- Using a sentinel value that a CHECK constraint or real data could also occupy
- Never questioning whether a dense unique position column is the right model