A table keeps soft-deleted rows using a deleted_at timestamp, and the business rule is 'at most one live account per email address' while deleted rows must be retained. How do you enforce that rule in the database?
answer
- plain UNIQUE blocks re-registering a deleted email
- partial index: WHERE deleted_at IS NULL
- smaller index, predicate must be provable for reads
- not an FK target → need a surrogate key
- portable fallback: generated column, NULL when deleted
basics
~20 sPlain UNIQUE (email) is wrong — it blocks re-registering a deleted address. Enforce it with a partial (filtered) unique index on email restricted to rows where deleted_at IS NULL. Where partial indexes are unavailable, index a generated column that holds the email only for live rows.
solid answer
~50 sA plain `UNIQUE (email)` enforces the wrong rule: it counts deleted rows, so once someone deletes their account nobody — including them — can register that address again. The direct answer is a **partial (filtered) unique index**: unique on `email` only over rows where `deleted_at IS NULL`. Live rows collide; deleted rows are outside the index and are ignored, and the index is smaller than a full one. Where the engine has no partial indexes, the portable trick is a generated/derived column that carries the email for live rows and NULL for deleted ones, with a unique constraint on it — it works because most engines treat NULLs as distinct, so any number of deleted rows coexist. Two consequences worth stating: the partial index is **not** a valid foreign-key target, so the table needs a surrogate primary key for children to reference; and if deletes are reversible, undelete must re-check uniqueness rather than assume it, because the address may have been taken while the row was hidden.
code
sql · 3 linesCREATE UNIQUE INDEX account_live_email_idx
ON account (email)
WHERE deleted_at IS NULL;go deeper
Recognise that a plain unique constraint counts deleted rows too, and name the partial/filtered unique index as the tool that fixes it.
Write the index correctly, explain that deleted rows are simply not in it, and note the smaller size and the predicate-matching rule for reads.
Cover the fallout: no foreign-key targetability, index-named errors, undelete races, and deduplicating before the index build on a live table.
Challenge the model first — archived rows in a history table restore a plain constraint and remove a leak-prone predicate from every query; choose the partial index only when co-location is required.
## Why the obvious constraint is wrong Soft deletion keeps a row physically present with a marker column. A `UNIQUE (email)` constraint has no idea about that marker: it enforces uniqueness across every stored row, live or not. The visible symptom is a support ticket — "I deleted my account and now I cannot sign up again" — and the usual bad fix is to move the check into application code, which then races under concurrency. The rule you actually want is conditional: *unique among rows satisfying a predicate*. Standard constraint syntax cannot express a predicate. This is the canonical reason to reach for a unique **index** instead of a unique **constraint**. ## The partial (filtered) unique index A partial index indexes only the rows matching a `WHERE` clause. Made unique, it enforces uniqueness exactly over that subset: ``` CREATE UNIQUE INDEX account_live_email_idx ON account (email) WHERE deleted_at IS NULL; ``` Properties: - **Correct semantics.** Two live rows with the same email collide; a live row and any number of deleted rows coexist. - **Smaller and cheaper.** Deleted rows are never inserted into the index. On a table where most rows are archived, the index can be a fraction of the full size, and writes that only touch archived rows skip it entirely. - **Usable for reads** — but only for queries whose predicate the optimizer can prove implies the index predicate. `WHERE email = ? AND deleted_at IS NULL` matches; `WHERE email = ?` alone generally does not, so a query that must search all rows needs its own index. This surprises people who assume the partial index covers every email lookup. ## The portable alternative If the engine has no partial indexes, encode the condition into a value and index that. A stored generated column works well: ``` ALTER TABLE account ADD COLUMN live_email text GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN email END) STORED; ALTER TABLE account ADD CONSTRAINT account_live_email_key UNIQUE (live_email); ``` For live rows the column holds the email and collisions are caught; for deleted rows it is NULL, and because most engines treat NULLs as distinct for uniqueness, unlimited deleted rows are fine. Note the dependency on that NULL rule — on an engine that treats NULLs as equal for uniqueness, the trick collapses to "only one deleted row ever", and you need a different encoding (for example, folding the row's surrogate id into the value so archived rows are unique by construction). ## What this costs you elsewhere **Foreign keys.** A partial unique index cannot be a foreign-key target: it proves nothing about rows outside the predicate, and children may legitimately point at archived rows. The table therefore needs a surrogate key — an identity or UUID primary key — as the stable reference. This is not a workaround; it is the correct model, because a business identifier that can be recycled should never be a child's reference. **Error handling.** Violations surface as a duplicate-key error naming the *index*, not a friendly constraint name. Name the index deliberately (`account_live_email_idx`) so error-mapping code and logs stay readable, and so a reviewer does not mistake it for a stray performance index. **Undelete.** If rows can be restored, restoring is an insert into the partial index and can fail — someone else may have taken the address in the meantime. Restore paths must handle the duplicate-key error rather than assume success. **Migration onto a live table.** Building the index will fail outright if the current data already violates the rule, which on a soft-delete table it frequently does (several live rows sharing an address created while the rule lived only in application code). Deduplicate first, then build. ## When not to do this If the archived rows have no further business meaning, the cleaner model is to move them to a history table and put a plain `UNIQUE (email)` on the live table. That restores a normal constraint, restores foreign-key targetability, keeps the hot table small, and removes the `deleted_at IS NULL` predicate from every query — a predicate that, once forgotten anywhere, is a data-leak bug. The partial index is the right tool when archived rows genuinely must stay in the same table, not a default.
- Will the partial unique index also speed up a lookup by email that does not mention deleted_at?Generally no. The optimizer may only use a partial index when it can prove the query predicate implies the index predicate, so 'WHERE email = ? AND deleted_at IS NULL' qualifies while 'WHERE email = ?' does not. If you also need to search archived rows by email, add a separate non-unique index for that access path.
- Why can't a child table's foreign key reference the email column protected by this partial index?A foreign key requires uniqueness over the whole table, and the partial index only guarantees it for live rows — nothing stops several archived rows sharing an address. Give the table a surrogate primary key and reference that instead, which is also more robust because the email can legitimately change.
saying these in an interview costs you the question
- Using plain UNIQUE (email) and moving the 'is it deleted' check into application code
- Assuming the partial index answers every email lookup, including archived rows
- Trying to point a foreign key at the partially-unique column
- Building the index without deduplicating existing violating rows first
- Believing a UNIQUE (email, deleted_at) constraint solves it — NULL deleted_at values do not collide, so it enforces nothing for live rows