skip to content

A signup flow runs a SELECT for the submitted email address and, when no row comes back, INSERTs the new account. Under concurrent traffic it occasionally creates duplicate accounts for the same address. Explain why, and what actually fixes it.

level: middleimportance: must knowfreq 68%

answer

  1. Check-then-act: gap between SELECT and INSERT
  2. No row exists → nothing to lock → no protection
  3. READ COMMITTED / snapshot: neither sees the other
  4. Unique index makes the conflict physical
  5. Keep the SELECT for UX, catch the violation for truth

basics

~20 s

The SELECT and the INSERT are separate operations with a gap between them, and a read of a row that does not exist locks nothing. Two requests can both find no row and both insert. The fix is a unique constraint on the email column, with the application handling the resulting violation.

solid answer

~60 s

This is a time-of-check-to-time-of-use race. The SELECT observes a snapshot in which no matching row exists; nothing is locked, because **there is no row to lock**; a concurrent request observes the same absence; both INSERTs proceed and both succeed. Isolation level does not rescue you at the usual settings — under READ COMMITTED or snapshot-style REPEATABLE READ neither transaction sees the other's uncommitted row. A truly serializable engine may abort one transaction, but that is a heavier hammer with retries, not the standard fix. The fix is to make the rule **physical**: a unique index or unique constraint on the email column. Uniqueness is then enforced inside the index structure — the second inserter collides with the first key, blocks until that transaction resolves, and then fails. There is no window. The application still keeps the pre-check, because it produces a nice 'that address is taken' message on the common path, and additionally handles the violation error for the rare race. Approaches that do **not** work: an in-process mutex (useless with more than one instance), retrying the SELECT, or locking a row that does not exist.

code

sql · 8 lines
sql
-- session A                        -- session B
BEGIN;                              BEGIN;
SELECT id FROM users                SELECT id FROM users
  WHERE email = '[email protected]';            WHERE email = '[email protected]';
-- 0 rows                           -- 0 rows (A has not committed)
INSERT INTO users(email) ...        INSERT INTO users(email) ...
COMMIT;                             COMMIT;
-- two rows now exist for [email protected]

go deeper

for a junior

Identify the race in plain terms — two requests both find nothing and both insert — and name the unique constraint as the fix.

for a middle

