Email addresses are stored with the casing the user typed, but the product treats [email protected] and [email protected] as the same account. How do you make the database enforce that uniqueness?
answer
- uniqueness on the normalized value, not the raw one
- unique index on lower(email)
- generated column + constraint = FK-targetable
- predicate must match the indexed expression
- case-insensitive collation changes all comparisons
basics
~20 sEnforce uniqueness on a normalized form, not the raw value: a unique index on an expression such as lower(email), or a stored generated column holding the lowercased value with a unique constraint on it, or a case-insensitive collation on the column plus a plain unique constraint.
solid answer
~50 sUniqueness must be enforced on the **normalized** value while the raw value stays stored. Three ways, in rough order of preference: 1. **Expression unique index** — `CREATE UNIQUE INDEX ... ON account (lower(email))`. One object, nothing extra stored, and lookups that spell the same expression (`WHERE lower(email) = lower(?)`) use it. 2. **Stored generated column + unique constraint** — `email_norm` generated as `lower(email)`, with `UNIQUE (email_norm)`. Costs a little storage but gives a real constraint: it is referenceable, shows up in schema tooling, and can be a foreign-key target. 3. **Case-insensitive collation on the column**, then an ordinary `UNIQUE (email)`. Cleanest to read, but it changes comparison semantics for *every* query on that column, which is a bigger decision than it looks. Two traps: a query written as `WHERE email = ?` will **not** use the expression index — the predicate must match the indexed expression; and lowercasing is locale- and Unicode-sensitive, so pick one normalization function and use it in exactly one place.
code
sql · 7 linesCREATE UNIQUE INDEX account_email_ci_idx ON account (lower(email));
-- or, as a real constraint:
ALTER TABLE account ADD COLUMN email_norm text
GENERATED ALWAYS AS (lower(email)) STORED;
ALTER TABLE account
ADD CONSTRAINT account_email_norm_key UNIQUE (email_norm);go deeper
Know that the fix is to enforce uniqueness on a normalized value, and be able to name a unique index on lower(email) as the mechanism.
Compare the three approaches, and state the expression-matching rule that decides whether reads actually use the index.
Add the operational parts: backfill collisions before enforcing, keep one normalization definition across registration/login/index, and know that expression indexes are not FK targets.
Frame normalization as a modelling decision — where the canonical form lives, whether the column's collation should carry it, and how it stays consistent across services.
## The problem The stored value and the value the rule applies to are different. Users expect their address back as typed, but two rows differing only in case must be rejected. A plain `UNIQUE (email)` compares the raw strings under the column's collation, so with a normal case-sensitive collation `[email protected]` and `[email protected]` are two distinct values and both are accepted. This is the second classic reason (after conditional uniqueness) that a unique **index** can do something a unique **constraint** cannot: a constraint takes a list of columns, an index can take an expression. ## Option 1 — expression (functional) unique index ``` CREATE UNIQUE INDEX account_email_ci_idx ON account (lower(email)); ``` The index stores the computed key. On insert or update the engine evaluates the expression and probes for a duplicate, so the rule is enforced atomically like any other unique index. Nothing extra is stored in the table. Requirements and consequences: - The expression must be **deterministic/immutable** — same input, same output, forever. Engines refuse to index a volatile expression, and one that silently depends on session settings would corrupt the index when the setting changes. - **Query matching.** The optimizer uses this index only for predicates spelled as the indexed expression. `WHERE lower(email) = lower($1)` matches; `WHERE email = $1` does not and will seq-scan. Every read path has to be written the same way, which is a real discipline cost in a codebase and the single most common reason this design underperforms in production. - **Not a foreign-key target**, since it keys on a derived value rather than on the referenced column. - Violations report the index name, so name it meaningfully. ## Option 2 — stored generated column plus a unique constraint ``` ALTER TABLE account ADD COLUMN email_norm text GENERATED ALWAYS AS (lower(email)) STORED; ALTER TABLE account ADD CONSTRAINT account_email_norm_key UNIQUE (email_norm); ``` The normalization is materialised as a column, and uniqueness becomes an ordinary constraint on an ordinary column. Advantages: it is a real catalog constraint (visible to tooling, referenceable by a foreign key, dumped as part of the model), reads are plain equality on `email_norm` so no expression-matching discipline is needed, and you can select the normalized value directly. Cost: extra storage and one more column to keep out of API responses. Because the column is generated, it cannot drift from `email` — do not hand-maintain it in application code, which is where these designs usually rot. ## Option 3 — case-insensitive collation Declare the column with a collation that ignores case, then `UNIQUE (email)` is enough: the uniqueness comparison uses the column's collation, so the two casings collide. This reads best and needs no discipline at call sites. The catch is scope: the collation changes comparison and sort semantics for *every* operation on that column — equality, `ORDER BY`, joins, `LIKE`/pattern behaviour in some engines. That may be exactly what you want for an email column and exactly wrong for a case-sensitive identifier. Changing a column's collation later also requires rebuilding every index over it. Treat it as a modelling decision, not a local fix. ## Normalization is a semantic choice "Case-insensitive" is not one thing. Lowercasing is locale-dependent for some scripts, and Unicode has characters whose case folding is not a simple one-to-one map; full case folding and simple lowercasing differ. For email specifically the domain part is case-insensitive by spec while the local part technically is not — most products still fold the whole thing, but that is a product decision to make explicitly. Whatever you choose, define it once. The failure mode is a system where registration normalizes one way, login another, and the unique index a third: the database happily accepts a row nobody can log into. ## Backfilling onto existing data Building the index or adding the constraint fails if current rows already collide case-insensitively — likely, since the rule was not previously enforced. Find the collisions first (group by the normalized value having count > 1), decide a merge/rename policy with the product owner, apply it, then create the index. Do not "fix" it by dropping the uniqueness requirement.
- After creating a unique index on lower(email), why is a login query still doing a sequential scan?Because the query almost certainly filters on 'email = ?' rather than on the indexed expression. An expression index is only usable when the optimizer can match the predicate to the indexed expression, so the query must be written as 'lower(email) = lower(?)'. If you cannot change every call site, materialise a normalized column and index that instead.
- Why might you prefer the generated-column form over the expression index?It produces a genuine unique constraint on a plain column, so it appears in schema tooling, can be a foreign-key target, and needs no special predicate spelling at call sites. The price is a little storage and an extra column to keep out of external representations.
saying these in an interview costs you the question
- Enforcing case-insensitive uniqueness only in application code with a SELECT-then-INSERT check
- Expecting an index on lower(email) to serve queries written as email = ?
- Maintaining a normalized duplicate column by hand instead of generating it
- Assuming a functional unique index can be a foreign-key target
- Treating lowercasing as locale- and Unicode-neutral