Last Updated: May 22, 2026
A switch expression is the modern, expression-based form of switch added in C# 8. Instead of running a block of statements per case, each arm computes a value and the whole switch produces that value. You write less code, you don't worry about break, and the compiler can tell you when you've forgotten a case. This lesson covers the syntax, every pattern shape you can use inside an arm, the exhaustiveness rules, and when to use an expression versus the classic switch statement.
A switch expression takes a value on the left of the switch keyword and a list of comma-separated arms on the right. Each arm has a pattern, the => token, and a result. The whole thing is an expression, so it returns a value you can assign, return, or pass to another method.
A few details to lock in. The value being matched (status) sits before the switch keyword, not after. Every arm ends with a comma, not a semicolon. The _ in the last arm is the discard pattern, the catch-all that matches any value the earlier arms didn't claim. The whole expression ends with a semicolon, because it's an expression being assigned to a variable.
Compare that to the classic switch statement, which would have looked like this:
Same logic, twice the code, and you have to remember break on every case. The expression form removes the noise and forces every path to produce a value.
A diagram makes the structural difference clearer. The classic statement walks down cases looking for a match, then runs a block of statements per case. The expression form picks one arm based on a pattern and returns that arm's value.
The statement is an imperative branching construct. The expression is a value-producing one. That single change reshapes how you write conditional logic.
The two forms aren't interchangeable, and the choice matters for readability. Here's a clean comparison.
| Feature | Classic switch statement | Switch expression |
|---|---|---|
| Returns a value | No (runs statements) | Yes |
break required | Yes, per case | No, arms are expressions |
| Fall-through allowed | Yes (combine case labels) | No |
| Default keyword | default: | _ (discard) |
| Arm separator | None, statements end with ; | Comma between arms |
| Patterns supported | All (since C# 7) | All (since C# 8) |
| Exhaustiveness check | No | Yes (CS8509 warning) |
| Best fit | Side effects, multi-line logic | Mapping a value to a value |
| C# version | Always | C# 8+ |
The headline trade-off: use the expression form when the arms produce a value, and stick with the statement form when each case needs to do work (call methods, log, mutate state, run several lines of logic). Mixing both styles in the same codebase is fine and common.
_)The underscore on the last arm is C#'s discard pattern. It matches any value that none of the earlier arms claimed, so it plays the same role as default: in a classic switch.
A few rules. The discard arm doesn't have to be last in the source, but you should put it last because the compiler matches arms in source order. If a real arm appears after _, the compiler warns you with CS8510: The pattern is unreachable. Without a discard arm, you might also get the exhaustiveness warning we'll cover later.
Switch expressions don't add overhead compared to chains of if/else for simple constant arms. The compiler often emits the same kind of jump-table or sequential-comparison code it would for a classic switch.
The arms in the examples above all use constant patterns. The pattern is a literal value, and the arm matches when the input equals that value. Constants can be numbers, strings, bool values, null, or enum members.
There's no _ arm here. The compiler knows bool has only two possible values, and both are covered, so the switch is exhaustive without a discard. We'll come back to that idea in the exhaustiveness section.
You can also match against an enum, which reads especially cleanly:
Even though every enum member is listed, the discard arm is still a good idea here. Enums in C# can hold any integer value of their underlying type, not just the named ones, so a (OrderStatus)99 value would slip past every constant arm. The discard catches that case at runtime.
A type pattern matches when the input is of a particular type, and it can bind the value to a name you use inside the arm. This is where switch expressions start replacing chains of if (x is SomeType).
The Standard arm has no variable name because the arm doesn't need to read any properties off the matched value. The Premium p arm binds the matched object to a local named p, scoped to that arm. And the order of arms matters: if you list a base type before a derived type, the base type's arm wins and the derived one is unreachable.
A property pattern matches based on the values of an object's properties. You write the property names in braces with the values you expect, and the arm matches only if every property condition is true.
Property patterns shine when a decision depends on several fields of the same object. The arm reads like a sentence: "if the status is Shipped and the total is over 100, then...". The > 100m inside the braces is a relational pattern, which we'll cover next.
Property patterns can also recurse into nested objects:
The Address.Country syntax (extended property pattern, C# 10+) drills into the nested record. Before C# 10 you'd have written { Address: { Country: "USA" } }, which still works but is wordier.
A relational pattern matches when the value satisfies a comparison: <, <=, >, >=. You saw one inside a property pattern above. They also work as standalone patterns.
The arms are matched in order, so a 2.5 kg package fails the first two arms (it's heavier than 0.5 and 1.0) and matches the third one. This is a common shape: a tiered lookup where each bracket has an upper bound and the first matching bracket wins.
The diagram below traces how a single weight value flows through these arms.
The first arm that returns true ends the match. Once an arm matches, the rest are skipped.
Relational patterns only work with the built-in numeric types, char, and enum values. They don't work with custom comparison operators, so you can't write < someCustomerOrder directly. You'd compare a property of the custom type instead.
Three keywords combine patterns: and, or, and not. They let you build composite conditions without falling back to an if.
and is great for ranges. or is useful when several values share the same arm, and not flips the result. A short example of all three:
The or arm matches any of its listed values without repeating the rest of the pattern. The not null arm matches any non-null string that the earlier arms didn't claim. The discard catches the leftover (which here is the null case).
Precedence: not binds tightest, then and, then or. You can use parentheses when you want to be explicit about grouping, and most teams do for anything beyond two operands.
Sometimes a decision depends on two values together, not on either one alone. You can wrap them in a tuple at the switch site and pattern-match on both at once.
Each arm is a tuple of two patterns. The first slot pattern-matches the tier, the second slot pattern-matches the cart total. _ inside a tuple arm matches anything in that slot, so ("Premium", _) reads as "any Premium customer regardless of total". The final (_, _) arm is the catch-all for any combination none of the earlier arms claimed.
Tuple patterns make multi-axis dispatch much cleaner than nested if statements. Compare the version above to its if equivalent:
Both compile to similar code. The tuple form lays the matrix out vertically and makes the structure obvious.
C# 11 added list patterns for matching against arrays and lists. Here's a quick taste so you recognize the syntax.
The [] arm matches an empty array. [var only] matches a one-element array and binds the single element to only. [.., var last] uses the slice pattern .. to match any number of elements followed by a final one that gets bound to last. The Pattern Matching section covers slicing, positional patterns, and the full set of list-pattern shapes.
A switch expression has to produce a value. If the runtime value doesn't match any arm, C# throws a SwitchExpressionException. The compiler tries to prove every possible input is covered, and it warns you (CS8509) when it can't.
The fix is one of two things: add a discard arm so every leftover value has a home, or convince the compiler the input is constrained. For a plain int like above, only a discard works because int has billions of possible values.
For finite types, the compiler does the math itself. A bool has two values, both covered, so this compiles without a warning and without a discard:
For enums it's trickier. Even when you list every named member, the compiler still wants a discard, because an enum value can hold any integer of its underlying type. Adding _ => ... silences CS8509 and keeps your runtime safe from a future enum member you forget to handle.
A SwitchExpressionException at runtime is a real bug, not a warning to ignore. Always handle CS8509 either with a discard arm or by proving exhaustiveness.
The three control-flow shapes you have for branching are if-else chains, the classic switch statement, and the switch expression. The choice is mostly about what each arm produces and how complex the work is.
| Situation | Best fit |
|---|---|
| Mapping a value to a value (status to label, weight to cost) | Switch expression |
| Multi-line logic in each branch (logging, side effects, multiple statements) | Classic switch statement |
| Two or three branches that don't share a single matched value | if / else if |
| Pattern matching with binding (type checks, property checks) | Switch expression |
| Fall-through behavior across cases | Classic switch statement |
| You want the compiler to enforce exhaustiveness | Switch expression |
A practical rule: if your arms look like assignments to the same variable (or return statements from the same method), the expression form will read better. If your arms do work that doesn't produce a value, stay with the statement form.
Here's the same dispatch logic written in all three forms so the difference is visible at a glance.
Three forms, same result. The expression form is the shortest and the one most modern C# codebases prefer for this kind of mapping.
A complete example that puts several patterns together. The goal: given an order, return the right return-policy message.
This single expression replaces what would be a dozen lines of nested if statements. The arms are listed in priority order: the most specific cases first (final sale always wins), then status-based filters, then numeric tiers within the delivered status. The arms read like business rules because that's effectively what they are.
The diagram below maps each arm to the rule it represents.
Reading top to bottom matches the arm order in the code, so the diagram and the switch expression stay in lockstep.
A summary of which pattern shapes work in which version, so you can target the right C# version for your project.
| Pattern | Introduced | Example |
|---|---|---|
| Constant pattern | C# 8 (in expression) | "Shipped" => ... |
Discard _ | C# 8 | _ => ... |
| Type pattern with binding | C# 8 | Premium p => p.DiscountRate |
| Property pattern | C# 8 | { Status: "Shipped" } => ... |
| Extended property pattern | C# 10 | { Address.Country: "USA" } => ... |
| Relational pattern | C# 9 | > 100m => ... |
Logical patterns (and, or, not) | C# 9 | >= 25 and < 50 => ... |
| Tuple pattern | C# 8 | ("Premium", >= 100m) => ... |
| List pattern | C# 11 | [_, _, var third] => ... |
.NET 8 (the current LTS at the time of writing) supports every pattern in the table. If you're stuck on an older runtime, check the C# version your project targets and back off to the supported patterns.