skip to content

Product wants email addresses to be unique in a 100-million-row users table that currently contains duplicates, and there is no maintenance window. Lay out the rollout you would run, including how you decide when it is safe to enforce.

level: principalimportance: should knowfreq 26%

answer

  1. No NOT VALID for unique — index build is the verification
  2. Semantics first: case, soft-deletes, NULLs, merge policy
  3. App enforcement before dedupe, or you race new dupes
  4. CREATE UNIQUE INDEX CONCURRENTLY: two passes, can leave invalid index
  5. ADD CONSTRAINT ... UNIQUE USING INDEX = brief lock, no rescan

basics

~20 s

Settle the semantics, enforce in the application first, dedupe the history in batches, build the unique index without blocking writes (CREATE UNIQUE INDEX CONCURRENTLY / ONLINE=ON), then attach it as a constraint with a brief lock. Uniqueness has no unvalidated mode, so the index build is the staging mechanism.

solid answer

~60 s

**1. Define uniqueness before touching anything.** Case sensitivity and normalisation, whether soft-deleted users are included, how NULL emails behave, and what merging duplicates means for their orders and sessions. These are product decisions and they dominate the schedule. **2. Stop the bleeding.** Enforce in the application and deploy it. Without this, dedupe races new duplicates and the index build fails. **3. Measure and dedupe.** Count duplicate groups, then resolve them in small batches — merge or rename — with a query you can rerun to confirm convergence. **4. Build the index without blocking.** Uniqueness has no `NOT VALID` equivalent, because the constraint *is* the index. Use `CREATE UNIQUE INDEX CONCURRENTLY` (PostgreSQL) or `WITH (ONLINE = ON)` (SQL Server). The concurrent build makes two passes, takes much longer than a normal build, and if it hits a duplicate it leaves an invalid index you must drop and retry. **5. Attach it.** `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX` adopts the finished index with a brief lock and no rescan. **6. Verify and keep a rollback.** Confirm the index is valid, monitor error rates from newly-rejected writes.

code

sql · 9 lines
sql
SELECT count(*) AS duplicate_groups,
       sum(n) - count(*) AS rows_to_resolve
FROM (
  SELECT lower(email) AS e, count(*) AS n
  FROM users
  WHERE email IS NOT NULL AND deleted_at IS NULL
  GROUP BY lower(email)
  HAVING count(*) > 1
) d;

go deeper

for a junior

Know that you must remove the duplicates before uniqueness can be enforced, and that the index build is the expensive part.

for a middle

Give the ordered plan and know that a concurrent or online index build avoids blocking writes, unlike a plain one.

for a senior

Add the failure modes — invalid index after a failed concurrent build, no NOT VALID equivalent for uniqueness, lock_timeout on the adoption step — and the monitoring of newly-failing writes.

for a principal

Lead with the semantic and merge decisions that dominate the risk, sequence enforcement before cleanup before build, and state the rollback and the convergence signal that tells you it is safe to enforce.

