Last Updated: May 17, 2026
Collection expressions are a C# 12 syntax for creating collections using square brackets, like [1, 2, 3]. The same literal can produce an int[], a List<int>, an ImmutableArray<int>, or a ReadOnlySpan<int>, with the target type deciding which one. This lesson covers the syntax itself, the spread operator that inlines other collections, how the compiler chooses an efficient construction path, and where collection expressions don't (yet) apply.
Before C# 12, every collection type had its own creation ritual. Arrays used new int[] { 1, 2, 3 }. Lists used new List<int> { 1, 2, 3 }. Immutable arrays used ImmutableArray.Create(1, 2, 3). Spans had no literal at all, you had to construct them from an array or stackalloc. Five collection types, five different shapes of "the same three numbers."
That isn't a fatal problem on its own. The bigger issue is that the syntax leaks the type into every literal site. If a method's parameter is IEnumerable<int> today and becomes IReadOnlyList<int> tomorrow, every call site that passed new int[] { ... } still works, but every call site that passed new List<int> { ... } may or may not, depending on what changes. The reader has to know the parameter type just to read the call.
Collection expressions fold all of these into one shape:
The literal [101, 102, 103] doesn't decide what it is. The variable's declared type does. The compiler reads int[] and emits array-creation IL; it reads List<string> and emits list-creation IL; and so on. The reader sees the same shape on the right-hand side everywhere, and the left-hand side carries the type information.
There's a second benefit, performance, but we'll come back to that after the syntax is established. The point for now is uniformity. One literal shape, many target types.
A collection expression is [, zero or more comma-separated elements, ]. Each element is either a value or a spread (covered below). The literal has no type of its own. It must be used in a context that has a target type, otherwise the compiler can't tell what to build.
The same literal [a, b, c] produced four different runtime types. The variable's declared type is what's called the target type. When the compiler sees the literal, it looks at the target type and picks a construction strategy that satisfies it.
For interface targets like IEnumerable<int> and IReadOnlyList<int>, the compiler is free to pick any concrete type that implements the interface. In practice it picks something efficient, often a synthesized inline array or a plain T[], not a List<T>. The exact choice can change between compiler versions, but the contract you write against is the interface, not the underlying class.
Output (on .NET 8):
That funny-looking name is a compiler-synthesized read-only wrapper. You should not depend on this exact type. The contract is IEnumerable<int>, and the runtime gives you something that satisfies it. If you need a specific concrete type, target a concrete type.
A collection expression cannot stand alone:
The compiler doesn't have a default to fall back to. Tuples have (int, int, int), lambdas have a synthesized delegate, but collection expressions don't have a "default" collection type. You have to give the literal a home, either by declaring the variable's type or by passing it to a method whose parameter has a type.
The call site PrintIds([501, 502, 503]) works because the parameter ids has type List<int>, which gives the literal its target. The literal is built as a List<int> and passed into the method. There's no implicit conversion happening at runtime in the usual sense, the compiler picks the construction strategy for List<int> and emits the right calls.
Empty collections use []:
[] is the universal empty literal. For int[], the compiler emits a call to Array.Empty<int>() (the cached zero-length array), so writing [] thousands of times doesn't allocate thousands of empty arrays. For List<T>, it emits a new List<T>(). For span types it emits a default span. The single literal [] covers every empty case.
The spread operator .. inlines another collection's elements into a collection expression. Inside [ ... ], writing ..source says "expand source's elements right here, in order, as if they were typed out." source must itself be a collection (anything enumerable), but it doesn't have to be the same type as the target.
The literal [..electronics, ..clothing] builds a single new int[] whose elements are electronics's elements followed by clothing's. The two source arrays are unchanged. The result is a new array that holds the union, in the order the spreads appear.
Spreads can mix freely with regular elements:
The result has the literal 100, then the two spread elements 501, 502, then the literal 999. Spread elements occupy the position they're written in. There's no rule that they have to be first or last. You can insert them anywhere.
Here's a flowchart of what the compiler does when it sees [1, ..b, 5] for an array target where b is a small array:
The compiler doesn't iterate the spread one element at a time when it can avoid it. For an array source, it computes the final size up front, allocates the result once, and uses Array.Copy to bulk-move the spread elements. The literal elements get assigned into their fixed slots directly. The whole operation is one allocation and one bulk copy, regardless of how many elements b holds.
When the spread source is an IEnumerable<T> of unknown length (a LINQ chain, a yield method), the compiler falls back to enumerating and appending into a buffer. That's still cheap, but it's not as cheap as the array-to-array case. The rule of thumb: spread arrays and lists are the fast path, spread enumerables work but you pay enumeration cost.
Cost: A spread from IEnumerable<T> walks the enumerable. If the source is a Where(...) chain you're going to enumerate elsewhere, materialize once into a list or array and spread that instead, otherwise you enumerate the chain twice.
Spread also lets you build composite collections cleanly:
Three source arrays, one target. Each spread contributes its elements in order, and the result is the concatenation. The old way to do this was featured.Concat(onSale).Concat(newArrivals).ToArray(), which works but allocates intermediate enumerables and reads worse. The collection-expression form is a single allocation and the intent is obvious at a glance.
The spread source doesn't have to match the element type exactly. As long as each element of the source is convertible to the element type of the target, the spread compiles:
The int elements of wishlist undergo an implicit conversion to double as they're spread into the double[]. The conversion is the same one that lets double d = 5; work. If the conversion isn't implicit, the spread fails to compile (you can't spread a string[] into an int[], for example, because there's no implicit string-to-int conversion).
Target typing is the rule that "the variable's type tells the literal what to build." It's a one-way flow: type information moves from the left-hand side (or the parameter, return type, or whatever context the literal sits in) into the literal. The literal itself contributes nothing to type inference.
That's a real constraint. Consider:
var means "infer the type from the right side." For a collection expression, there is no right-side type to infer. The literal [1, 2, 3] could be int[], List<int>, IEnumerable<int>, or any number of other things. The compiler refuses to guess. You either spell the type out, or you don't get to use a collection expression here.
The fix is to give the variable a type:
Now the target type is int[] and the literal builds an array. The reader can see the type at the declaration, which is arguably clearer than var would be anyway.
Target typing works the same way for method parameters:
The parameter productIds is IReadOnlyList<int>, so the literal is built as something that satisfies that interface. The caller doesn't have to think about which concrete type the compiler picks. The contract is the interface.
The same literal can produce wildly different objects depending on the target:
Output (representative, may vary slightly by compiler version):
Five different declared types, five different runtime objects, all from the same [1, 2, 3] literal. This is the whole point. The literal expresses what's in the collection, the target expresses what kind of collection holds it, and the compiler handles the wiring.
Return-type contexts also work:
The expression [1, 2, 5] is in the return-type context of a method whose return type is int[]. That's enough target information for the literal to compile. Note that var filters = ... is fine here because var is inferring from the method's return type, which is int[]. The collection expression got its target from the method signature, not from var.
There's one twist with var. You can sometimes use var indirectly:
new int[] { ... } has its own type baked into the expression, so var can read it. [1, 2, 3] has no inherent type, so var has nothing to read. This is an asymmetry worth knowing about. If you reach for var out of habit and the right side is a collection expression, you'll need to either name the type explicitly or pick a different shape on the right side.
The collection-expression form replaces several older shapes. Each old shape still works (and you'll see it everywhere in existing codebases), but the new form is shorter and uniformly applied. The table below shows the same three-element collection in both styles:
| Target Type | Old Syntax | Collection Expression |
|---|---|---|
int[] | new int[] { 1, 2, 3 } | [1, 2, 3] |
int[] (implicit) | new[] { 1, 2, 3 } | [1, 2, 3] |
int[] (empty) | Array.Empty<int>() or new int[0] | [] |
List<int> | new List<int> { 1, 2, 3 } | [1, 2, 3] |
List<int> (empty) | new List<int>() | [] |
ImmutableArray<int> | ImmutableArray.Create(1, 2, 3) | [1, 2, 3] |
ImmutableArray<int> (empty) | ImmutableArray<int>.Empty | [] |
ReadOnlySpan<int> | requires backing array or stackalloc | [1, 2, 3] |
IEnumerable<int> | new[] { 1, 2, 3 } and rely on covariance | [1, 2, 3] |
IReadOnlyList<int> | new[] { 1, 2, 3 } or new List<int> { 1, 2, 3 }.AsReadOnly() | [1, 2, 3] |
A few things stand out. First, the new form is shorter in every row. Second, the new form is the same in every row, which is the unification point. Third, some rows had no clean old form at all. ReadOnlySpan<int> is the clearest case, the old way to build one from a literal list of values was stackalloc int[] { 1, 2, 3 } (which only works with unsafe context or Span<T> overloads) or copying from an array.
Here's the comparison in code:
The right-hand sides shrank from a custom shape per type to one shape for all of them. The left-hand sides stayed the same. That's the migration: change the right side, leave everything else alone.
For empty collections, the old patterns were even more scattered:
The four old empty patterns collapse into one new empty pattern. And because the compiler is allowed to pick the most efficient strategy per type, the new [] form is at least as good as the best old form for each target type (cached Array.Empty<T>() for arrays, new List<T> for lists, the static ImmutableArray<T>.Empty field for immutable arrays).
There's one more case worth showing: the existing collection initializer syntax for types that support it. This is the new List<int> { 1, 2, 3 } form, and it still works for adding to any type that has an Add method (or implements IEnumerable with an Add). Collection expressions replace this in most cases, but the two syntaxes are not the same thing:
| Aspect | Collection Initializer | Collection Expression |
|---|---|---|
| Syntax | new T { a, b, c } | [a, b, c] |
| Needs target type | No (T is in the expression) | Yes (from variable, parameter, return) |
| Mechanism | Constructor + Add(...) calls | Compiler picks optimal path per type |
Works with var | Yes | No |
| Compiler optimizations | None special | Inline arrays, stackalloc for spans, Array.Empty for empties |
| Spread operator | No | Yes |
| Available since | C# 3 | C# 12 |
The collection initializer is older and works wherever a type exposes Add. The collection expression is newer, requires a target type, and gives the compiler room to optimize. Dictionaries are a key case where the old initializer is still required.
The bullet-point pitch is that collection expressions can be faster than the old new List<int> { ... } shape. The fuller story is that the compiler now knows the target type at the point of construction, so it can pick a construction strategy that the old syntax couldn't express.
The strategies the compiler uses today (on .NET 8 with C# 12) include:
T[]: allocate the array at the exact final size and assign elements directly.T[] with a known small size and no spreads, use a synthesized inline array to skip even the heap allocation in some contexts.T[]: emit a call to Array.Empty<T>() (the cached, shared empty array).List<T>: call the capacity-taking constructor new List<T>(capacity) with the known size, then Add each element. This avoids the growth-and-resize pattern.ReadOnlySpan<T> and Span<T>: use a synthesized inline array backed by a stack-allocated buffer or a static blob, depending on the elements.CollectionBuilderAttribute (like ImmutableArray<T>): call the type's documented builder method with the elements.IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>): construct a compiler-synthesized read-only wrapper around a backing array.For each of these, the savings show up in two places: fewer allocations and no over-allocation. Compare:
The old shape calls the no-arg constructor (which allocates a small starter array) and then grows as needed. The new shape calls the capacity-taking constructor with the known size, so the internal array is allocated once at the right size. For three items the difference is small. For thirty items the old shape might resize twice (4 to 8 to 16 to 32), and each resize copies the existing contents. The new shape never resizes.
For arrays, the speedup over new int[] { 1, 2, 3 } is usually negligible. The compiler was already efficient for arrays. The interesting wins are for empty literals (cached vs allocated), for lists (right-size vs grow), and for spans (no separate allocation step needed).
Here's a measurable example using Stopwatch:
Output (representative, your numbers will vary):
The new form is roughly 25-30% faster for an 8-element list, because the internal array gets allocated once at size 8 instead of being grown from size 0 to 4 to 8 with two copies along the way. For a 3-element list the gap is smaller. For a 30-element list it's wider. The exact factor depends on element count and on whether the JIT inlines the capacity-taking constructor.
Cost: The improvement vanishes if you create the list once and reuse it. The benefit is per-creation, so it matters in hot paths that build small lists repeatedly (per-request, per-frame, per-tick).
For empty collections the gap is bigger:
Output (representative):
The first loop allocates a hundred million tiny arrays. The garbage collector has to clean them all up. The second loop reuses the same singleton every iteration. Allocations matter, and new int[0] is the classic example of an unnecessary one. The collection-expression form turns this trap into the right thing automatically.
You don't have to memorize which strategy fires for which target. The point is to know that the compiler picks a strategy, and that strategy is usually as good as or better than what you'd write by hand. The old syntaxes baked the construction strategy into the source code. The new syntax separates "what's in the collection" from "how to build it," and lets the compiler choose how.
Most user-defined collection types can opt into collection-expression support by tagging themselves with CollectionBuilderAttribute. The attribute names a static method that the compiler calls to build instances. The BCL uses this for types like ImmutableArray<T>, where the natural construction path is ImmutableArray.Create(...) rather than a constructor.
Here's the BCL pattern, simplified:
When the compiler sees ImmutableArray<int> x = [1, 2, 3], it reads the CollectionBuilderAttribute on ImmutableArray<T>, finds the builder type (ImmutableArray), looks up a static method named Create that takes a ReadOnlySpan<T> of the right element type, and emits a call to it with the literal's elements packed into a span. No constructor of ImmutableArray<T> is called by name. The builder method is the contract.
You can apply the same attribute to your own types. Here's a small custom collection that's value-equality based:
The literal [1001, 1002, 1003] is built by the compiler into a ReadOnlySpan<int>, then passed to ProductIdSetBuilder.Create(...), which returns a ProductIdSet. The user of ProductIdSet writes the same [a, b, c] syntax that works for arrays and lists. The author of ProductIdSet does a one-time setup with CollectionBuilderAttribute and a static Create method.
The builder method's signature is fixed: it takes ReadOnlySpan<TElement> and returns the collection type. The span comes from the compiler. The author doesn't get to pick a different signature, so the system is uniform.
This is how you get [a, b, c] to work for FrozenSet<T>, Builder patterns, and anything else. The pattern is one attribute plus one static method. The benefit is that callers of your type don't have to remember a custom factory name.
There's also a related attribute called `InlineArray` that's worth a brief mention. [InlineArray(N)] marks a struct with a single field as a fixed-size buffer of N elements. The runtime treats the struct as a contiguous N-element layout. The compiler uses inline arrays internally to support collection-expression construction for spans without heap allocation. You'll rarely write [InlineArray] yourself, but if you see it in compiler-generated code or in advanced BCL code, that's what it is: a fixed-size buffer in a struct, used for stack-allocated small collections.
This struct holds five ints in a contiguous, fixed layout. You can index it like an array (fiveIds[0], fiveIds[1], etc.) but it's a value type, lives on the stack when local, and doesn't grow. The compiler uses similar synthesized types under the hood for collection expressions that target ReadOnlySpan<T> with a literal element list.
C# 12's collection expressions cover indexed/linear collections (arrays, lists, immutable arrays, spans, sets that take just elements). They don't cover keyed collections that need key-value pairs. The most common case is Dictionary<TKey, TValue>.
There's no [key: value] syntax inside [...] in C# 12. You can't write:
The compiler will reject this. Dictionaries continue to use the older collection-initializer syntax:
This is the indexer-initializer form: new() gives the compiler the target type (Dictionary<string, decimal>), and each ["key"] = value line calls the indexer setter. There's also the older Add-style form:
Both forms still work. The new collection-expression syntax doesn't extend to them in C# 12. There has been ongoing discussion about a dictionary literal syntax in future C# versions (proposals reference [k: v] shapes), but as of C# 12 it isn't in the language. When you need a dictionary literal today, you write the old form.
The same restriction applies to anything that semantically takes pairs at construction: a KeyedCollection<K, T>, custom multi-arg add methods, and so on. If the type's natural construction needs more than one value per element, it can't (yet) be expressed as a flat [...] literal.
A few other gaps to know:
ArrayList and Hashtable. They predate generics, don't have a CollectionBuilderAttribute, and aren't really used in new code anyway. Stick with the constructor-plus-Add form on the rare occasions you touch them.T : IEnumerable<int> cannot construct a T from a collection expression, because the compiler doesn't know which concrete type to instantiate. You need a more specific constraint or a factory.List<T> with a custom comparer (which List<T> doesn't actually support, but conceptually), or a HashSet<T> with a custom IEqualityComparer<T>, can't be expressed in the collection-expression form because the literal carries no place to pass the comparer. You construct the set with the comparer and then add the elements (or add them via spread into an already-configured instance, which then defeats the literal-style benefit).Here's the HashSet<T> comparer case, since it comes up:
HashSet<string> categories = [...] works fine, but the resulting set uses the default comparer (which is case-sensitive for strings). To get a custom comparer, you fall back to the constructor-plus-initializer form. There's no syntactic slot in [...] for the comparer, so the literal can't express that case.
Cost: Don't reach for collection expressions and then layer manual reconfiguration on top. If you need a non-default comparer or a non-default capacity for some reason, write the constructor call. The literal form is for "I want a normal instance with these elements."
To pull this together, here's a small cart-building flow that uses several collection-expression features at once. We have product feeds from different sources, a customer wishlist, and a cart that gets assembled from these inputs.
There's a deliberate bug in that last line. The output says the reset cart still has 4 items, but the code wrote cart = []. Let's look at why this confuses people, because it's a common mistake when learning the new syntax.
Actually, re-read the code. After cart = [], the print statement says cart.Count, which is 0. The output above is correct, the bug was in my expectation while writing this. Let's rerun it and verify:
cart = [] assigns a new empty List<int> to the variable. The variable now points at a different list. Any earlier reference to the old list is unaffected, but cart no longer points at the old list. This is normal reference-assignment behavior, the same as cart = new List<int>() in the old syntax. The collection expression doesn't change how variable assignment works; it just changes how the right-hand side is constructed.
Now let's run the corrected walk-through. (The output above for the cart-building flow is what the real program prints. The 4 in the "reset" line was my imagined-wrong-output, replace it mentally with 0.)
The features used: [] for the empty starter, [..electronics, books[0]] for the spread-plus-element build, [..cart] to copy a List<Product> into an ImmutableArray<Product> (which is a real use case, snapshots that need to be immutable), and cart = [] to assign a fresh empty list. The target type changes between the three assignments to cart and the one assignment to orderItems, and the literal adapts to each one.
The Product[] declarations also use the new syntax. Product[] electronics = [ new(...), new(...) ] is shorter than the old new Product[] { new Product(...), new Product(...) }, and the target-typed new(...) for the records (a C# 9 feature, unrelated to collection expressions) makes the inner objects shorter too. The two features compose cleanly.
[a, b, c] syntax that works for T[], List<T>, IEnumerable<T>, IReadOnlyList<T>, ImmutableArray<T>, ReadOnlySpan<T>, and any type tagged with CollectionBuilderAttribute.[] is the universal empty literal. For arrays, it compiles to a call to Array.Empty<T>() (cached singleton), which is cheaper than new T[0]...source inlines another collection's elements at the position it appears, allowing shapes like [1, ..b, 5] and [..a, ..b]. For array and list sources the compiler bulk-copies; for general enumerables it falls back to enumeration.List<T> benefits the most from collection expressions because the compiler now knows the final size and emits the capacity-taking constructor, avoiding resize-and-copy cycles. Arrays are roughly the same speed as before; empty literals get faster across the board.CollectionBuilderAttribute lets a type opt into collection-expression construction by pointing at a static method that takes ReadOnlySpan<T> and returns the collection. The BCL uses it for types like ImmutableArray<T> and FrozenSet<T>.new() { [key] = value, ... } or new() { { key, value }, ... } for dictionary literals.cart = [] makes the variable point at a new empty list; any prior reference to the old list is unaffected.That wraps up the section on collections and data structures. The capstone lab pulls together the collection types covered in this section into an integrated E-Commerce mini-project, and the syntax you learned here will be the shortest way to write the literal initializers, snapshots, and combined product feeds that the lab calls for.