C# Switch Pattern Matching: Syntax and Use Cases
Learn the syntax and practical use cases of c# switch pattern matching, including type, property, and relational patterns for cleaner branching logic.
The c# switch pattern matching feature introduced with C# 7 and expanded in C# 9 enables you to express conditional logic in a more declarative way compared to traditional if-else chains. At its core, pattern matching allows a switch to test a value against a series of patterns, not just constants. This means you can match on the runtime type, on property values, or on ranges of numeric values directly within the switch statement or expression.
Old-Style Switch vs. Pattern Matching
Before pattern matching, a switch in C# was limited to comparing an integral type, a char, a string, or an enum against constant case labels. This works fine for discrete values but becomes awkward when you need to branch based on a condition, like whether a number falls within a range or whether an object is of a certain type and has a specific property.
Consider a method that returns a string describing a number. The traditional approach might look like this:
public static string DescribeNumber(object value) { if (value is int i) { if (i >= 0 && i <= 9) return "Single digit"; else if (i >= 10 && i <= 99) return "Two digits"; else return "Many digits"; } else if (value is string s && s.Length > 0) { return "Non-empty string"; } else { return "Unknown"; } }
The logic is correct, but it's dense, and the nested conditionals make it harder to scan. Pattern matching flattens this into a single switch expression:
public static string DescribeNumber(object value) => value switch { int i when i >= 0 && i <= 9 => "Single digit", int i when i >= 10 && i <= 99 => "Two digits", int i => "Many digits", string s when s.Length > 0 => "Non-empty string", _ => "Unknown" };
This is the c# switch pattern matching style that modern C# developers use. The switch keyword followed by value and an arrow => marks a switch expression. Each arm consists of a pattern, an optional when clause, and the result. The _ pattern is the discard pattern, which acts as the default case.
Type Patterns and the Discard Pattern
The type pattern is the foundation of pattern matching. It checks whether the input matches a specific type and, if it does, binds a new variable to the cast value. In the previous example, int i is a type pattern: it matches only if value is an int, and assigns the value to i for use in that arm.
One practical consequence is that you no longer need explicit cast checks before using a value. For instance:
public static string GetShapeName(object shape) => shape switch { Circle c => $"Circle with radius {c.Radius}", Rectangle r => $"Rectangle {r.Width}x{r.Height}", _ => "Unknown shape" };
The _ discard pattern catches everything that doesn't match any earlier pattern. It is the pattern-matching equivalent of the default case. Placing it first would make all other arms unreachable, so it should always be the last arm.
Property Patterns
Property patterns let you match on the value of a public property of the input. This is useful when you care about a specific state of an object rather than just its type. A common case is handling requests or commands that carry a status flag.
public static string GetStatusMessage(Order order) => order.Status switch { _ when order.IsExpedited && order.Status == OrderStatus.Shipped => "Expedited shipment on its way", OrderStatus.Pending => "Order pending", _ => "Unknown status" };
Note that the first arm uses a when guard because it combines multiple conditions: it checks the IsExpedited property and the Status property. Property patterns, however, allow you to express such checks directly on the object's shape. Here's the equivalent using a property pattern:
public static string GetStatusMessage(Order order) => order switch { { Status: OrderStatus.Shipped, IsExpedited: true } => "Expedited shipment on its way", { Status: OrderStatus.Pending } => "Order pending", { Status: OrderStatus.Shipped } => "Order shipped", _ => "Unknown status" };
The pattern { Status: OrderStatus.Shipped, IsExpedited: true } matches any object that has a Status property equal to OrderStatus.Shipped and an IsExpedited property equal to true. This is more readable because the condition appears as a structural description of the object, not as a separate when clause.
Property patterns also allow nesting. You can match nested properties without intermediate casts:
public static decimal CalculateShipping(Order order) => order switch { { Destination: { Country: "USA" } } => 10.0m, { Destination: { Country: "Canada" } } => 20.0m, _ => 50.0m };
This only works if Destination is not null and has a Country property. If Destination could be null, you'll need an explicit null check, since pattern matching does not automatically guard against null for property access.
Relational and Logical Patterns
C# 9 introduced relational patterns, which let you compare a value using <, >, <=, >=, and == within a pattern. This makes range-matching much cleaner than the when clauses we used earlier.
public static string ClassifyNumber(int number) => number switch { < 0 => "Negative", > 0 => "Positive", 0 => "Zero" };
You can also combine relational patterns with logical combinators and, or, and not:
public static string Grade(int score) => score switch { >= 90 and <= 100 => "A", >= 80 and < 90 => "B", >= 70 and < 80 => "C", < 70 => "F", _ => "Invalid score" };
The and and or patterns allow you to express compound conditions without nesting when clauses. One restriction: you cannot use a relational pattern directly on a value that is not a comparable numeric type. For example, you cannot write < 0 for a string. Relational patterns work only on types that implement the relevant comparison operators.
Using Switch Expressions as Statements
So far the examples have used switch expressions, which produce a value. But pattern matching also works with the classic switch statement form, which is useful when each arm must execute multiple statements.
public static void ProcessShape(object shape) { switch (shape) { case Circle c: Console.WriteLine($"Drawing circle of radius {c.Radius}"); break; case Rectangle r when r.Width > 0 && r.Height > 0: Console.WriteLine($"Drawing rectangle {r.Width}x{r.Height}"); break; default: Console.WriteLine("Cannot draw this shape"); break; } }
Here the case labels use patterns, and each block can contain any number of statements. This form is preferrable when you need to perform side effects or call multiple methods inside an arm.
One important difference between the statement form and the expression form is that the expression form requires that every possible input be covered by a pattern. If you omit the _ discard, the compiler will issue a warning that the switch expression may not handle all possible inputs. In the statement form, omitting default is allowed, but if no case matches, execution simply continues after the switch block.
Performance and Compiler Optimization
The C# compiler translates pattern-matching switches into an efficient jump table when the patterns are constant-based, similar to traditional switch statements. For type and property patterns, the generated code typically involves a sequence of type checks and conditional jumps. In most cases, the runtime overhead is negligible compared to the readability gains.
However, there is one situation where performance can degrade: when you match against a large number of string or type patterns. The compiler may generate a linear sequence of if checks rather than a hash-based dispatch, resulting in O(n) complexity. If you have many patterns and profiling shows that this method is a hot path, consider restructuring to use a dictionary mapping input types or constants to handlers instead.
Another point: switch expressions are evaluated eagerly. That means the expression value switch { ... } will calculate the result immediately, and every arm's result expression is only evaluated when its pattern matches. There is no short-circuit across arms. This is generally expected, but it's useful to remember when the result expressions involve method calls that may throw or have side effects.
When to Use Pattern Matching vs. Traditional Approach
Pattern matching is not always the right tool. If you only need to compare a value against a few constants, a classic switch statement is still fine. Pattern matching shines when the logic branches on types, ranges, or object properties, and when you want to avoid verbose if-else nesting.
Use c# switch pattern matching when:
- You need to test an object's runtime type and bind a variable to that typed reference.
- You need to check several properties of an object in combination.
- You want to express numeric range checks without writing comparison chains.
- You want to keep related branches in a single, readable structure.
Avoid it when the branching depends on complex compound conditions that are clearer as separate expressions, or when the number of patterns is so large that the code becomes difficult to maintain.
One common maintainability concern is exhaustiveness. Switch expressions require the compiler to know that all possible inputs are handled. If you later add a new enum member or a new subclass, the compiler will warn you if your switch expression does not cover it, provided you haven't used a blanket _ pattern. Using the _ pattern suppresses that warning, so if you want the compiler to help you catch missing cases, avoid a default discard in cases where exhaustiveness is important.
Compatibility and Language Version Dependencies
Pattern matching has evolved across C# versions. Here is a quick mapping of when each major feature became available:
| Pattern feature | C# version | Example |
|---|---|---|
| Type patterns, var patterns | C# 7.0 | case int i: |
| Switch expressions | C# 8.0 | value switch { ... } |
| Property, positional, and relational patterns | C# 9.0 | { Count: > 5 } |
| Extended property patterns, list patterns | C# 10 / 11 | [1, 2, ..] |
If you are working on a project that targets an older C# language version, you may not have access to all these syntaxes. For example, C# 8 added switch expressions, but relational patterns like > 100 are only available in C# 9. The language version is typically determined by the target framework, but you can also set <LangVersion> explicitly in the project file. Before adopting a newer pattern syntax, verify that the build environment and all team members use a compiler that supports it.
Another restriction is that pattern matching operates on the compile-time type of the input. If you have an object typed as object, pattern matching will still perform a runtime type check. But if the static type is a specific class, patterns that are impossible for that type will produce compiler warnings. For example, matching a string against an int pattern will not compile because a string can never be an int.
Handling Null in Pattern Matching
Pattern matching has a few peculiar behaviors with null. The type pattern case string s: does not match null because the null value does not have a type. The discard pattern _ matches null as well as any other value. This means you should put a null-specific pattern before a discard if you need to handle null specially.
public static string Describe(object value) => value switch { null => "Null", int i => $"Integer {i}", _ => "Other" };
Property patterns also require non-null input. If you write { Name: "Alice" } and the value is null, the pattern will not match because there is no object to access the Name property on. The compiler does not automatically insert null checks, so you must place a null guard earlier if you want to treat null distinctly.