Return home

Is it fast to check null or enum equality using the is operator in C#?

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:

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!