Pattern Matching · How it works

11 min read
Mid-level12 min read
Rapid overview

How it works

Where patterns are used

Q: What are the three places you can use a pattern in C#?

A: The is expression (returns bool, optionally declares variables), the switch statement (case <pattern>: labels, since C# 7.0) and the switch expression (C# 8, an expression that yields a value, with pattern => result arms separated by commas). Patterns also appear in when guards indirectly, since a guard is any boolean expression.

if (input is string s && s.Length > 0) Console.WriteLine(s);

switch (shape)
{
    case Circle c when c.Radius > 10: Console.WriteLine("big circle"); break;
    case Circle c:                    Console.WriteLine("circle");     break;
    case null:                        Console.WriteLine("nothing");    break;
    default:                          Console.WriteLine("other");      break;
}

double area = shape switch
{
    Circle c    => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    _           => throw new ArgumentException("Unknown shape", nameof(shape))
};
Q: How does a switch expression differ from a switch statement?

A: The switch expression produces a value, so every arm is an expression (a throw expression is allowed), there is no case, break or fall-through, the discard _ replaces default, and the compiler checks it is exhaustive. The switch statement runs statements, needs break (or return/throw) at the end of every section, and is allowed to handle only some inputs.


Type, declaration, constant and var patterns

Q: What is a declaration pattern and a type pattern?

A: A declaration pattern (C# 7.0) tests the runtime type and, on success, assigns the converted value to a new variable: obj is Customer c. A type pattern (C# 9) tests the type without declaring a variable: obj is Customer, or in a switch arm Customer => "customer". Both fail for null, because null has no runtime type.

Q: What is a constant pattern?

A: A test for equality with a compile-time constant: a number, a string, a character, true/false, an enum member or null. status is HttpStatusCode.NotFound, name is "admin", x is 0. For numeric constants the input must be convertible to the constant's type; for object inputs the check is "is it a boxed int with value 0".

Q: What is a var pattern?

A: var x always succeeds, including for null, and captures the value in a new variable. It is useful to name an intermediate result inside a larger pattern or to follow up with a when guard: case var n when n % 2 == 0:. Beware if (GetUser() is var u): it is always true, so it tests nothing.

Q: What is the discard pattern?

A: _ matches anything, including null, and binds nothing. In a switch expression it is the catch-all arm (the equivalent of default); inside positional, tuple or list patterns it means "any value here": (_, 0), [_, _, var third].


Relational and logical patterns

Q: What are relational patterns?

A: (C# 9) Comparisons against a constant: < 0, <= 10, > 100, >= 18. They work on numeric types, char and enums (for enums, against constants of the same enum), and fail for null.

string Classify(int temp) => temp switch
{
    < 0            => "freezing",
    >= 0 and < 15  => "cold",
    >= 15 and < 25 => "mild",
    _              => "hot"
};
Q: What are the logical patterns?

A: (C# 9) and (both patterns match), or (either matches) and not (the pattern does not match), plus parentheses for grouping. Precedence is not, then and, then or, the same order as !, &&, ||. c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') tests for a letter; obj is not null is the idiomatic non-null check; x is not (1 or 2) means neither 1 nor 2.

Q: Why does x is not 1 or 2 surprise people?

A: Because not binds tighter than or, it means (not 1) or 2, which is true for every value except 1 (and true for 2 anyway). The intended "neither 1 nor 2" is x is not (1 or 2). The compiler warns about some such redundant patterns, but not all.


Property and extended property patterns

Q: What is a property pattern?

A: (C# 8) { Name: pattern, Other: pattern } tests that the input is not null and that each named property or field matches its nested pattern. It can be combined with a type: order is PaidOrder { Total: > 1000, Customer: { IsVip: true } }. { } on its own means "not null" (and binds nothing unless you add a designation: { } o).

decimal Discount(Order o) => o switch
{
    { Customer.IsVip: true, Total: > 500 } => 0.15m,
    { Customer.IsVip: true }               => 0.10m,
    { Total: > 1000 }                      => 0.05m,
    _                                      => 0m
};
Q: What is an extended property pattern?

A: (C# 10) A dotted path inside a property pattern: { Customer.Address.City: "Nicosia" } instead of { Customer: { Address: { City: "Nicosia" } } }. It means the same thing: each step along the path must be non-null for the pattern to match, so a null Customer or Address simply makes the pattern fail rather than throw.


Positional, tuple and list patterns

Q: What is a positional pattern?

A: (C# 8) Type(pattern1, pattern2, ...), which calls the type's Deconstruct method and matches each output against a sub-pattern. Records with a primary constructor generate Deconstruct automatically, and you can write one yourself (instance or extension method) for any type.

public record Point(int X, int Y);

string Quadrant(Point p) => p switch
{
    (0, 0)                 => "origin",
    ( > 0, > 0)            => "first",
    ( < 0, > 0)            => "second",
    ( < 0, < 0)            => "third",
    ( > 0, < 0)            => "fourth",
    _                      => "on an axis"
};
Q: What is a tuple pattern?

A: A positional pattern applied to a tuple you build inline, used to switch on several values at once, which is the classic way to write a state machine or a decision table:

State Next(State current, Command cmd) => (current, cmd) switch
{
    (State.Closed, Command.Open)   => State.Open,
    (State.Open,   Command.Close)  => State.Closed,
    (State.Closed, Command.Lock)   => State.Locked,
    (State.Locked, Command.Unlock) => State.Closed,
    (_, _) => throw new InvalidOperationException($"Cannot {cmd} when {current}")
};
Q: What are list patterns and slice patterns?

A: (C# 11) [p1, p2, p3] matches a sequence whose length equals the number of elements and each element matches its sub-pattern. A slice .. matches zero or more elements and may appear at most once; .. var middle captures the slice. They work on any type that is countable (a Length or Count property) and indexable (an int indexer); a slice capture additionally needs a range indexer or Slice method, so arrays, List of T and spans all qualify.

string Describe(int[] values) => values switch
{
    []                  => "empty",
    [var only]          => $"one: {only}",
    [var first, .., var last] => $"from {first} to {last}",
};

bool IsCommand(string[] args) => args is ["run", _, ..];

when guards

Q: What is a when guard?

A: A boolean condition attached to a case label or switch-expression arm, evaluated after the pattern matches, with the pattern's variables in scope: case Circle c when c.Radius > 10:. Use it when the condition cannot be written as a pattern (calls a method, compares two captured variables, reads something that is not a constant). Arms are tried top to bottom, so a guarded arm must come before its unguarded fallback.

Q: Why do guards affect exhaustiveness?

A: The compiler cannot reason about arbitrary boolean expressions, so it treats a guarded arm as possibly failing. x switch { int n when n >= 0 => "non-negative", int n when n < 0 => "negative" } still warns CS8509 even though the two guards cover everything; writing it with relational patterns (>= 0 and < 0) lets the compiler prove it.


Exhaustiveness and CS8509

Q: What does CS8509 mean?

A: "The switch expression does not handle all possible values of its input type (it is not exhaustive)." The warning usually names an unmatched example value. If such a value arrives at runtime, the switch expression throws SwitchExpressionException (an InvalidOperationException). Fix it by adding the missing arms or a _ arm; many teams make CS8509 an error with <WarningsAsErrors>CS8509</WarningsAsErrors>. The related CS8524 fires for an enum switch that covers every named member but not unnamed values like (Color)42.

Q: What does CS8510 mean?

A: "The pattern has already been handled by a previous arm": an arm can never match because an earlier, broader arm already catches everything it would. It is an error, which is why _ must be last and why Shape s before Circle c does not compile.

Q: Does a switch statement check exhaustiveness?

A: No. A switch statement may legally ignore inputs, so the compiler reports nothing for a missing case. That is one practical reason to prefer the switch expression for mapping values: forgetting a case becomes a warning instead of a silent no-op.


Null checks

Q: What is the difference between x is null and x == null?

A: x is null is a constant pattern that the compiler emits as a reference comparison against null (or a HasValue check for nullable value types); it never calls a user-defined operator. x == null calls the type's overloaded operator == if there is one, which may do something unexpected: Unity's UnityEngine.Object deliberately returns true for destroyed objects, and a buggy operator can throw or return false. x is not null is the equally safe negation. For plain classes without an overloaded operator, both compile to the same check.

Q: Which patterns match null?

A: Only null itself, var x, the discard _ and not of a pattern that fails for null (such as not string). Type, declaration, property (including { }), positional, relational and list patterns all fail for null, which is what makes if (obj is Customer { IsActive: true } c) a safe one-line null check plus type check plus condition.


Memory level

Q: What does the compiler generate for a type pattern?

A: For a reference-type target, obj is Customer c becomes an isinst Customer IL instruction (a type check that returns the reference or null) followed by a null check and a store into c: no allocation, no exception, roughly the cost of as plus != null. It replaced the old if (obj is Customer) { var c = (Customer)obj; }, which checked the type twice.

IL for:  if (obj is Customer c) Use(c);

  ldarg.1              // push obj
  isinst  Customer     // obj as Customer (null if not)
  stloc.0              // c = result
  ldloc.0
  brfalse.s SKIP       // null -> pattern failed
  ldloc.0
  call    Use(Customer)
SKIP:
Q: Does pattern matching on a value type box it?

A: Not when the input is already statically a value type or a generic parameter: temp switch { < 0 => ... } on an int compiles to ordinary integer comparisons, and matching a type parameter T against int is done by the JIT per instantiation without boxing. Boxing happens only where it already happened: if the value was stored in an object or an interface variable, it was boxed when it was assigned, and obj is int n then does an isinst on the box and unboxes (copies the value out) into n, which is a copy, not an allocation.

 int 42 on the stack  ──(assign to object)──► heap box: [header | MT=Int32 | 42]
                                                         ▲
 obj is int n:  isinst Int32 on the box, then unbox.any copies 42 into n (stack)
Q: How does the compiler compile a whole switch expression?

A: It builds a decision DAG: it orders the tests so each fact (the type, a property value, a length) is evaluated once and shared between arms, reads each property once, and turns dense integer or string constant sets into jump tables or hashed lookups. So a long switch expression is typically as fast as the hand-written if chain you would otherwise write, and often faster, because it avoids repeated type checks and property reads.

Q: Do property patterns call the getter?

A: Yes, a property pattern reads the property through its getter (inlined by the JIT for trivial auto-properties). The compiler caches each read within one match, but a getter with side effects or heavy work will run, so keep patterned properties cheap and pure.


Records and closed hierarchies

Q: Why do records work well with pattern matching?

A: A positional record generates Deconstruct, so Point(0, var y) works with no extra code, and its value equality and immutability make it a natural "data case" to switch on. A common design is an abstract base record with a few derived records, each a case: abstract record Payment; record Card(string Last4) : Payment; record Cash : Payment;.

Q: Can the compiler prove a switch over a class hierarchy is exhaustive?

A: No. C# has no closed ("sealed") hierarchies in the language, so even if Payment has exactly three derived types and the base is abstract, payment switch { Card => ..., Cash => ..., Transfer => ... } still warns CS8509, because another assembly or a later subclass could add a fourth case. The usual workaround is a final _ => throw new UnreachableException() (.NET 7) arm, or making the base's constructor private with the cases nested inside it so nobody else can derive, plus an analyzer. Marking each derived type sealed stops further inheritance and lets the JIT make type checks cheaper, but it does not make the switch exhaustive.


Common interview gotchas

Q: What does this print? object o = null; Console.WriteLine(o is var x); Console.WriteLine(o is object);

A: True then False. A var pattern matches everything, including null; a type pattern fails for null.

Q: Why does case Shape s: followed by case Circle c: fail to compile?

A: The first case already matches every Circle, so the second can never be reached: error CS8120 in a switch statement (CS8510 in a switch expression). Order arms from the most specific to the most general.

Q: Is obj is not null the same as obj != null?

A: Not always. is not null never calls an overloaded != operator; != null does. For types with no overloaded operator they are the same.

Q: What happens at runtime when no arm of a switch expression matches?

A: It throws System.Runtime.CompilerServices.SwitchExpressionException, which is what CS8509 was warning you about at compile time.