powershelldba.de · Uwe Janke

IS DISTINCT FROM: Comparing NULLs Without the Tricks

Every sync job, every change detection, every "did this row actually change" query runs into the same wall: two NULLs are not equal, and they are not unequal either. SQL Server 2022 finally gave us an operator that answers the question.

A synchronisation job compares a staging table against a target table and updates the rows that differ. It has worked for years. Then somebody makes the phone number column nullable, and from that day on changes to phone numbers silently stop being applied. No error, no warning, no failed job. The comparison simply stops returning those rows.

The cause is not a bug in the job. It is the rule that any comparison involving NULL produces UNKNOWN, and UNKNOWN is not TRUE, so the WHERE clause filters the row out. Every DBA knows this rule. Almost every codebase has at least one place where it was forgotten anyway.

Why the Equals Sign Cannot Compare NULLs

SQL uses three-valued logic. A predicate is TRUE, FALSE or UNKNOWN, and a WHERE clause only keeps rows where the predicate is TRUE. NULL does not mean "empty", it means "unknown", and asking whether two unknowns are equal has no sensible answer. So SQL Server returns the only honest one: unknown.

Figure 1 · The same five cases, four different operators
a b a = b a <> b a IS DISTINCT FROM b a IS NOT DISTINCT FROM b 1 1 TRUE FALSE FALSE TRUE 1 2 FALSE TRUE TRUE FALSE 1 NULL UNKNOWN UNKNOWN TRUE FALSE NULL 1 UNKNOWN UNKNOWN TRUE FALSE NULL NULL UNKNOWN UNKNOWN FALSE TRUE six cells of UNKNOWN: rows your WHERE clause silently drops
The two shaded columns are where data goes missing. A WHERE clause keeps a row only when the predicate is TRUE, so UNKNOWN and FALSE have exactly the same effect on your result set and no way to tell them apart afterwards.

Here is the failure in its smallest reproducible form. Two tables, three rows, one changed email address each in rows 2 and 3, where one side is NULL:

CREATE TABLE dbo.Tgt (id INT PRIMARY KEY, name VARCHAR(20) NULL,
                      email VARCHAR(40) NULL, phone VARCHAR(20) NULL);
CREATE TABLE dbo.Src (id INT PRIMARY KEY, name VARCHAR(20) NULL,
                      email VARCHAR(40) NULL, phone VARCHAR(20) NULL);

INSERT dbo.Tgt VALUES (1,'Ann','ann@x.de',NULL), (2,'Bob',NULL,'0301'), (3,'Cy','cy@x.de','0302');
INSERT dbo.Src VALUES (1,'Ann','ann@x.de',NULL), (2,'Bob','bob@x.de','0301'), (3,'Cy',NULL,'0302');

-- the comparison almost everybody writes first
SELECT t.id
FROM dbo.Tgt t JOIN dbo.Src s ON s.id = t.id
WHERE t.name <> s.name OR t.email <> s.email OR t.phone <> s.phone;

id
--
(0 rows)

Zero rows. Bob got an email address, Cy lost his, and the query that exists specifically to notice such things reports that nothing happened. Swap the operator and the same query does its job:

SELECT t.id
FROM dbo.Tgt t JOIN dbo.Src s ON s.id = t.id
WHERE t.name  IS DISTINCT FROM s.name
   OR t.email IS DISTINCT FROM s.email
   OR t.phone IS DISTINCT FROM s.phone;

id
--
2
3

What IS DISTINCT FROM Actually Means

Read it as "is a different value from", where NULL counts as a value in its own right. Two expressions are distinct when they hold different values, and one of those values may be "no value at all". The operator answers with TRUE or FALSE and never with UNKNOWN.

Two details decide whether you can use it today:

A related setting you may still meet in old code SET ANSI_NULLS OFF makes a comparison against a NULL literal or a NULL variable behave like IS NULL, which is how some very old applications got away with WHERE col = @p. It is deprecated, it always applied only to that narrow case and never to column-to-column comparisons, and it changes the meaning of code depending on who connected. See QUOTED_IDENTIFIER and ANSI_NULLS for why session-level switches that change query semantics are their own category of trouble.

The Three Workarounds and What They Cost

Before 2022 there were three established ways to compare two possibly-null values, and all three still work. They differ sharply in how much they cost the optimizer. The test table below holds 100,000 rows with a nonclustered index on code, of which 100 rows are NULL:

CREATE TABLE dbo.Sarg (id INT IDENTITY PRIMARY KEY, code INT NULL,
                       filler CHAR(100) NOT NULL DEFAULT 'x');
-- 100,000 rows, code = row number modulo 1000, every 997th row NULL
CREATE NONCLUSTERED INDEX IX_Sarg_code ON dbo.Sarg (code);

