powershelldba.de

Unique Indexes on Nullable Columns: The NULL Behavior Trap

A UNIQUE constraint is supposed to guarantee there's only one of something. On a nullable column in SQL Server, it doesn't — SQL Server lets you insert multiple NULLs, because it treats NULL as "unknown," and two unknowns are never considered equal to each other, not even for uniqueness. Here's why, and how to actually enforce "at most one" when you mean it.

The Surprise

CREATE TABLE dbo.Employees (
    EmployeeId INT PRIMARY KEY,
    BadgeNumber VARCHAR(20) NULL
);

CREATE UNIQUE INDEX UX_Employees_BadgeNumber
    ON dbo.Employees (BadgeNumber);

INSERT INTO dbo.Employees VALUES (1, 'B-1001');
INSERT INTO dbo.Employees VALUES (2, NULL);
INSERT INTO dbo.Employees VALUES (3, NULL);   -- succeeds — no violation

SELECT * FROM dbo.Employees;
-- EmployeeId 2 and 3 BOTH have NULL BadgeNumber, and the unique index allowed it

To anyone coming from a mental model of "unique means no duplicates," this looks like a bug. It isn't — it's SQL Server implementing the ANSI SQL standard's three-valued logic consistently. NULL means "unknown," and by that logic, one unknown value is never equal to another unknown value — including for the purposes of a uniqueness check. SQL Server allows any number of NULLs in a unique index for exactly the same reason NULL = NULL evaluates to unknown rather than TRUE in a WHERE clause.

⚠ This is not universal across databases — know your engine. PostgreSQL and Oracle follow the same standard and also permit multiple NULLs in a unique column. MySQL's InnoDB does the same. But teams moving between platforms, or reading advice written for a different engine, sometimes assume a unique constraint blocks all duplicates including NULL — and ship code with a silent gap.

Why This Actually Matters

The badge number example above is realistic: a nullable "external reference" column — badge number, external customer ID, an optional SSO subject identifier — that's supposed to be unique whenever it's populated, but is legitimately absent for many rows. A plain UNIQUE index does not enforce "unique whenever present." It enforces "unique among non-NULL values, with no limit on how many rows can share NULL."

If your business rule is genuinely "every employee must have exactly one badge number, full stop, no exceptions," that's a NOT NULL UNIQUE column and this article doesn't apply to you. The trap only bites when the column is legitimately nullable and you also need uniqueness among the values that do exist — a very common combination that a plain unique index does not actually enforce the way most people assume.

The Fix: A Filtered Unique Index

SQL Server's filtered index feature lets you scope a unique index to only the rows where the column is populated — which is precisely "unique among non-NULL values, and I don't care how many NULLs there are" made explicit and enforced:

DROP INDEX UX_Employees_BadgeNumber ON dbo.Employees;

CREATE UNIQUE INDEX UX_Employees_BadgeNumber
    ON dbo.Employees (BadgeNumber)
    WHERE BadgeNumber IS NOT NULL;

INSERT INTO dbo.Employees VALUES (4, 'B-1001');
-- Msg 2601: Cannot insert duplicate key row... the duplicate key value is (B-1001)
-- This one now correctly fails

Rows where BadgeNumber IS NULL are simply outside the index's filter — they aren't indexed at all, so they can't violate anything. Rows where it's populated are fully enforced for uniqueness. This is exactly "at most one" for the values that matter, with unlimited NULLs for the rows where the value doesn't apply.

Filtered Index vs. a CHECK Constraint Workaround

Before filtered indexes existed (pre-SQL Server 2008), the common workaround was a computed column trick or application-level checking. Neither is worth reaching for now — the filtered index is simpler, is enforced by the engine itself (not by application discipline), and the optimizer can use it like any other index for queries that include the same filter predicate.

ApproachEnforced byVerdict
Filtered unique index The database engine, always Correct, modern approach — use this
Application-level duplicate check before insert Application code, only if every code path remembers to check Race condition under concurrency; skip this
Computed column + unique index on the computed column The engine, but adds a redundant column and more complexity Legacy pattern, superseded by filtered indexes

The Reverse Trap: Composite Unique Constraints

The same three-valued logic applies to multi-column unique indexes, and it's easier to miss there. A unique index on (TenantId, ExternalRef) treats a row with ExternalRef = NULL as never conflicting with any other row for that TenantId — even a second, third, or hundredth row with the same TenantId and a NULL ExternalRef. If the actual requirement is "unique per tenant, whenever an external ref exists," the fix is the same filtered pattern, just with the composite key:

CREATE UNIQUE INDEX UX_Mapping_TenantExternalRef
    ON dbo.ExternalMapping (TenantId, ExternalRef)
    WHERE ExternalRef IS NOT NULL;

Checking What You Already Have

If you suspect an existing unique index on a nullable column might already have accumulated duplicate NULLs (it will have, if the column is nullable and populated over time), check before adding a filtered index — you can't add a unique filtered index over data that already violates it:

-- Does this column already have more than one NULL?
SELECT COUNT(*) AS NullCount
FROM dbo.Employees
WHERE BadgeNumber IS NULL;

-- Which INDEXES on this table are unique but NOT filtered — candidates to review
SELECT
    i.name AS index_name,
    i.is_unique,
    i.has_filter,
    i.filter_definition
FROM sys.indexes AS i
WHERE i.object_id = OBJECT_ID('dbo.Employees')
  AND i.is_unique = 1;

If duplicates already exist and the business rule genuinely requires closing the gap, you'll need to resolve the existing duplicates (assign real values, or a decision on which row keeps which value) before the filtered unique index can be created — it will fail to build over data that already violates it, which is exactly the safety check you want before enforcing a rule retroactively.

The Bottom Line

A plain unique index on a nullable column in SQL Server does not prevent duplicate NULLs — this is standard-compliant three-valued-logic behavior, not a bug, but it surprises almost everyone the first time they hit it. If the real requirement is "unique whenever a value is present," say so explicitly with a filtered unique index (WHERE column IS NOT NULL) rather than relying on a plain unique constraint to do something it was never designed to do.

← Back to Blog