Explain why a plain transaction does not help (nothing to lock when the row is absent, snapshots hide the peer's uncommitted row) and describe how the index makes the conflict physical.

for a senior

Cover the violation-handling path: named constraints mapped to messages, savepoint or retry semantics, and the idempotent insert-on-conflict variant; contrast with serializable isolation and its retry cost.

for a principal

Generalise to a rule for the codebase: any check-then-act invariant must be backed by a physical enforcement structure, and code review should treat the pattern as a defect until an index or constraint is named.

## The shape of the bug The code is: 1. `SELECT id FROM users WHERE email = ?` 2. if no row → `INSERT INTO users (email, ...) VALUES (?, ...)` Between steps 1 and 2 there is a window. Whatever the code learned in step 1 is a statement about the past, and nothing prevents the world from changing before step 2 lands. This is the check-then-act (or time-of-check-to-time-of-use) pattern, and it is the single most common concurrency defect in application data access. ## Why a transaction alone does not close the window The usual first reaction is 'wrap it in a transaction'. That does not help, and understanding why is the point of the question. A transaction gives atomicity and a consistent view; it does not give exclusivity over rows that do not exist. Locks in a relational engine are taken on rows (and on index entries). Reading a predicate that matches **nothing** finds no row to lock, so nothing is protected. This is the phantom problem: the absence of a row is not a lockable object at the usual isolation levels. Under **READ COMMITTED**, each statement sees committed data as of its own start, so transaction B's SELECT cannot see A's uncommitted insert. Under snapshot-based **REPEATABLE READ**, B sees its transaction-start snapshot, which also excludes A's row. Either way both transactions legitimately conclude 'no such user'. **SERIALIZABLE** is the one level that is defined to prevent this. An engine implementing it with predicate locks or with serializable snapshot isolation will detect the read-write conflict and abort one transaction with a serialization failure, which the application must retry. That is correct but expensive and operationally noisy: you now need retry logic on every such path, and throughput suffers under contention. It is a fine tool for invariants nothing else can express, and overkill for uniqueness. Also note `SELECT ... FOR UPDATE` does not help: there is no row to lock. It solves check-then-act for *existing* rows, not for absence. ## The actual fix: make the constraint physical Declare a unique constraint (or unique index) on the email column. Enforcement then happens inside the index B-tree at the moment of insertion: - Transaction A inserts, placing its index entry for that key. - Transaction B inserts the same key, finds the conflicting entry, and blocks waiting for A to commit or roll back — the engine has something concrete to wait on. - If A commits, B fails with a unique-violation error. If A rolls back, B proceeds. There is no window, no isolation level to reason about and no retry loop. The invariant is enforced by the physical structure rather than by a sequence of decisions. This generalises: whenever you catch yourself writing 'check that no row exists, then insert', ask what index would make the conflict physical. Conditional uniqueness — 'at most one active row per customer' — has the same answer via a filtered/partial unique index. ## What the application code should look like afterwards Keep the SELECT. It is not there for correctness any more; it is there for user experience — it lets you return a clean 'this email is already registered' response on the common path, alongside other form errors, without hitting an exception path. Then handle the violation: - Catch the specific unique-violation error and identify **which** constraint fired by name. A row may violate several unique keys, and 'email taken' and 'username taken' are different messages. Named constraints make that mapping possible. - Translate to the same user-facing error as the pre-check, so the rare race and the common case look identical to the user. - Be aware of the transaction consequences: in some engines any error aborts the transaction, so a savepoint around the insert, or restarting the transaction, is required to continue. In others the statement fails and the transaction survives. - For operations that are naturally idempotent, prefer an insert-if-absent form (an upsert or insert-on-conflict-do-nothing) so the race is not an error at all. ## Things that look like fixes and are not **An application-level lock or `synchronized` block.** Works on one process, fails the moment you run two instances — which is every production deployment. It also does nothing about scripts and other services. **A distributed lock.** Correct in principle but far heavier than an index, adds a dependency and a failure mode, and still leaves the database able to accept duplicates from any path that forgot the lock. **Retrying the SELECT, or sleeping.** Does not narrow the window in any meaningful way; it just changes the probability. **Serializable isolation.** Correct, but you have replaced a free, always-on, universal guarantee with one that requires every writer to run at that level and every caller to retry. ## The generalisation worth saying out loud An invariant enforced by a sequence of application steps is only as strong as the weakest concurrent interleaving and the most careless code path. An invariant enforced by the database is a property of the data. When both are available, use the database for the guarantee and the application for the message.

  • Would running the transaction at SERIALIZABLE isolation fix it, and why is that not the standard answer?
    Yes, a correct serializable implementation prevents it: it detects the read-write conflict between the two transactions and aborts one with a serialization failure. It is not the standard answer because it costs more, requires every writer on that path to use the same level, and forces the application to implement retry logic for a problem a unique index solves with no window, no retries and no coordination. Serializable is the right tool for invariants no index can express.
  • The insert now fails with a unique-violation error. How should the write path handle it?
    Catch the specific violation, determine which constraint fired by its name, and map it to the same user-facing message the pre-check would have produced, so the race is invisible to the user. Be careful with the transaction: in some engines the error aborts the whole transaction, so wrap the insert in a savepoint or restart it if further work must follow. Where the operation is idempotent, prefer an insert-on-conflict-do-nothing form so no error is raised at all.

Two people check a hotel register, both see room 12 unbooked, and both write their name in. The register was accurate when each of them read it. A lock on the door — something physical that only one person can hold — is what actually prevents the double booking, and that is what the unique index is.

saying these in an interview costs you the question

  • 'Just wrap the SELECT and INSERT in a transaction' — the gap is still there
  • Claiming SELECT ... FOR UPDATE protects a row that does not exist yet
  • Using an in-process mutex or a language-level lock as the fix
  • Believing READ COMMITTED or REPEATABLE READ lets one transaction see the other's uncommitted insert
  • Removing the pre-check SELECT entirely and returning a raw database error to the user

context