List patterns match the shape of an array, a List<T>, or anything sequence-like by writing the expected elements inside square brackets. They arrived in C# 11 and finished the pattern matching story: where property patterns describe an object's fields, list patterns describe an object's contents. This chapter covers the bracket syntax, the slice operator .., where these patterns work, where they don't, and the runtime cost of each match.
A list pattern is square brackets containing element patterns separated by commas. Each element pattern matches one slot in the sequence, in order, from left to right. The whole thing matches only when the sequence has exactly that many elements and every slot satisfies its sub-pattern.
The discard _ matches any single value, so [_, _, _] reads as "a sequence of exactly three of anything." The empty brackets [] match a sequence of length zero. None of these arms care what the values are; they only care about the length and structure.
The same patterns work inside an is expression, not only a switch.
Each slot inside the brackets is itself a pattern, which is what makes list patterns useful. Constants, type checks, property patterns, relational patterns, or another list pattern all fit. The pattern engine recurses naturally.
The first two slots use constant patterns (0 matches only the value zero). The third uses a relational pattern (> 0 matches any positive value). The bracket syntax describes the shape, and each slot can use the full pattern grammar inside.
The bracket syntax above is rigid: it pins the exact length. Real data isn't always that cooperative. A cart can have one item or twenty, and matching the first item, the last item, or both, without caring what's in between, is often what's needed. That's what the slice pattern .. is for.
The slice pattern matches any number of elements, zero or more, in one position of the list pattern. It's a single token written as two dots, and it can appear anywhere inside the brackets, but at most once per list pattern.
Each .. says "I don't care what's here or how many elements there are." The other slots still match exactly one element each, in order. The pattern [5, ..] means "first element is 5, then any number of elements." The pattern [5, .., 25] means "first element is 5, last element is 25, anything in between."
The slice can sit in any single position. At the start, at the end, in the middle, or as the whole pattern.
The last pattern is the most interesting. The slice .. covers the middle of the array, but the slot just before it is a discard _ that requires exactly one element. So the pattern reads as "starts with 1, then at least one more element, then any number of elements, then ends with 4." That makes it match any sequence of length 3 or more that starts with 1 and ends with 4.
A common point of confusion: .. is not a wildcard for one element; it's a wildcard for any number of elements, including zero. The pattern [..] matches an empty array.
Compare this to [_], which requires exactly one element. The two look similar in print but mean entirely different things.
| Pattern | Matches |
|---|---|
[] | A sequence with exactly zero elements |
[_] | A sequence with exactly one element (any value) |
[..] | A sequence of any length, including zero |
[_, ..] | A sequence with at least one element |
[_, _, ..] | A sequence with at least two elements |
The compiler turns a list pattern into property accesses on Length (or Count) plus indexer reads on the checked elements. There's no allocation, no array copy, no enumeration. A pattern like [_, .., _] reads Length once and the first and last elements; it doesn't materialize the middle of the sequence.
Matching by shape is useful, but most code wants to do something with the matched parts. List patterns support a var designation on any slot, and on the slice itself.
A var slot captures one element. A var on the slice captures the matched range as a sub-sequence.
The type of first and last is the element type of the array (int here). The type of middle is the slice's natural return type. For arrays, that's another array of the same element type. For List<T>, it's a List<T>. For Span<T>, it's another Span<T>. The compiler picks the matching type from the underlying collection.
For only the slice without the surrounding elements, the slot-only form works.
The first match captures the entire array. The second drops the first element and captures the rest. The third drops the last element and captures everything before it. The slice always captures exactly what isn't claimed by the other slots.
Capturing a slice from an array allocates a new array of the slice's length and copies the elements into it. For a Span<T>, the slice is free (it's a view into the same memory). For reading the middle of arrays in a tight loop, use Span<T> to avoid the allocation.
A captured slice can be empty if no elements fall into it. The pattern [var first, .. var rest, var last] against a two-element array gives an empty rest.
The pattern matches because the array has at least two elements (one for first, one for last). The slice claims everything between them, which happens to be nothing. The capture is an empty array, not null. That's a meaningful distinction: code that iterates rest runs zero times instead of crashing.
List patterns aren't tied to arrays. They work on any type the compiler recognizes as "indexable with a length," which covers many types. The rules are concrete: the type must have an accessible Length or Count property that returns int, an indexer that takes an int, and, for slice patterns, either a Slice(int, int) method or an indexer that takes a Range.
That covers:
| Type | Length / Count | Indexer | Slice support |
|---|---|---|---|
T[] (arrays) | Length | [int] | Yes (the compiler synthesizes it) |
List<T> | Count | [int] | Yes (via Slice extension in .NET 6+) |
string | Length | [int] | Yes (the indexer accepts Range) |
Span<T> / ReadOnlySpan<T> | Length | [int] | Yes (Slice(int, int)) |
Memory<T> / ReadOnlyMemory<T> | Length | (via Span) | Yes (through Span) |
ImmutableArray<T> | Length | [int] | Yes |
| Custom types with the right shape | If defined | If defined | If defined |
Any type with that surface area works.
The List<T> case works because List<T> exposes a Count and an int indexer. The string case works because string exposes Length and a char indexer, and the comparison elements are char literals.
A custom type needs the right surface area. A LineItemBatch that wraps an array and supports list patterns by exposing Length and an indexer:
The compiler sees Length and this[int], decides the type is list-pattern compatible, and emits the checks against those members. No interface, no attribute, no marker. The pattern works because the shape works.
To make slice patterns work on a custom type, add a Slice(int start, int length) method or an indexer that takes Range.
The list-pattern check on a custom type costs the Length access plus the indexer reads it triggers. If the indexer does additional work (logs, validates, locks), every slot in a list pattern pays that cost. Keep indexers cheap when the type participates in patterns.
List patterns don't apply to plain enumerables.
The reason is direct: IEnumerable<T> doesn't have a Length or Count (those are on richer types like ICollection<T> and the concrete collections), and it doesn't have an indexer. The only way to know how many elements it has is to enumerate it, and enumerating once may exhaust the source (for an IEnumerable<T> produced by yield return, for example). A pattern that iterated a stream without warning would be error-prone, so the language refuses to compile it.
To match an IEnumerable<T> by shape, materialize it first.
The ToArray() call costs one allocation and one pass over the source. After that, snapshot is an array and supports the full list-pattern grammar. For large or potentially infinite streams, materializing is the wrong move; fall back to Take(n) plus normal LINQ checks.
IReadOnlyCollection<T> (which has Count but no indexer) and IReadOnlyList<T> (which has both) behave differently. IReadOnlyList<T> supports list patterns; IReadOnlyCollection<T> doesn't.
| Interface | Count | Indexer | List patterns |
|---|---|---|---|
IEnumerable<T> | No | No | No |
IReadOnlyCollection<T> | Yes | No | No |
ICollection<T> | Yes | No | No |
IReadOnlyList<T> | Yes | Yes ([int]) | Yes |
IList<T> | Yes | Yes ([int]) | Yes |
For pattern matching inside a method that takes IEnumerable<T>, change the parameter to IReadOnlyList<T> (or IList<T>) at the boundary. Callers can usually supply one without extra work since most concrete collections implement both.
The full pattern grammar nests cleanly inside list patterns. Each slot can be any pattern, mixing list patterns with type patterns, property patterns, relational patterns, and even nested list patterns.
The simplest combination is a relational pattern in each slot, which describes ranges.
The first arm checks that all three values are at least 4.5. The arms are tried in order, and the first one that matches wins. The compiler emits short-circuited checks: it reads Length, then the elements only when needed.
Property patterns work inside slots. When the elements are objects, you can pattern-match on their properties.
The first slot checks that the first order has Status: "Paid". The last slot checks that the last order has Status: "Pending". The slice covers anything in the middle. Each slot is a property pattern, and the whole expression reads like prose.
Type patterns combine the same way. When the array holds a base type and the match runs on subtypes per position:
The first slot uses a type pattern (OrderPlaced) and matches only if the first element is of that type. The last slot does the same for OrderDelivered. The slice doesn't constrain the type of the intermediate events.
You can layer this further by combining a type pattern with a property pattern inside one slot, or by capturing the matched object with a var binding.
The slots use type-plus-property patterns. The var bindings expose the captured values, and the && after the pattern adds a normal Boolean check so both ends refer to the same order. List patterns compose with everything the rest of the pattern grammar offers; there's no special second grammar.
A nested list pattern is the same idea applied recursively. When the elements are themselves sequences (an array of arrays, a list of pairs), the inner shape can be matched too.
Each slot is itself a list pattern. The first slot matches an inner array of length 3. The middle slot is a discard (any single sub-array). The last slot matches a one-element inner array. The compiler emits nested length and indexer checks. This kind of nested matching is rare in everyday code, but when used, the syntax stays consistent with everything else.
The pattern [var first, .. var middle, var last] against an array breaks the array into three segments. Two of them are single elements (the head and the tail); the middle is a slice that can be empty or non-empty depending on the length. The diagram below shows what gets read and what gets captured.
The match starts with a length check, because the explicit slots fix a minimum count. For this pattern, that minimum is 2 (the head plus the tail). If the length is at least 2, the engine reads the first and last elements with the indexer and synthesizes the slice for the middle. For an array, that slice is a new array containing the middle elements. For a Span<T>, the slice is a Span<T> view into the same memory, no copy. The captures (first, middle, last) are local variables in the surrounding code, available for use after the match succeeds.
This is what the compiler does. There's no enumeration, no traversal, no allocation beyond the slice's own array (when the source is a heap array). The pattern compiles down to property accesses and indexer reads, and the cost is bounded by the number of explicit slots.
A list pattern translates to one or two property reads (Length/Count) and one indexer read per explicit slot. For a pattern without a slice capture, that's the entire cost. For a pattern with a slice capture, there's an additional cost depending on the source type.
| Source type | Slice capture allocation |
|---|---|
T[] (array) | New array of slice length, copied |
List<T> | New List<T> of slice length, copied |
string | New string of slice length, copied |
Span<T> / ReadOnlySpan<T> | None: returns a view into the same memory |
ReadOnlyMemory<T> | None: returns another ReadOnlyMemory<T> slice |
ImmutableArray<T> | New ImmutableArray<T> |
The non-capturing slice (.. with no var) doesn't allocate at all, because there's nothing to capture. The pattern [1, .., 9] reads Length, reads index 0, reads Length - 1, and that's the whole cost.
To inspect the slice without keeping it, use a Span<T> and skip the copy. The pattern grammar is identical; the only change is the source type.
middle here is a ReadOnlySpan<int> over the same memory as the original array, no allocation. This fits tight loops or perf-sensitive code that processes many sequences per second.
Span<T> isn't worth the change to save a few allocations in code that runs once. The benefit shows up in repeated work: parsing many lines, scanning many requests, processing many records. For a one-off branch in business logic, int[] or List<T> is fine, and the readability is the same.
A few list-pattern mistakes compile cleanly but don't match what's intended.
Confusing `[_]` with `[..]`. The discard _ matches exactly one element. The slice .. matches zero or more. [_] is a single-element sequence; [..] is any sequence. Writing [_] instead of [..] makes the match fail for every length except 1.
Expecting `..` alone to capture. A bare .. discards the matched range; it doesn't make the elements available afterward. To capture, use .. var name.
Using more than one slice per pattern. A list pattern can contain at most one ... The compiler reports CS8979 if two are used. Multiple slices would make the match ambiguous (the elements have no way to split between them), so the language picks one.
Trying list patterns on `IEnumerable<T>`. Covered earlier: it doesn't compile because IEnumerable<T> has no length or indexer. The fix is to materialize the sequence (.ToArray() or .ToList()) or change the parameter type to IReadOnlyList<T> or IList<T>.
Assuming a captured slice is `null` when empty. A slice that captures zero elements gives an empty collection, not null. rest.Length == 0 is true, but rest == null is false. Code that iterates the capture runs zero times.
Patterns are read in order; mind the arm sequence. Inside a switch, more specific arms must come before less specific ones, or they'll be unreachable.
The compiler emits a warning here (CS8510 or similar) saying that the [_] arm is unreachable because [..] already covered it. Put narrow patterns above broad ones.
A cart inspection routine that mixes property patterns, relational patterns, and slice captures. Each arm describes one cart shape that the system handles specially.
The switch arms cover the shapes the business cares about, in order of specificity. Empty carts first, then specific shapes (single high-value item, bulk first item, gift-cards at both ends), then a general fallback that captures and reports the structure, then the catch-all single-item case.
A few details. The first-arm [] matches the empty case; without it, the empty array would have fallen through to the catch-all single-item arm and crashed when reading first. The bulk-order arm uses a property pattern inside the first slot ({ Quantity: > 5 }) followed by a slice; the slice doesn't constrain what comes after. The large-cart arm uses five discard slots and a captured slice with a when guard, expressing "at least 10 items." The arm with gift cards at both ends uses property patterns on the first and last slots and lets the slice handle the middle.
Patterns this dense aren't right for every codebase. Two or three nested levels is the sweet spot; beyond that, a method extraction or a helper type is usually clearer. For the cases that fit, this is the most direct way to express "if the cart looks like X, do Y" without a stack of if-statements.
10 quizzes