skip to content

A rule requires that at most one row per customer may have an empty (NULL) discount code — that is, uniqueness must treat two NULLs as equal. What options does a relational database give you, and what does each cost?

level: seniorimportance: should knowfreq 28%

answer

  1. default: NULLs distinct → they never collide
  2. NULLS NOT DISTINCT — SQL:2023, PostgreSQL 15+
  3. sentinel + NOT NULL = portable, noisy queries
  4. partial unique index WHERE col IS NULL
  5. SQL Server: non-distinct is already the default

basics

~20 s

Three routes: declare the key with NULLS NOT DISTINCT where the engine supports it (SQL:2023, PostgreSQL 15+); replace NULL with a non-null sentinel and use an ordinary unique constraint; or add a partial unique index over the rows where the column IS NULL. The sentinel is the portable choice.

solid answer

~60 s

Default uniqueness treats NULLs as distinct, so the rule has to be imposed deliberately. Options: 1. **`NULLS NOT DISTINCT` on the key** — standardised in SQL:2023 and available in PostgreSQL 15+. One clause, exact semantics, and it also covers composite keys. Not portable: most engines do not have it, and on SQL Server this behaviour is already the default. 2. **Non-null sentinel** — make the column `NOT NULL` with a designated value meaning "none" (`''` where the engine keeps it distinct from NULL, `0`, or a fixed date such as `'0001-01-01'` for soft-delete timestamps). Ordinary `UNIQUE` then works everywhere. Costs: every query and mapping layer must know the sentinel, `COUNT(col)` and outer-join semantics change, and the sentinel must be genuinely impossible as real data. 3. **Partial unique index over the NULL rows** — for the composite case, unique on `customer_id` where `discount_code IS NULL`. Keeps NULL meaning "unknown", adds one object, but it is a second thing to keep in sync and is not a foreign-key target. I would pick the sentinel for cross-engine schemas, the clause for a single modern engine, and the partial index when NULL must stay semantically honest.

code

sql · 3 lines
sql
ALTER TABLE discount
  ADD CONSTRAINT discount_cust_code_key
  UNIQUE NULLS NOT DISTINCT (customer_id, discount_code);

go deeper

for a junior

Know the default (NULLs never collide) and that making them collide requires an explicit clause, a sentinel value, or a filtered index.

for a middle

Write each of the three forms correctly and say which engine versions support the declarative clause.

for a senior

Weigh the costs — query sweep for sentinels, extra object for partial indexes, portability for the clause — and check existing data before the migration.

for a principal

Ask why a NULL needs to be unique at all: it usually signals two facts packed into one column, and splitting them removes the problem while improving the model.

## Why this needs deliberate work Standard uniqueness compares with three-valued logic: `NULL = NULL` is unknown, so NULL rows never collide. That default is correct for "the value genuinely does not exist and several rows may lack it". It is wrong when the absence itself is a state that must be unique — one active row, one default address, one un-revoked token, one row with no discount code. ## Option 1 — declare NULLs non-distinct SQL:2023 added an explicit `NULLS DISTINCT` / `NULLS NOT DISTINCT` qualifier on unique constraints and unique indexes; PostgreSQL implemented it in version 15. Writing `UNIQUE NULLS NOT DISTINCT (customer_id, discount_code)` makes the engine treat NULL as a comparable value for the purposes of this key, so two `(42, NULL)` rows collide. Strengths: the intent is declared in the DDL rather than encoded in data, NULL keeps its meaning everywhere else in the schema, and there is exactly one object to maintain. It works naturally for composite keys, where the sentinel approach gets awkward. Weaknesses: availability. Older versions and most other engines do not accept the clause, so migrations become engine-specific. And it flips a semantic that readers assume — anyone auditing the schema must notice the qualifier. Note the mirror-image situation: on Microsoft SQL Server, non-distinct NULLs are the *default* behaviour for unique constraints and unique indexes, so this rule needs no work there, while the opposite rule ("allow many NULLs") is the one requiring a filtered index on `WHERE col IS NOT NULL`. ## Option 2 — sentinel value Make the column `NOT NULL` and reserve one value for "none". A plain `UNIQUE (customer_id, discount_code)` then enforces the rule on every engine, because there are no NULLs left in the key. This is the most portable answer and, for some columns, the more honest model: if "no discount code" is a real business state rather than missing information, representing it as a value is arguably more correct than representing it as unknown. Costs to state explicitly: - **Query noise.** Every read must know the sentinel; `WHERE discount_code IS NULL` becomes `WHERE discount_code = ''` or `= 0`, and forgetting it produces wrong results rather than errors. - **Aggregate and join semantics change.** `COUNT(discount_code)` now counts the sentinel rows; `IS NULL` checks in application code go dead. - **Sentinel collision risk.** The chosen value must be impossible as real data. `0` for an id and `'0001-01-01'` for a timestamp are usually safe; empty string for free text often is not — and on Oracle an empty string *is* NULL, so it cannot be used as a sentinel there at all. - **Type reach.** Some types have no natural impossible value, which pushes you toward one of the other options. ## Option 3 — partial unique index over the NULL rows Keep NULL as "unknown" and add a second object enforcing the extra rule: ``` CREATE UNIQUE INDEX cust_null_code_idx ON discount (customer_id) WHERE discount_code IS NULL; ``` Combined with the ordinary `UNIQUE (customer_id, discount_code)`, you get: one row per customer per code, and at most one codeless row per customer. Each object states one rule, which reads well in review. Costs: two objects that must be changed together; the partial index is not a foreign-key target; and if the key has several nullable columns, the number of predicates you need grows combinatorially — that is where the declared-non-distinct clause clearly wins. ## Choosing - Single engine, modern version, and NULL genuinely means "unknown" elsewhere → use the `NULLS NOT DISTINCT` qualifier. - Schema must run on several engines, or the migration tooling must stay vendor-neutral → sentinel with `NOT NULL`, and document the sentinel in the column comment. - Only one nullable column and you want the DDL to read as two explicit business rules → partial unique index. Whatever you choose, apply it during a migration only after checking that existing data satisfies the new rule: if two codeless rows already exist for a customer, the constraint or index build fails, and you need a product decision about which row survives before the schema change can land. ## The deeper modelling question Often the reason a nullable column needs unique NULLs is that two different facts have been packed into one column — a code, plus a flag saying "this is the customer's default row". Splitting them (an explicit boolean or state column, uniquely constrained via a partial index; or a separate table holding exactly the special rows with a `NOT NULL` key) removes the awkwardness altogether and usually reads better a year later.

  • What breaks in existing queries when you switch a nullable column to a NOT NULL sentinel?
    Every 'IS NULL' predicate silently stops matching, and every 'IS NOT NULL' starts matching the sentinel rows, so filters return wrong rows rather than errors. Aggregates change too: COUNT(col) now includes the sentinel, and outer joins can no longer be distinguished by a NULL test on that column. The migration has to sweep the codebase, not just the schema.
  • You need this rule on a key with three nullable columns. Which option scales?
    The declared NULLS NOT DISTINCT qualifier, because it covers every NULL combination in one object. Partial indexes would require a separate predicate for each combination of which columns are NULL, which grows combinatorially and is unmaintainable, and sentinels would have to be found for three different types.

saying these in an interview costs you the question

  • Assuming plain UNIQUE already makes two NULLs collide
  • Choosing empty string as the sentinel on an engine where '' is stored as NULL
  • Adding the constraint without first checking that existing data satisfies it
  • Enforcing the rule only in application code, which races between concurrent inserts
  • Treating NULLS NOT DISTINCT as portable across engines

context