Pattern matching in C# lets you test a value against a shape, type, or condition and bind variables in the same expression. It replaces verbose if (x is SomeType t) { ... } else if (x is OtherType o) { ... } chains with expressive, compiler-verified switch expressions. Since C# 7, patterns have expanded with every release — C# 9 added relational and logical patterns, C# 10 added extended property patterns, C# 11 added list patterns. The result is a rich vocabulary for expressing complex matching logic that reads almost like a specification.
Pattern Types Reference
object value = "Hello, World!";
// ── Type pattern — tests the runtime type ─────────────────────────────────
if (value is string s)
Console.WriteLine($"String of length {s.Length}");
// ── Constant pattern — tests equality ─────────────────────────────────────
if (value is "Hello, World!") Console.WriteLine("Exact match");
if (value is null) Console.WriteLine("Is null");
if (value is not null) Console.WriteLine("Is not null");
// ── Relational patterns — numeric comparison ───────────────────────────────
int score = 85;
string grade = score switch
{
>= 90 => "A",
>= 80 and < 90 => "B",
>= 70 and < 80 => "C",
>= 60 => "D",
_ => "F",
};
// ── Logical patterns — and, or, not ────────────────────────────────────────
bool isWeekend = DateTime.Now.DayOfWeek switch
{
DayOfWeek.Saturday or DayOfWeek.Sunday => true,
_ => false,
};
// ── Property pattern — match on object properties ─────────────────────────
var user = new User { Role = "Admin", IsActive = true };
string access = user switch
{
{ Role: "Admin", IsActive: true } => "Full access",
{ Role: "Editor", IsActive: true } => "Edit access",
{ IsActive: false } => "Account inactive",
_ => "Read only",
};
// ── Extended property pattern (C# 10) — nested properties ─────────────────
string region = order switch
{
{ ShippingAddress.Country: "US", ShippingAddress.State: "CA" } => "California",
{ ShippingAddress.Country: "US" } => "United States",
{ ShippingAddress.Country: "UK" } => "United Kingdom",
_ => "International",
};
bool and enum types, you can omit the discard arm _ if all values are explicitly handled; the compiler verifies completeness. For int and string, there are too many possible values to enumerate explicitly, so the discard arm is always required. This exhaustiveness checking is one of the major advantages over if/else if chains, which the compiler cannot verify are complete.return exception switch { NotFoundException => NotFound(), ConflictException => Conflict(), _ => StatusCode(500) };. This is cleaner than a chain of if/return statements and the compiler verifies you have not missed a case._ => "default" before { IsActive: true } => "active", the default arm always wins and the specific arm is never reached. The compiler does warn about unreachable patterns in some cases, but not all — always read your switch expressions top-to-bottom and verify the specificity order is correct.Positional and List Patterns
// ── Positional pattern — deconstruct and match ────────────────────────────
// Works with records, tuples, and types with Deconstruct methods
record Point(int X, int Y);
string quadrant = new Point(3, -2) switch
{
(> 0, > 0) => "Q1", // both positive
(< 0, > 0) => "Q2", // x negative, y positive
(< 0, < 0) => "Q3", // both negative
(> 0, < 0) => "Q4", // x positive, y negative
(0, _) => "Y-axis",
(_, 0) => "X-axis",
_ => "Origin",
};
// ── List patterns (C# 11) — match on collection structure ─────────────────
int[] numbers = { 1, 2, 3, 4, 5 };
string description = numbers switch
{
[] => "empty",
[var single] => $"one element: {single}",
[var first, .., var last] => $"starts with {first}, ends with {last}",
};
// "starts with 1, ends with 5"
// Match specific element values
bool isPrimePair = numbers switch
{
[2, 3, ..] => true, // starts with 2 and 3
_ => false,
};
// ── Guard clause with when ─────────────────────────────────────────────────
string classify = score switch
{
var s when s >= 90 && s <= 100 => "Excellent",
var s when s >= 70 => "Good",
var s when s >= 0 => "Poor",
_ => throw new ArgumentOutOfRangeException(),
};
Common Mistakes
Mistake 1 — Putting less-specific patterns before more-specific ones (unreachable arms)
❌ Wrong — discard arm matches everything, specific arms are never reached:
string result = user switch
{
_ => "default", // matches everything!
{ Role: "Admin", IsActive: true } => "Admin", // unreachable!
};
✅ Correct — most specific first, discard last.
Mistake 2 — Missing the discard arm in non-exhaustive switches (throws at runtime)
❌ Wrong — SwitchExpressionException if no arm matches:
string result = status switch { 200 => "OK", 404 => "Not Found" }; // what about 500?
✅ Correct — always include a final _ => ... arm for safety.