How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
Imagine you are sorting the mail that arrives at your house. You look at each envelope. Is it a bill? Put it in one pile. Is it a letter from a friend? Another pile. Is it junk? Straight to the bin. You do not read every word. You just look at the shape and the markings and decide quickly.
That is exactly what pattern matching does in C#. You hand the computer a piece of data, and it checks the shape and the value of that data, then picks the right thing to do. No long chains of confusing if and else. Just clean, calm, readable code.
In this guide we will learn pattern matching step by step, in plain English, with small examples you can try yourself. By the end, your if ladders will turn into short, elegant lines.
What problem are we solving?
Let us look at some old-style code first. Say we want to print a friendly message based on a number score.
string GetGrade(int score)
{
if (score >= 90)
return "Excellent";
else if (score >= 70)
return "Good";
else if (score >= 40)
return "Pass";
else
return "Try again";
}This works, but it is a bit noisy. There is a lot of if, else if, and return repeated again and again. When you add more cases, it grows messy. Pattern matching gives us a cleaner way to write the same idea.
The is keyword: your first pattern
The simplest pattern uses the is keyword. It asks one question: does this data match this shape?
A very common job is checking the type of an object. In the old days you had to cast first, then check for null. Now you can do it in one step.
object box = "hello world";
// Old way
string text = box as string;
if (text != null)
{
Console.WriteLine(text.ToUpper());
}
// Pattern matching way
if (box is string message)
{
Console.WriteLine(message.ToUpper());
}Read the second one out loud: "if box is a string, call it message." If the test passes, you instantly get a new variable named message that is already the right type and is never null. This is called a declaration pattern. It checks the type and gives you a ready-to-use variable at the same time.
Checking for null and not-null
You can also use patterns to check for null in a clear way.
if (user is null)
return "No user found";
if (user is not null)
Console.WriteLine(user.Name);The words is null and is not null read like normal English. They are also safer than == because they cannot be tricked by a class that overrides the equals operator.
The switch expression: the real star
Here is where code becomes truly elegant. The switch expression lets you map an input to a result in a compact block. Let us rewrite our grade example.
string GetGrade(int score) => score switch
{
>= 90 => "Excellent",
>= 70 => "Good",
>= 40 => "Pass",
_ => "Try again"
};Look how short that is. Each line says "this pattern, then this result." The little _ at the bottom is the discard pattern. It means "anything else," like the else at the end of an if ladder. It also keeps your switch safe, because the compiler wants every possible value to be handled.
Those >= 90 and >= 70 parts are relational patterns. They compare your value against a constant using <, >, <=, or >=. The arms are checked from top to bottom, so order matters: put the most specific case first.
Combining patterns with and, or, not
You can join patterns together with logical words. This is called a logical pattern. It makes ranges and combos very easy to read.
string DescribeTemp(int celsius) => celsius switch
{
< 0 => "Freezing",
>= 0 and < 15 => "Cold",
>= 15 and < 30 => "Pleasant",
>= 30 and < 45 => "Hot",
_ => "Dangerously hot"
};The line >= 15 and < 30 reads almost like a sentence. Compare that to writing celsius >= 15 && celsius < 30 everywhere. The pattern version is calmer and harder to get wrong.
Property patterns: looking inside objects
Sometimes you do not care about the type of an object. You care about its insides, its properties. A property pattern lets you peek inside and match on the fields you care about.
Imagine a small Order record.
record Order(string Country, decimal Amount, bool IsGift);
decimal CalculateShipping(Order order) => order switch
{
{ Country: "IN", Amount: >= 500 } => 0m, // free shipping
{ Country: "IN" } => 40m,
{ IsGift: true, Amount: < 1000 } => 60m,
_ => 100m
};Read the first arm: "if the order's Country is IN and the Amount is 500 or more, shipping is free." You match several properties at once, inside curly braces, and you can even use relational patterns like >= 500 on them. This removes whole stacks of nested if statements.
You can also reach into nested objects. If Order had a Customer with a City, you could write { Customer.City: "Mumbai" } to match deep inside.
How a property pattern is checked
Steps
Receive object
Switch gets the order
Check property 1
Country == IN?
Check property 2
Amount >= 500?
Match or skip
All pass: run this arm
A quick comparison of the pattern kinds
Here is a small table so you can see the main patterns side by side. Keep this nearby while you practice.
| Pattern | What it checks | Example |
|---|---|---|
| Type / declaration | Is it this type? | obj is string s |
| Constant | Equals a fixed value | x is 0 |
| Relational | Compares with <, >, etc. | x is >= 18 |
| Logical | Joins with and, or, not | x is > 0 and < 10 |
| Property | Matches object fields | { Age: >= 18 } |
| List | Matches a sequence shape | [1, 2, ..] |
And here is when to reach for each main tool.
| Tool | Best for | Returns a value? |
|---|---|---|
is expression | A single quick check | No, gives true/false |
switch statement | Many actions, no result needed | No |
switch expression | Mapping input to one output | Yes |
List patterns: matching sequences
Since C# 11 you can match on the shape of a list or array. This is called a list pattern, and it feels almost magical the first time you use it.
int[] numbers = { 1, 2, 3 };
string Describe(int[] values) => values switch
{
[] => "Empty",
[var only] => $"One item: {only}",
[var first, .., var last] => $"Starts {first}, ends {last}",
_ => "Something else"
};The [] matches an empty list. The [var only] matches a list with exactly one item and names it only. The .. is a slice pattern, meaning "any number of items here, I do not care." So [var first, .., var last] grabs the first and last items and ignores the middle. This is wonderful for parsing commands or small data shapes.
A real example: handling shapes
Let us put several patterns together in one tidy method. Say we have different shapes and we want their area.
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
record Square(double Side) : Shape;
double Area(Shape shape) => shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle { Width: var w, Height: var h } => w * h,
Square s => s.Side * s.Side,
null => throw new ArgumentNullException(nameof(shape)),
_ => throw new ArgumentException("Unknown shape")
};This single method handles every shape clearly. Each arm matches a type and pulls out exactly the values it needs. If someone later adds a new shape and forgets a case, the _ arm protects the program from crashing in a confusing way. This is the heart of writing elegant code: each idea sits on its own line, and the reader understands it at a glance.
Shape area decision
Steps
Shape in
Circle, Rect, or Square
Match type
Pick the right arm
Extract values
Get radius or sides
Return area
Compute and return
Tips for clean, safe patterns
A few small habits keep your pattern code friendly:
- Order from specific to general. The switch checks arms top to bottom. Put the narrow cases first and the broad
_last. - Always include a catch-all with
_in a switch expression, unless the compiler can already prove every case is handled. This avoids surprise exceptions. - Do not over-pack one arm. If a pattern grows huge, it is a sign to split it or pull a helper method.
- Use
varto name parts you want to use, and_for parts you want to ignore. - Prefer
is nullover== nullfor safety.
One more note for teams: patterns are not slow. The compiler turns them into the same fast comparisons you would write by hand, and list and span patterns can even avoid extra memory work. So you get clean code and good speed.
What is new and what to remember
Pattern matching has grown a lot over the years. Here is the short history so you know what each version brought:
| C# version | Added |
|---|---|
| C# 7 | is type patterns, basic switch |
| C# 8 | switch expression, property patterns |
| C# 9 | relational and logical patterns |
| C# 11 | list and slice patterns |
| C# 14 | uses all of the above, no breaking changes |
C# 14 ships with .NET 10, which is the current Long Term Support (LTS) release. Pattern matching did not change in C# 12, 13, or 14, so everything you learned here works on the latest .NET today. Looking ahead, C# 15 in the .NET 11 preview is exploring union types, which will pair even more nicely with switch expressions in the future.
Quick recap
- Pattern matching checks the shape, type, and value of data, then acts on it, like sorting mail by its markings.
- The
iskeyword does quick checks and can give you a typed variable in one step. - The switch expression maps an input to one clean result, with
_as the catch-all. - Relational (
>= 90) and logical (and,or,not) patterns make ranges easy to read. - Property patterns peek inside objects, and can reach into nested fields.
- List patterns match the shape of arrays and sequences, with
..for "any items here." - Order arms from specific to general, always handle the leftover case, and keep each arm small.
- It is fast: the compiler turns patterns into efficient code, so you get elegance without cost.
References and further reading
- Pattern matching overview - Microsoft Learn
- Patterns: pattern matching using the is and switch expressions - C# reference
- switch expression - C# reference
- Pattern matching tutorial - A tour of C#
- C# Pattern Matching Explained (2026) - NDepend Blog
Related Posts
Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.
How to Replace Exceptions with the Result Pattern in .NET
Learn how to replace exceptions with the Result pattern in .NET for clearer, faster, and safer error handling. Simple guide with C# examples.
How to Apply Functional Programming in C#: A Beginner's Guide
Learn functional programming in C# the simple way: pure functions, immutability, records, LINQ, pattern matching, and composition with friendly examples.