Pattern matching gets a lot more useful once you can ask questions like "is this price between 100 and 500?" or "is this order total over 1000 and not refunded?" without dragging a chain of && and || operators into your switch. Relational patterns (C# 9) compare a value against a constant with <, <=, >, >=, and logical patterns (and, or, not) glue them together. This lesson covers what they do, how they combine, where the boundaries are, and the small set of rules you need to keep them readable.
Before C# 9, the only way to test a numeric range inside an is expression was with a when guard:
That works, but the var t when ... clauses do nothing the pattern itself can express. The pattern matched everything, then the guard did the real check. Relational patterns let the comparison live inside the pattern:
Same result, half the noise. The compiler also understands the pattern well enough to warn you about unreachable arms, overlapping ranges, and gaps. The when version is opaque to that analysis.
The relational patterns are <, <=, >, >= (plus ==, which you've already seen as a plain constant pattern). They appear after is or as a switch arm and compare the input to the constant on the right.
The right side of a relational pattern has to be a constant: a literal, a const field, or an enum member. It can't be a variable or a property:
The error is CS0150 ("A constant value is expected"). The fix is either to declare max as const (only possible for primitives and strings) or to drop back to a plain > expression outside the pattern. Pattern matching is about decisions against a fixed shape, not arbitrary runtime comparisons.
and, or, notA single relational pattern is rarely enough. Real range checks need both ends. The logical patterns and, or, and not combine subpatterns:
The and keyword requires both subpatterns to match the same input. or requires at least one. not flips a pattern: is not null is true when the input isn't null, is not Order is true when the input isn't an Order.
Here is not int reads naturally and works even when payload is declared as object. The compiler knows the test rules out int, so inside the if block, narrowing logic still applies.
not Beats and Beats orThe three logical patterns have a fixed precedence. From tightest to loosest:
| Operator | Binds | Example |
|---|---|---|
not | Tightest | not null and not 0 reads as (not null) and (not 0) |
and | Middle | > 0 and < 100 or > 1000 reads as (> 0 and < 100) or > 1000 |
or | Loosest | Last to bind |
This matches the precedence of !, &&, || in regular C# expressions, so the rules are already familiar. Parentheses work the same way too. When in doubt, add them:
Without the parentheses, > 0 and < 5 or > 50 would parse as (> 0 and < 5) or > 50, which is a different question. Always use parentheses when mixing and with or in a non-trivial way; the few extra characters pay for themselves in code review.
Pattern matching compiles down to ordinary branches and comparisons. There's no runtime parser, no boxing, and no allocation for relational or logical patterns on primitive types. The compiler emits the same IL it would for a hand-written if-else chain, sometimes better when it spots a chance to use a jump table.
and with RelationalsRange checks are common with relational patterns. The shape is always >= low and <= high (inclusive) or > low and < high (exclusive), and you pick the endpoints to match the business rule.
The arms are ordered so each one picks up exactly the range above the previous. There's no overlap (the < excludes the upper boundary, which the next arm's >= then includes) and no gap. The discard arm _ covers anything negative.
The compiler does range analysis on these. If you write an arm that's strictly inside an earlier arm, you'll get a CS8510 warning ("The pattern is unreachable"):
The diagnostic is one of the better arguments for using relational patterns over when guards. The guard version forces the compiler to assume each arm could match anything, so you lose the unreachability analysis.
The real payoff shows up when you mix relational patterns with type and property patterns. A type pattern narrows to a specific type; a property pattern then asks a question about a member; a relational pattern does the comparison.
The second arm is doing three things at once: the type pattern Order { ... } requires payload to be an Order, the property pattern { Total: ..., Status: ... } reads the two members, and the relational pattern > 100m and < 1000m constrains Total. All of that lives inside a single switch arm. Without pattern matching, you'd write a nested if (payload is Order o && o.Total > 100m && o.Total < 1000m && o.Status == "Confirmed") chain, which loses the visual symmetry.
not null vs != nullThe not pattern is more than syntactic sugar. is not null looks like a stylistic variant of != null, but the two behave differently on user-defined types that overload == and !=.
The != operator goes through the overload, which can return anything the author wants. The is not null pattern bypasses overloads and checks the reference directly. For nullable value types and reference types where you want a true null check, is not null is the safer reading because it can't be subverted by an overload.
The C# team recommends is null and is not null for exactly this reason. They mean what they say.
Putting all the pieces together, here's a discount calculator that picks a percentage from the order total, the customer tier, and a coupon-applied flag. It uses relational, type, property, and logical patterns side by side.
Each arm reads as a sentence: "Gold customer between 50 and 500 gets 10 percent." The compiler verifies the arms don't overlap (Gold + 200 only hits the second arm, never the third), and the discard _ catches anything that fell through.
A second worked example: shipping cost from package weight. Boundary handling is the part that bites here, so the pattern is worth reading carefully.
The diagram lays out five tiers plus an invalid-weight guard. Each tier owns a half-open range so consecutive tiers don't collide. The same shape translates into code one-to-one:
The switch is exhaustive over double: every real number falls into exactly one arm, and the compiler doesn't require a _ catch-all because the > 50 and <= 0 arms together with the half-open ranges cover the full numeric domain. (For double specifically, NaN is its own oddity, see the section on edge cases below.)
when clauses haven't gone away. They're the escape hatch when a pattern can't express what you need:
The relational pattern handles the total. The when clause carries the comparison against cutoff, which is a runtime variable and therefore not legal inside a pattern. This is the canonical split: patterns for shape and constant comparisons, guards for everything else.
A working rule: if the comparison is against a literal or a const, use a pattern. If it's against a variable, a method call, or anything else evaluated at runtime, use a guard. Don't use when just because you've always used it. The pattern form gives you better compiler analysis, and the eye scans it faster.
A when clause runs after the pattern matches but before the arm is selected. If the guard returns false, the switch resumes with the next arm. There's no extra cost beyond evaluating the guard expression, but a guard that calls an expensive function will pay that cost on every matching candidate, which can be surprising.
Relational patterns only work where <, <=, >, >= already work on the language level: numeric primitives (int, long, float, double, decimal, byte, short, etc.), char, and enums. Strings are excluded, because string comparison isn't a primitive < operation in C#; it goes through String.Compare and depends on culture.
char is treated as a 16-bit integer for comparison purposes, so the relational pattern works on character codes. A character 'C' (code 67) lies between 'A' (65) and 'D' (68), so the second arm wins.
User-defined types are not supported in relational patterns even if they overload < and >. The pattern matcher recognises only built-in numeric types and enums. If you have a Money struct with operator overloads and you want range matching on it, you have to either expose a numeric Amount property and pattern-match that, or use a when guard.
The diagnostic is CS8781, "Relational patterns may not be used for a value of type ...". The fix is to pattern-match the numeric field instead:
The pattern works on the decimal inside Money, where the language understands < natively. Wrap or extract the underlying primitive whenever you want pattern matching on a domain type.
and Is Not &&A trap worth flagging early: the and, or, not keywords inside patterns are not the same as the &&, ||, ! operators that work on bool. They combine patterns, not boolean expressions.
Both lines are valid C#, and both produce true for n = 5. The difference is that the first version lives inside an is pattern, where and is a pattern combinator, and the second lives outside, where && is a regular operator. Mixing them up usually shows up as a compile error.
The compiler reports CS1525 or similar, because it expected a relational pattern continuation, not an expression operator. Inside is, you write and; outside is, you write &&. They aren't interchangeable.
A more subtle version of the same mistake is using && between two is expressions when you could have stayed inside one pattern:
The right-hand side of a type pattern can't carry a value-level relational pattern in that position; the relational pattern needs a target. The correct cleaner form is to declare the variable and continue:
The pattern reads: "is an int, capture it as n, and that same int is > 0 and < 100." The whole check is one pattern, the variable is in scope inside the if body, and the relational pattern applies to the captured int.
Relational patterns inherit the quirks of the underlying comparison operators. The two you're most likely to hit:
double.NaN doesn't compare equal or unequal to anything in the usual sense. NaN < 5, NaN > 5, and NaN == 5 are all false. A switch over double won't match any relational arm when the input is NaN:
Without the discard arm, the switch would throw SwitchExpressionException for NaN, because no arm matched and there's no fallback. When you're switching over double or float, always add a _ arm even if you think you've covered every case. The same applies to float.NaN.
decimal doesn't have a NaN, but it does have its own surprise: decimal.MinValue and decimal.MaxValue are real bounds, and comparisons are exact rather than approximate. That's actually nicer for money calculations than double, which is one reason it's the default money type in C#. Relational patterns on decimal behave exactly like the < and > operators on decimal.
For int-shaped switches, int.MinValue and int.MaxValue are the absolute bounds, and < int.MinValue or > int.MaxValue are arms the compiler will warn you about as unreachable.
A larger example. An order-routing function decides which fulfillment centre to send a package to based on weight, destination, and whether the order is express. Multiple dimensions, multiple relational checks, and a few not patterns thrown in:
The interesting arm is the international one: Region: not ("US-WEST" or "US-EAST"). It says "the region is not one of the two domestic codes." The or inside the parentheses combines two constant patterns into a single set, and not negates the whole set. Writing it as Region: not "US-WEST" and not "US-EAST" would also work, but the set form scales better when more domestic codes show up.
Notice also how the rejection arms come first. Pattern matching is top-to-bottom, so guard arms that catch invalid inputs should sit above the happy-path arms. The compiler doesn't enforce that, but readers will thank you.
A short tour of mistakes that come up in code review:
Using `&&` inside a pattern. Already covered: it doesn't work. Use and.
Forgetting that ranges are half-open. >= 100 and < 200 includes 100 and excludes 200. If you write >= 100 and < 200 next to > 200 and < 300, the value 200 falls in neither arm and you'll get a missing-case bug. Either use >= 100 and <= 200 followed by > 200, or >= 100 and < 200 followed by >= 200.
Expecting relational patterns to call your `<` overload. They don't. Relational patterns only work on built-in numeric types, char, and enums. Extract a comparable primitive first.
Using `is not null` and `!= null` interchangeably on types that overload `==`. is not null is a reference check; != goes through the overload. Prefer is not null when you mean "the reference exists."
Skipping the discard arm in a `double` switch. NaN won't match any relational arm, and the runtime will throw SwitchExpressionException. Always add _ => ....
Building unreachable arms. >= 100 => "A" followed by > 200 => "B" puts arm B inside arm A's range, so it never matches. The compiler warns (CS8510), but the warning is easy to ignore. Treat it like an error.
10 quizzes