AlgoMaster Logo

Pattern Matching Basics

Last Updated: May 22, 2026

Medium Priority
10 min read

Pattern matching in C# is a set of language features that let you test a value's shape, type, or contents and extract data from it in one expression. It started as a small extension to the is operator in C# 7 and grew, version by version, into a full vocabulary that covers types, properties, positional fields, ranges, and even list shapes. This lesson covers the foundations: what pattern matching replaces, how the is operator works as a pattern, the two switch forms (statement and expression), and the high-level shape of every pattern type the rest of the section will explore. The individual pattern kinds (type, property, positional, relational, logical, list) each get their own dedicated lesson; this one stays at the foundational level.

The Problem Pattern Matching Solves

Before pattern matching, C# code that needed to branch on type and extract data looked verbose and noisy. The classic shape is "check the type, cast to that type, use the value," and you wrote it for every branch by hand.

Three problems with this code, all of them small but cumulative. First, the type is named twice on every branch: once in the is check and once in the cast. Second, the cast can fail at runtime if the is check and cast drift apart during a refactor. Third, every branch needs its own local variable to hold the cast result, which clutters the body.

A more honest example shows the pain at scale. Consider a function that prices different kinds of cart line entries, and each kind needs its own field.

Every branch repeats the type name, declares a fresh local, and produces a result. Pattern matching collapses the noise into a single, declarative expression that the compiler can also check for completeness.

Same behavior, one expression. Each arm names the type, gives the value a local name, and produces a result. The shape that took twelve lines before fits in four, and refactors no longer drift between the type check and the cast.

The rest of this lesson walks through the building blocks that make this possible, starting with the is operator.

The is Operator as a Pattern

The is operator existed long before pattern matching. Originally it just tested type compatibility and returned a bool.

C# 7 extended is so the right side could be a pattern, not just a type. The most basic pattern is the declaration pattern: a type followed by a variable name. The check returns true when the value matches the type, and if it does, the variable is bound to the value with that type.

input is Product p reads as "is input a Product? If so, call it p." Inside the if, p is in scope and typed as Product, with no separate cast. The variable's scope extends until the end of the enclosing block, so you can also use it after the if if the compiler can prove it's definitely assigned.

