List patterns: syntax, slice patterns, type requirements, slice costs and practical uses.
A list pattern (C# 11) matches a sequence by its elements: [] is empty, [var x] has exactly one element, [1, _, 3] has three with given first and last, [var first, ..] has at least one, and [.., var last] captures the last. Each element position takes any pattern (constant, relational, type, var, _). .. is a slice pattern matching zero or more elements, and at most one is allowed; .. var rest captures the slice. A type supports list patterns if it is countable (Length or Count) and indexable (an int or Index indexer); capturing a slice also needs a Range indexer or a Slice method. Arrays, List<T>, strings, Span<T> and ReadOnlySpan<T> work; IEnumerable<T> does not. Slices of spans are free, while slices of arrays, lists and strings allocate copies.
List patterns (C# 11) match a sequence by its length and elements, in is, switch statements and switch expressions: args is ["deploy", var env, ..]. They combine with every other pattern, support a discard (_) for single elements and a slice (..) for zero or more elements, which can be captured (.. var rest). I'd cover matching rules, supported types, slice allocation costs, and real uses like command parsing.
int[] numbers = [1, 2, 3];
numbers is [1, 2, 3]; // exact length and values
numbers is [_, _, _]; // any three elements
numbers is [1, ..]; // starts with 1, any length ≥ 1
numbers is [.., 3]; // ends with 3
numbers is [1, .., 3]; // starts with 1, ends with 3
numbers is [] ; // empty
numbers is [> 0, > 0, ..]; // element patterns can be any pattern
numbers is [var first, .. var middle, var last]; // capture elements and the slice
Element positions accept any pattern — constant, relational, type, property, nested list patterns.
_ matches exactly one element (any value)... matches zero or more elements; at most one slice per list pattern... var rest captures the slice; .. [> 0, ..] applies a nested list pattern to the slice.A type works with list patterns if it is:
Length or Count property returning int.Index (supports ^1) or an int indexer (the compiler uses implicit Index support).Range indexer or a Slice(int start, int length) method (implicit Range support).So: arrays, string, List<T>, Span<T>, ReadOnlySpan<T>, ImmutableArray<T>, and your own types with those members. IEnumerable<T> doesn't work (not countable/indexable). List<T> supports element matching but not slice capture (no Slice method; matching .. without capturing is fine).
Capturing a slice calls the type's range indexer or Slice:
| Type | .. var rest result |
Allocates? |
|---|---|---|
T[] |
New array (RuntimeHelpers.GetSubArray) |
Yes — copy |
string |
Substring |
Yes — new string |
Span<T> / ReadOnlySpan<T> |
Slice view | No |
ImmutableArray<T> |
New immutable array | Yes |
List<T> |
Not supported for capture | — |
ReadOnlyMemory<T> |
Slice view | No |
Unused slices (.. without var) don't allocate. In hot paths, match on spans (args.AsSpan(), line.AsSpan()) to get free slices.
Command parsing:
static Task<int> RunAsync(string[] args) => args switch
{
[] or ["help"] => ShowHelpAsync(),
["deploy", var env] => DeployAsync(env, dryRun: false),
["deploy", var env, "--dry-run"] => DeployAsync(env, dryRun: true),
["migrate", "up", .. var options] => MigrateAsync(options),
["report", var from, var to] when DateOnly.TryParse(from, out _) => ReportAsync(from, to),
_ => UnknownAsync(args)
};
Binary formats / magic numbers:
static string Detect(ReadOnlySpan<byte> header) => header switch
{
[0x89, 0x50, 0x4E, 0x47, ..] => "image/png",
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
[0x25, 0x50, 0x44, 0x46, ..] => "application/pdf", // %PDF
_ => "application/octet-stream"
};
CSV/fixed fields, routing segments, protocol messages:
var parts = line.Split(',');
if (parts is [var sku, var qty, var price] && int.TryParse(qty, out var q)) { ... }
Recursion on sequences (functional style): int Sum(ReadOnlySpan<int> s) => s is [var head, .. var tail] ? head + Sum(tail) : 0; (fine for spans; would allocate per call with arrays).
A CLI tool for deployment had nested if (args.Length > 1 && args[0] == "deploy" && ...) checks, with off-by-one bugs causing IndexOutOfRangeException for unusual argument counts. Rewriting the dispatcher as a switch expression with list patterns made every supported shape explicit, removed index arithmetic, and the discard arm produced a clear usage message. A file-upload validator used list patterns over ReadOnlySpan<byte> for magic-number detection, rejecting renamed executables before scanning.
The interviewer checks you can use list patterns correctly and know their requirements and costs.
Green flags
Red flags
IEnumerable<T>.args is ["deploy", var env]
// becomes roughly
args is not null && args.Length == 2 && args[0] == "deploy" && (env = args[1]) is var _
span is [var first, .. var rest]
// becomes
span.Length >= 1 && (first = span[0]) is var _ && (rest = span[1..]) is var _
The compiler checks length once, then indexes. ^n indexing uses Length - n. Slices call Slice(start, length) or the range indexer.
With multiple arms, the decision DAG reads Length once and branches on length ranges, then tests elements — efficient even with many patterns.
order is { Lines: [{ Quantity: > 10 }, ..] } // first line has quantity > 10
points is [(0, 0), .., (var x, var y)] // positional patterns inside list patterns
words is ["Dear", .. var middle, "Regards"]
List patterns don't match null (they imply non-null). [] matches empty but not null. Use null or [] to match both.
ReadOnlySpan<T> inputs for parsing (no allocation for slices).List<T>, use CollectionsMarshal.AsSpan(list) to get span semantics.string.Split allocates; for hot parsing use span-based splitting then list patterns over spans of ranges.[MemoryDiagnoser].IEnumerable<T> and read the error.public enum OrderStatus { Pending, Paid, Shipped }
public sealed record Order(int Id, OrderStatus Status, decimal Total);
public static class Commands
{
// Parse simple CLI commands; span input means slices are views, not copies
public static string Run(ReadOnlySpan<string> args) => args switch
{
[] => "usage: tool <command> [options]",
["help"] or ["--help"] or ["-h"] => "commands: deploy, rollback, status",
["deploy", var env] => $"deploying to {env}",
["deploy", var env, "--canary", var percent] when int.TryParse(percent, out var p) && p is > 0 and <= 100
=> $"canary {p}% to {env}",
["rollback", var env, .. var rest] => $"rolling back {env} with {rest.Length} extra option(s)",
["status", ..] => "all systems normal",
[var unknown, ..] => $"unknown command '{unknown}'",
};
public static string Review(IReadOnlyList<Order> orders) => orders switch
{
[] => "no orders",
[{ Status: OrderStatus.Pending }] => "one pending order",
[.., { Total: > 1000m } last] => $"latest order {last.Id} is high value",
[{ Status: OrderStatus.Paid }, _, ..] => "first order paid, more to review",
_ => "review manually",
};
// Byte framing: STX payload ETX, no allocation
public static bool TryReadFrame(ReadOnlySpan<byte> data, out ReadOnlySpan<byte> payload)
{
if (data is [0x02, .. var body, 0x03])
{
payload = body;
return true;
}
payload = default;
return false;
}
}
public static class Program
{
public static void Main(string[] args)
{
Console.WriteLine(Commands.Run(["deploy", "prod", "--canary", "10"]));
Console.WriteLine(Commands.Run(["rollback", "test", "--force", "--quiet"]));
Console.WriteLine(Commands.Run(args));
List<Order> orders = [new(1, OrderStatus.Paid, 50m), new(2, OrderStatus.Pending, 1500m)];
Console.WriteLine(Commands.Review(orders)); // latest order 2 is high value
ReadOnlySpan<byte> frame = [0x02, 0x41, 0x42, 0x03];
if (Commands.TryReadFrame(frame, out var payload))
Console.WriteLine(payload.Length); // 2
}
}
IEnumerable<T>What happens: sequence is [var a, ..] on a LINQ query.
Why it is wrong: not countable/indexable — compile error.
Do instead: materialise to an array/list or use spans.
What happens: .. var rest on string[] per request.
Why it is wrong: allocates a new array each time.
Do instead: match on spans or avoid capturing.
List<T>What happens: list is [_, .. var rest].
Why it is wrong: List<T> has no Slice/range indexer.
Do instead: use CollectionsMarshal.AsSpan(list) or arrays.
What happens: [.., 1, ..].
Why it is wrong: only one slice allowed.
Do instead: use Contains/IndexOf for "anywhere" searches.
What happens: expecting [] to match null.
Why it is wrong: list patterns imply non-null.
Do instead: null or [].
What happens: deeply nested list and property patterns in one arm. Why it is wrong: hard to read. Do instead: split arms or extract methods.
What happens: forcing patterns for simple args.Length == 0 checks.
Why it is wrong: no benefit.
Do instead: use patterns where shapes vary.
What happens: recursive functions slicing arrays via list patterns. Why it is wrong: O(n²) copying. Do instead: use spans or indices.
List patterns are at their best when the shape of the data is the logic: commands, routes, protocol frames and small sequences of states. They make that logic declarative and let the compiler check exhaustiveness and unreachable arms.
| Situation | Good fit? |
|---|---|
| Parsing small, fixed-shape inputs | yes |
| Byte protocol framing on spans | yes, allocation-free |
| Searching large collections | no, use loops or LINQ |
| Complex grammars | no, use a real parser |
The senior detail is cost awareness: the same pattern is free on a span and allocates on an array or string. Choosing span inputs for parsers keeps the readability without the garbage.
C# patterns now cover most data checks. There are type, constant and null patterns, relational ones like >= 100, logical and, or and not combinators, and property patterns, including extended ones like { Customer.Address.State: "NSW" }. There are also positional patterns that use Deconstruct, var and discard patterns, and list patterns like [first, .., last]. The compiler turns a switch into an efficient decision tree, warns when it isn't exhaustive, and errors on unreachable cases. I use patterns for closed sets of data shapes and polymorphism when new types keep being added.
A switch expression returns a value: status switch { Paid => "Paid", _ => "Other" }. Each arm is a pattern, an optional when guard, and an expression. There's no fall-through or break, and the first matching arm wins. The result type comes from the arms or from the target. The compiler warns with CS8509 if it isn't exhaustive, and an unmatched value throws SwitchExpressionException. Switching on a tuple like (state, trigger) gives very readable state machines. When an arm needs several statements or side effects, a classic switch statement or polymorphism is clearer.
ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.
How to tell it (STAR)
**Situation:** our [chat-ops bot] crashed on partial commands and once nearly deployed to production because of a missing argument. **Task:** I volunteered to make the parsing safe. **Action:** I replaced the index checks with a single switch expression using list patterns, one arm per valid command shape, and made the fallback a usage message. **Result:** crashes stopped, the dangerous default was gone, and adding a new command became [one line plus a test].
Last updated 30 Oct 2026.