DECLARE @p INT = 500;

SELECT COUNT(*) FROM dbo.Sarg WHERE code = @p;                                -- baseline
SELECT COUNT(*) FROM dbo.Sarg WHERE code IS NOT DISTINCT FROM @p;             -- 2022+
SELECT COUNT(*) FROM dbo.Sarg WHERE (code = @p OR (code IS NULL AND @p IS NULL));
SELECT COUNT(*) FROM dbo.Sarg WHERE EXISTS (SELECT code INTERSECT SELECT @p);
SELECT COUNT(*) FROM dbo.Sarg WHERE ISNULL(code, -1) = ISNULL(@p, -1);

All five return the same 100 rows. The execution plans do not agree at all:

Figure 2 · Logical reads for one lookup, 100,000-row table
measured with SET STATISTICS IO ON, nonclustered index on the column code = @p 2 Index Seek code IS NOT DISTINCT FROM @p 2 Index Seek code = @p OR (code IS NULL AND @p IS NULL) 2 Index Seek EXISTS (SELECT code INTERSECT SELECT @p) 2 Index Seek ISNULL(code,-1) = ISNULL(@p,-1) 183 Index Scan, every row evaluated
The numbers are identical when the parameter is NULL: still 2 reads for the seek forms, still 183 for the sentinel. Wrapping the column in a function is what costs you the index, and it costs the same whether the value you are looking for exists or not.

Two results deserve comment, because they contradict things people repeat about this topic.

First, the verbose OR form is not slow. The optimizer recognises the pattern and still produces a single index seek. It is long, it is easy to get wrong when six columns are involved, and it makes a MERGE statement unreadable, but it does not cost performance. Replace it for clarity, not for speed.

Second, the ISNULL sentinel form is the expensive one, and it was always the most popular. It loses the index because the column sits inside a function, so the engine has to compute ISNULL(code, -1) for all 100,000 rows before it can compare anything. That is the same rule described in Never Put Functions in WHERE Clauses, and it is the strongest practical argument for the new operator.

The sentinel is also a correctness bug waiting to happen ISNULL(code, -1) = ISNULL(@p, -1) is only correct as long as -1 never appears in the data. The day a real row contains -1, that row starts matching every NULL. Picking a sentinel means betting that a value will never occur in a column you do not control, and for a date, a GUID or a free-text field there is often no safe choice at all.

ISNULL and COALESCE Are Not Comparison Operators

This is where the confusion usually starts. ISNULL and COALESCE do not compare anything. They replace a NULL with something else. Using them to compare two values is a workaround built from the wrong tool, which is why it drags the sentinel problem and the index problem along with it.

They are also not interchangeable with each other, and the differences are larger than most code assumes.

Difference 1: the data type of the result

ISNULL returns the data type of its first argument. COALESCE returns the data type with the highest precedence across all arguments. When the first argument is narrower than the replacement, ISNULL truncates without a word:

SELECT ISNULL  (CAST(NULL AS VARCHAR(2)), 'abcdef') AS isnull_result,
       COALESCE(CAST(NULL AS VARCHAR(2)), 'abcdef') AS coalesce_result;

isnull_result|coalesce_result
-------------|---------------
ab           |abcdef

-- the declared types behind those two values
isnull_type|isnull_len|coal_type|coal_len
-----------|----------|---------|--------
varchar    |2         |varchar  |6

No error, no warning, just ab. In a report column or a generated key this is the kind of defect that survives testing and shows up in production as "the description looks cut off".

Difference 2: nullability of the result

The result of ISNULL can be NOT NULL, because the optimizer knows the second argument replaces every NULL. The result of COALESCE is always treated as nullable. This is invisible in a SELECT list and decisive in a computed column:

CREATE TABLE dbo.NullTest (
    id       INT NULL,
    c_isnull AS ISNULL(id, 0),
    c_coal   AS COALESCE(id, 0)
);

col     |typ|is_nullable
--------|---|-----------
id      |int|1
c_isnull|int|0        <-- NOT NULL
c_coal  |int|1        <-- nullable

That single bit decides what you can build on the column. With both computed columns marked PERSISTED and a primary key attempted on each:

PERSISTED ISNULL column  : PK created
PERSISTED COALESCE column: Cannot define PRIMARY KEY constraint on nullable column
                           in table 'CoalOnly'.

Same logic, same result values, and only one of them can carry a key.

Difference 3: how often the first argument is evaluated

COALESCE is not a function in the engine. It is expanded into a CASE expression, and the first argument appears twice in that expansion. ISNULL is a genuine built-in and is evaluated once. The plan shows it directly:

SELECT ISNULL((SELECT val FROM dbo.Big WHERE id = 999), -1);

  |--Compute Scalar(DEFINE:([Expr1003]=isnull([dbo].[Big].[val],(-1))))
       |--Nested Loops(Left Outer Join)
            |--Constant Scan
            |--Clustered Index Seek(OBJECT:([dbo].[Big].[PK__Big...]), SEEK:([id]=(999)))


SELECT COALESCE((SELECT val FROM dbo.Big WHERE id = 999), -1);

  |--Compute Scalar(DEFINE:([Expr1006]=CASE WHEN [val] IS NOT NULL THEN [val] ELSE (-1) END))
       |--Nested Loops(Left Outer Join, PASSTHRU:(IsFalseOrNull [val] IS NOT NULL))
            |--Nested Loops(Left Outer Join)
            |    |--Constant Scan
            |    |--Clustered Index Seek(OBJECT:([dbo].[Big].[PK__Big...]), SEEK:([id]=(999)))
            |--Clustered Index Seek(OBJECT:([dbo].[Big].[PK__Big...]), SEEK:([id]=(999)))

Two seeks instead of one, and the CASE rewrite is right there in the Compute Scalar. With an aggregate subquery the optimizer often adds a spool and the second access costs nothing measurable, so this is not a reliable disaster. It is a reliable trap: put an expensive subquery or a non-deterministic expression in the first argument of a COALESCE and you have no guarantee it runs once.

Figure 3 · ISNULL and COALESCE, side by side
1. Which type wins ISNULL( CAST(NULL AS VARCHAR(2)) , 'abcdef') varchar(2) 'ab' silently truncated COALESCE( CAST(NULL AS VARCHAR(2)) , 'abcdef') varchar(6) 'abcdef' ISNULL takes the type of the first argument. COALESCE takes the highest precedence of all of them. 2. How often the first argument runs ISNULL((SELECT ...), -1) Compute Scalar: isnull(val, -1) Clustered Index Seek one access COALESCE((SELECT ...), -1) Compute Scalar: CASE WHEN val IS NOT NULL ... Clustered Index Seek Clustered Index Seek the same subquery, twice in the plan
Both differences come from the same root: ISNULL is a built-in function the engine handles as one unit, while COALESCE is ANSI syntax that gets rewritten into CASE before the optimizer ever sees it.

The full comparison

ISNULLCOALESCE
ArgumentsExactly twoTwo or more, evaluated left to right
StandardT-SQL onlyANSI SQL, portable
Result typeType of the first argument, truncates the restHighest data type precedence of all arguments
Result nullabilityCan be NOT NULLAlways nullable
ImplementationBuilt-in function, one evaluationRewritten to CASE, first argument appears twice
Usable in a key or index on a computed columnYes, when PERSISTEDNo, the column stays nullable
Good atA cheap two-way default on a known typeA chain of fallbacks across several expressions
Good at comparing NULLsNoNo

The last row is the point of this article. Whichever of the two you prefer, neither is the right tool for asking whether two values differ.

Three Questions That Look the Same and Are Not

Most of the confusion around NULL handling comes from mixing up three genuinely different questions. Once they are separated, the choice of tool is obvious.

Figure 4 · Which question are you actually asking
Replace a missing value "show 0 instead of nothing" ISNULL(col, 0) COALESCE(a, b, c) Presentation and defaulting. Never put these around a column you want to search on. Compare two values "did this row change?" a IS DISTINCT FROM b a IS NOT DISTINCT FROM b NULL counts as a value. Always TRUE or FALSE, never UNKNOWN, and the index still works. Make a filter optional "no parameter, no filter" (@p IS NULL OR col = @p) Here a NULL parameter means "all rows", not "rows where the column is NULL". Different job. Using the middle tool for the right-hand job is the most common way to break an optional search
The middle and right columns produce identical results for every non-null parameter, which is why the difference usually survives testing and fails the first time somebody leaves the search field empty.

That last point is worth spelling out in code, because it is a real regression waiting in any "optional search parameter" procedure:

-- optional filter: a NULL parameter means "do not filter at all"
WHERE (@p IS NULL OR code = @p)          -- @p NULL  -> all 100,000 rows

-- null-safe comparison: a NULL parameter means "find the rows that are NULL"
WHERE code IS NOT DISTINCT FROM @p       -- @p NULL  -> the 100 NULL rows

Both are correct. They answer different questions, and swapping one for the other changes a search screen from "show everything" to "show almost nothing" without touching a single test case that passes a real value.

Where It Fits in Real Code

Change detection in a MERGE or UPDATE

This is the case that pays for itself immediately. Compare the two versions of the same condition across four nullable columns:

-- before
WHERE  (t.name  <> s.name  OR (t.name  IS NULL AND s.name  IS NOT NULL)
                            OR (t.name  IS NOT NULL AND s.name  IS NULL))
   OR  (t.email <> s.email OR (t.email IS NULL AND s.email IS NOT NULL)
                            OR (t.email IS NOT NULL AND s.email IS NULL))
   -- and two more of these

-- after
WHERE  t.name  IS DISTINCT FROM s.name
   OR  t.email IS DISTINCT FROM s.email
   OR  t.phone IS DISTINCT FROM s.phone
   OR  t.city  IS DISTINCT FROM s.city

In a MERGE, that condition belongs in the WHEN MATCHED AND ... clause so that unchanged rows are not rewritten. See T-SQL MERGE: The Synchronization Statement That Needs Caution for the rest of the MERGE caveats, and MERGE for Slowly Changing Dimensions for the same pattern inside a dimension load, where nullable attributes are the norm rather than the exception.

Joining on a nullable key

SELECT t.id
FROM dbo.Tgt t
JOIN dbo.Src s
  ON  s.id    = t.id
  AND s.email IS NOT DISTINCT FROM t.email;   -- matches NULL to NULL

Constraints and computed columns

The operator is allowed anywhere a predicate is allowed, including CHECK constraints and computed columns, both verified on SQL Server 2022:

-- a and b may never hold the same value, and two NULLs count as the same value
CREATE TABLE dbo.ChkTest (
    a INT NULL,
    b INT NULL,
    CONSTRAINT CK_ab CHECK (a IS DISTINCT FROM b)
);

CREATE TABLE dbo.CompTest (
    a       INT NULL,
    b       INT NULL,
    changed AS CAST(CASE WHEN a IS DISTINCT FROM b THEN 1 ELSE 0 END AS BIT)
);

In a constraint the difference is bigger than it looks, because a CHECK constraint accepts a row when its predicate is TRUE or UNKNOWN. It only rejects an outright FALSE. That gives the classic CHECK (a <> b) a hole exactly where you thought the rule applied:

Inserted rowCHECK (a <> b)CHECK (a IS DISTINCT FROM b)
(1, 1)rejected, Msg 547rejected, Msg 547
(1, 2)acceptedaccepted
(NULL, NULL)accepted, the predicate is UNKNOWNrejected, Msg 547
(1, NULL)acceptedaccepted

Two NULLs slip straight through the old constraint. If your rule is "these two columns must never hold the same value", the <> version has been letting one case through for as long as the table has existed.

Limits

Q&A

Q: Is IS NOT DISTINCT FROM the same as EXISTS (SELECT a INTERSECT SELECT b)? Semantically yes for a single column, and the measured cost was identical at 2 logical reads. The INTERSECT trick also scales to several columns at once, which the operator does not. It is just unreadable enough that the next person to touch the query will not know what it does.
Q: Can I still use ISNULL for anything? Yes, for what it is for: supplying a default value. It is cheaper than COALESCE in the plan and it produces a NOT NULL result, which is exactly what you want in a PERSISTED computed column. Just keep it out of the WHERE clause on columns you index, and watch the type of the first argument.
Q: Does the new operator work on SQL Server 2019 with a higher compatibility level? No. Compatibility level does not add engine features. It works on SQL Server 2022 even at compatibility level 140, and it does not work on SQL Server 2019 at any level.
Q: Should I rewrite all my old comparisons? Rewrite the ones that carry the sentinel pattern, because those are wrong-in-waiting and slow today. Leave correct OR forms alone unless you are touching that code anyway. A rewrite that changes only style is a code review with no upside and a small chance of a new bug.

The Bottom Line

Three-valued logic is not going away, and = will never match two NULLs. What changed in SQL Server 2022 is that you no longer have to choose between a comparison that is verbose, one that is unreadable and one that is both slow and subtly wrong.

IS DISTINCT FROM and IS NOT DISTINCT FROM say what you mean, always return a definite answer, and still let the optimizer seek. ISNULL and COALESCE stay where they belong: replacing missing values, not comparing them, and not interchangeable with each other when the result feeds a computed column, a key or an expensive subquery.

Related reading QUOTED_IDENTIFIER and ANSI_NULLS for the session settings that quietly change what a comparison means. Never Put Functions in WHERE Clauses for why the sentinel workaround costs an index. Unique Indexes on Nullable Columns for the other NULL trap in schema design. T-SQL MERGE and MERGE for Slowly Changing Dimensions for where change detection actually lives. Index Seek vs. Index Scan for reading the plans quoted above. SQL Server 2025: Quick Overview for what came after this feature.