The is not form (C# 9) is the negation, and it composes with the declaration pattern in a slightly counterintuitive way: even though the match "failed" in the false branch, when the false branch exits (here, via return), p is definitely assigned in the code that follows. This is the standard early-return shape for unwrapping a value.

Null Checks With is

Pattern matching gives you two clean ways to check for null: is null and is not null. They look like ordinary expressions, but they have a property that == doesn't: they always compare against actual null, never against an overloaded == operator.

The reason to prefer is null over == null is subtle but real. A type can overload the == operator to mean almost anything, including "return true even when the right side is a real null." is null bypasses any user-defined operator and always tests for the actual null reference. For value types, is null only compiles when the type is nullable (int?, decimal?), which is exactly the behavior you want.

The commented line wouldn't compile because decimal (without ?) can never be null. The compiler catches the mistake at build time, which is one of the small quality-of-life wins of using patterns over == null.

The switch Statement With Patterns

The switch statement is the older of the two switch forms and has been in C# since version 1. C# 7 added pattern support to its case labels, letting each case test a pattern rather than a constant value.

Each case uses a pattern (here, declaration patterns and the null pattern) instead of a constant. Every case ends with break to prevent fall-through. Order matters: the first matching case wins, even if a later case would also match.

A case clause can carry a when clause for an extra runtime condition that the pattern alone can't express.

The when clause runs only after the pattern matches and gates whether the case is actually taken. Cases are tried top-down, so the order of the two Product p cases matters: the more specific one (with the when filter) has to come first, otherwise the unfiltered one would always win.

The switch statement is fine for code that needs to do something in each branch (call a method, write to a log, mutate state). It's less ergonomic when each branch's only job is to compute a value. For that, C# 8 introduced the switch expression.

The switch Expression

A switch expression is a value-producing form of switch. The syntax is different in three important ways: the matched value comes first, the arms use => instead of case/break, and the whole thing produces a result.

Each line between the braces is an arm. An arm has the form pattern => expression, optionally with a when clause before the =>. The arms are tried in order, top to bottom, and the first matching arm's expression becomes the value of the whole switch.

The underscore _ on the last arm is the discard pattern. It matches any value (including null in some contexts) without binding a variable. By convention, _ lives at the end and serves as a default case.

Compare the two forms side by side. The statement form is for side effects:

The expression form is for computing a result:

The expression form is more compact and avoids the break boilerplate, but statements can't appear in an arm. To call a method that returns void or perform a sequence of operations, use the statement form. For producing a value from a set of patterns, the expression form is almost always cleaner.

Exhaustiveness and the Discard Pattern

A switch expression must produce a value for every possible input. If the compiler can prove that some input has no matching arm, it issues warning CS8509 ("The switch expression does not handle all possible values") and, at runtime, an unmatched input throws a SwitchExpressionException. This is the exhaustiveness check, and it's one of the main reasons switch expressions are safer than long if-else chains.

The fix is to add an arm that matches everything else. The discard pattern _ is the standard choice.

Two subtle points worth flagging. First, the discard arm matches null when the input's static type is a reference type, so Total(null) returns 0m here. If you need null to be handled separately, put an explicit null => arm above the _ arm. Second, when the input type is an enum, the compiler can sometimes prove exhaustiveness without needing _, but adding a _ arm is still a defensive habit because new enum values added later won't immediately break the switch.

This pattern (explicit null arm, then specific types, then a throw on _) is the defensive shape most production code uses. It tells the reader exactly what's expected and forces the program to fail loudly on inputs that weren't anticipated.

A Preview of the Pattern Vocabulary

C# has accumulated a rich set of pattern kinds across versions. What follows is a one-line preview of each, so you can recognize them when you see them in real code.

Type patterns (C# 7) test whether a value is of a specific type and optionally bind it to a variable. You've already seen these as the declaration pattern in this lesson.

The difference between Product p, Product (no variable), and the relationship to inheritance is where the depth lives.

Property patterns (C# 8) test the values of an object's properties.

The property pattern lets you reach into an object and assert constraints on its fields without writing the chain of . accesses yourself.

Positional patterns (C# 8) match against the values of a type's deconstructor (Deconstruct method) or the fields of a tuple.

These come up most often with records and tuples, where the positional layout is part of the type's contract.

Relational patterns (C# 9) compare a value against a constant using <, <=, >, >=.

Before C# 9, you had to write a when clause for these. Now they're first-class patterns.

Logical patterns (C# 9) combine patterns with and, or, and not.

These work with any pattern, not just relationals, and they're how you express the "X and Y" or "X or Y" conditions that used to require separate if clauses.

Extended property patterns (C# 10) let you chain property accesses inside a single pattern.

Before C# 10, you had to nest braces like { Customer: { Address: { Country: "US" } } }. The dotted form reads top-to-bottom and is one of the small ergonomic wins of newer C# versions.

List patterns (C# 11) match against the shape and contents of a collection.

List patterns work with arrays, lists, and any type that supports indexing and a Length or Count property. The slice pattern .. matches a contiguous run of elements.

How Pattern Matching Grew

Pattern matching wasn't designed in one shot. Each C# release added pieces, and the result is a vocabulary that started small and ended up covering most of what functional languages offer.

The pattern is incremental. C# 7 introduced the concept with type patterns and is. C# 8 generalized it with switch expressions and added the patterns that test object shape. C# 9 brought relational and logical patterns, which made when clauses unnecessary for simple comparisons. C# 10 added the dotted form of property patterns. C# 11 finally covered collection shapes with list patterns. Each version's additions compose with everything that came before, so a modern C# codebase mixes them freely.

Targeting a newer .NET version (.NET 8 or .NET 9 use C# 12 and C# 13 respectively) makes every pattern in this list available. Older code on .NET Framework or older .NET Core versions uses the older shapes (longer if/is/cast chains, more when clauses), which may need to be kept for compatibility.

Common Mistakes

Several common mistakes come up when using pattern matching. Each has a clean fix.

Forgetting null. A switch expression over object or any reference type has to consider null as a possible input. If you don't handle it explicitly, it falls into the _ arm by default, which may or may not be what you want. When the meaning of "null" is different from "unknown type," put an explicit null => arm first.

Non-exhaustive switch expressions. The compiler will warn (CS8509) when it can't prove every input is handled. Treating that warning as informational is dangerous; an unmatched input throws SwitchExpressionException at runtime, and the exception type tells you almost nothing about what was missed. Add a _ arm or an explicit set of arms that the compiler can verify covers the type.

Ordering pitfalls. Pattern arms are tried top-down, and the first match wins. If a broad pattern (like Product p) sits above a narrow one (like Product p when p.Price > 100m), the narrow one is unreachable.

The fix is to put the more specific arm first.

Reusing variable names across arms. Each arm has its own scope for the pattern's bound variables, so the same name can be used across arms without conflict, but the types inferred for each arm's variable must match the intent. In a switch expression that returns a single value, the compiler unifies the arms' result types; without a common type, the result is a compile error requiring explicit conversions.

Comparing `==` with `is null`. Use is null for null checks unless there's a specific reason to invoke ==. is null always tests the actual reference, ignoring any overloaded == operator, which matters in types that define equality. It's also caught at compile time when used on a non-nullable value type, a useful safety net.

Switch expressions and the is operator compile to roughly the same IL as the equivalent if-else chain. For small numbers of cases, there's no measurable difference. For large switches (dozens of arms) on integers or strings, the compiler may generate a jump table or a hash-based dispatch, which can be faster than a chain of comparisons. Pattern matching's main cost is conceptual, not runtime: it's easier to read but takes a moment to learn.