## Why this is harder than adding a CHECK For CHECK and foreign-key constraints you can add the rule unvalidated, so enforcement starts immediately and verification happens later under a weak lock. **Uniqueness has no such mode.** A unique constraint is implemented by a unique index, and there is no unique index without building it — the build *is* the verification. So the staging mechanism has to be the index build itself, which is why the whole plan is shaped differently. ## Step 1 — decide what "unique" means This is where most of the risk lives, and it is not a database question: - **Case and normalisation.** Is `[email protected]` the same as `[email protected]`? Almost always yes for login, which means the constraint should be on a normalised expression or a stored normalised column — and that changes the duplicate count, often dramatically. - **Scope.** Are soft-deleted or deactivated users included? If a deleted user's address should be reusable, the rule is a *partial* uniqueness over active rows, not a global one. - **Missing values.** Whether an email is optional at all, and what should happen to rows without one. - **Merge semantics.** Two accounts with the same address own orders, sessions, subscriptions, audit history. Deciding which survives and what happens to the other's data is a product and possibly a legal question, and it is the long pole of the schedule. Get these wrong and you will build the index twice. ## Step 2 — enforce in the application first Deploy the check in code before touching the schema: registration and profile-update paths reject an address that already exists. This is not the durable guarantee — races can still slip a pair through — but it reduces the arrival rate of new duplicates to near zero, which is what makes the cleanup converge and the index build succeed. Let it soak long enough to see the duplicate-creation rate flatten in the data. ## Step 3 — measure, then dedupe in batches Count first: how many duplicate groups, how large, how old. The distribution decides the strategy — a few thousand old groups is a scripted merge; a few million spread across active users is a product programme. Dedupe in small transactions, keyed so the work is resumable. Keep the resolution rule explicit and reversible: record the merged-away identifiers somewhere durable so a bad merge can be reasoned about later. Rerun the duplicate-count query until it reaches zero and *stays* zero across a few hours of live traffic — that stability, not the single zero reading, is your signal that step 2 is actually holding. ## Step 4 — build the unique index without blocking ```sql CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (lower(email)); ``` What to know about a concurrent build: - It makes **two passes** over the table and waits for existing transactions between them, so it takes considerably longer than a plain build — plan for hours on 100 million rows. - It does **not** block reads or writes, which is the point. - It cannot run inside a transaction block, and many migration frameworks wrap statements in transactions by default — a classic trip hazard. - If it encounters a duplicate, it fails and leaves an **invalid** index behind that still costs write overhead. You must `DROP INDEX CONCURRENTLY` it and start over. This is precisely why steps 2 and 3 come first: a failed build on a huge table is hours of wasted time. - On SQL Server the analogue is `CREATE UNIQUE INDEX ... WITH (ONLINE = ON)`. ## Step 5 — attach the index as a constraint ```sql ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE USING INDEX users_email_uniq; ``` This adopts the already-built index, so it takes a brief strong lock and performs **no rescan**. Run it with a short `lock_timeout` and retry. (Whether you need the named constraint at all is a separate judgement — a unique index already enforces uniqueness; the constraint form is what other constraints and some tooling can reference. Note also that an expression index such as `lower(email)` cannot be adopted as a table constraint, so if you normalise via an expression you keep the unique index rather than converting it — another reason step 1 must be settled early.) ## Step 6 — verify, monitor, and know the rollback - Confirm the index reports as valid in the catalog; a silently invalid index enforces nothing on reads and slows writes. - Watch application error rates: writes that used to succeed now fail with duplicate-key errors, and every caller must handle that. Ideally the application already returns a clean "email already registered" message from step 2, and the constraint error is a last-resort path. - The rollback is cheap and should be written down before you start: drop the constraint or index concurrently. Nothing in this plan is irreversible except the merges, which is another reason the merge step deserves the most scrutiny. ## The judgement being tested The mechanical steps are learnable. What distinguishes a strong answer is sequencing — enforce before you clean, clean before you build — and recognising that the semantic decisions in step 1 and the merge policy in step 3 carry more risk than any DDL statement in the plan.

  • Why can't you add a unique constraint NOT VALID and validate it later, the way you would a CHECK?
    Because a unique constraint is realised as a unique index, and there is no way to have the index without building it — building it is the verification. The staged equivalent is to build the index concurrently, which does the long work without blocking reads or writes, and then adopt it with ADD CONSTRAINT ... UNIQUE USING INDEX, which takes only a brief lock and no rescan.
  • The concurrent index build failed partway through. What state are you in and what do you do?
    PostgreSQL leaves an invalid index in the catalog. It is not used for query planning and does not enforce uniqueness, but it is still maintained on every write, so it costs you without helping. Drop it with DROP INDEX CONCURRENTLY, find and resolve the duplicates that caused the failure, and restart the build.
  • Why enforce uniqueness in the application before cleaning up the data, rather than after?
    Because otherwise the cleanup races live traffic: you resolve duplicate groups while new ones are still being created, the count never reaches zero, and the eventual index build fails on a duplicate created minutes earlier. Application enforcement is not a durable guarantee — races can still slip pairs through — but it reduces the arrival rate enough for the cleanup to converge.

saying these in an interview costs you the question

  • Trying to add the unique constraint NOT VALID, which is not supported
  • Running a plain CREATE UNIQUE INDEX and locking out writes for hours
  • Deduplicating before the application stops creating duplicates
  • Assuming a failed concurrent build leaves nothing behind
  • Deciding case-sensitivity and soft-delete scope after the index is already built

context