Is it fast to check null or enum equality using is in C#?
Posted 2026–08–12.
tl;dr
If you’re here just looking for a very quick answer, the answer seems to be: yes! is is appropriate as an equality operator. Usefully, it checks for equality directly and does not follow an overloaded == operator. However, it can only be used when checking against constant values. This all exists as part of pattern-matching.
The meat of the post
I now work, and have previously worked at companies whose backend code was written primarily in C#. Fairly often these days I’ll see someone checking for certain equalities in ways that I might not think to do without outside prompting.
Namely:
- Checking for
nullwith expressions such asx is not null, andx is A(in the case thatAis the non-nullable form of the type, andxis of typeA?). - Checking for enum equality (especially when comparing with a variety of options) with
ise.g.x is MyEnum.Option1 or MyEnum.Option2.
These stand out to me, because the is operator in my mind is historically associated with various kinds of reflection-like behaviour, like checking whether an object’s class is a particular subclass of its statically checked type, or whether its class implements a particular interface. This association of mine is outdated, as this use of is for simple equality checking was introduced as part of pattern matching in C#, i.e. quite some time ago.
There’s a good reason to use is when checking null: it avoids accidental usage of an overloaded == operator.
The x is A syntax seems pointlessly indirect to me — am I missing something? However, there’s an extended form x is A a, assigning the non-null value to a new variable a. This has some genuine convenience in that it provides a quick way to strengthen a nullable variable into a non-nullable one for a particular scope:
A? x = new A();
// ...
if (x is A a)
{
// a's type is just `A`, not `A?`
}
Anyway, I’m not here to argue the merits of these alternative expressions but instead just confirm for you, dear reader, that they are all semantically equivalent, and your coworker’s code using x is null to check a null is really not doing any reflection.
Everything I’m doing here is using .NET 9 with its default C# language version, C# 13, using top-level statements. At the time of writing, .NET 9 is still supported. Just.
To start with, the enum check. There’s a bit of gymnastics in this code to force the compiler to emit IL resembling the C# code. Our canonical, plain version:
var input = Console.ReadLine();
A foo;
if (input?.Length > 3)
{
Console.WriteLine("output!");
foo = A.X;
}
else
{
foo = A.Y;
}
if (foo == A.X)
{
Console.WriteLine("hello!");
}
enum A
{
X,
Y
}
Our contrasting version has only one change, on the line checking the value of foo:
if (foo is A.X)
Compiling both and decompiling with ILSpy gives me the following IL:
.class private auto ansi beforefieldinit Program
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Methods
.method private hidebysig static
void '<Main>$' (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Header size: 12
// Code size: 68 (0x44)
.maxstack 2
.entrypoint
.locals init (
[0] string input,
[1] valuetype A foo,
[2] bool,
[3] bool
)
IL_0000: call string [System.Console]System.Console::ReadLine()
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: brtrue.s IL_000c
IL_0009: ldc.i4.0
IL_000a: br.s IL_0015
IL_000c: ldloc.0
IL_000d: call instance int32 [System.Runtime]System.String::get_Length()
IL_0012: ldc.i4.3
IL_0013: cgt
IL_0015: stloc.2
IL_0016: ldloc.2
IL_0017: brfalse.s IL_002a
IL_0019: nop
IL_001a: ldstr "output!"
IL_001f: call void [System.Console]System.Console::WriteLine(string)
IL_0024: nop
IL_0025: ldc.i4.0
IL_0026: stloc.1
IL_0027: nop
IL_0028: br.s IL_002e
IL_002a: nop
IL_002b: ldc.i4.1
IL_002c: stloc.1
IL_002d: nop
IL_002e: ldloc.1
IL_002f: ldc.i4.0
IL_0030: ceq
IL_0032: stloc.3
IL_0033: ldloc.3
IL_0034: brfalse.s IL_0043
IL_0036: nop
IL_0037: ldstr "hello!"
IL_003c: call void [System.Console]System.Console::WriteLine(string)
IL_0041: nop
IL_0042: nop
IL_0043: ret
} // end of method Program::'<Main>$'
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// Method begins at RVA 0x20a0
// Header size: 1
// Code size: 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method Program::.ctor
} // end of class Program
I won’t comment on the contents of this as I’m far from an expert, but I can confirm that the same IL is produced from both versions of the source code.
Great. Next let’s look at nulls.
var input = Console.ReadLine();
A? foo;
if (input?.Length > 3)
{
foo = new A
{
X = "abc"
};
}
else
{
foo = null;
}
if (foo != null)
{
Console.WriteLine("hello!");
}
public class A
{
public A()
{
Console.WriteLine("output!");
}
public required string X { get; init; }
}
The altered versions again just change the line with the null check to:
if (foo is not null)
and
if (foo is A)
The output IL for all three is:
.class private auto ansi beforefieldinit Program
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Methods
.method private hidebysig static
void '<Main>$' (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Header size: 12
// Code size: 73 (0x49)
.maxstack 3
.entrypoint
.locals init (
[0] string input,
[1] class A foo,
[2] bool,
[3] bool
)
IL_0000: call string [System.Console]System.Console::ReadLine()
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: brtrue.s IL_000c
IL_0009: ldc.i4.0
IL_000a: br.s IL_0015
IL_000c: ldloc.0
IL_000d: call instance int32 [System.Runtime]System.String::get_Length()
IL_0012: ldc.i4.3
IL_0013: cgt
IL_0015: stloc.2
IL_0016: ldloc.2
IL_0017: brfalse.s IL_002f
IL_0019: nop
IL_001a: newobj instance void A::.ctor()
IL_001f: dup
IL_0020: ldstr "abc"
IL_0025: callvirt instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) A::set_X(string)
IL_002a: nop
IL_002b: stloc.1
IL_002c: nop
IL_002d: br.s IL_0033
IL_002f: nop
IL_0030: ldnull
IL_0031: stloc.1
IL_0032: nop
IL_0033: ldloc.1
IL_0034: ldnull
IL_0035: cgt.un
IL_0037: stloc.3
IL_0038: ldloc.3
IL_0039: brfalse.s IL_0048
IL_003b: nop
IL_003c: ldstr "hello!"
IL_0041: call void [System.Console]System.Console::WriteLine(string)
IL_0046: nop
IL_0047: nop
IL_0048: ret
} // end of method Program::'<Main>$'
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// Method begins at RVA 0x20a5
// Header size: 1
// Code size: 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method Program::.ctor
} // end of class Program
Again, confirming that all three versions produce the same IL.
Hopefully, you can now rest easy — I know I can. Goodnight!