The params keyword used to mean one thing: "accept a variable number of arguments as an array." C# 13 lifts that restriction. A params parameter can now be any supported collection type, including ReadOnlySpan<T>, IEnumerable<T>, List<T>, and types that opt in via a collection builder. The headline benefit is performance, because params ReadOnlySpan<T> lets the compiler skip the heap allocation that params T[] always paid for.
This lesson assumes you've seen classic params T[] from the methods and arrays chapters. The focus here is what's new in C# 13, how overload resolution behaves when multiple params shapes exist side by side, and which form to use in real code.
Version note: params collections require C# 13 (.NET 9 SDK or later). If your project targets an older LangVersion, the compiler treats params Span<T> and params IEnumerable<T> as syntax errors. The classic params T[] still works everywhere.
Before C# 13, params accepted exactly one shape:
Every call with comma-separated arguments allocated a fresh decimal[] on the heap, even for tiny argument lists. C# 13 introduces this:
Same call site, same result, but the compiler now stack-allocates a small buffer for the prices and passes a ReadOnlySpan<decimal> over it. No garbage, no GC pressure.
The supported params types in C# 13 are:
| Type | Backing storage | Notes |
|---|---|---|
params T[] | Heap array | The original form, still valid |
params Span<T> | Stack buffer (usually) | Method can mutate elements |
params ReadOnlySpan<T> | Stack buffer (usually) | The fastest read-only option |
params IEnumerable<T> | Whatever the caller passes, or a synthesized list | Most flexible, least efficient |
params List<T> | Heap list | Useful when the callee wants List<T> APIs |
params HashSet<T> and others with [CollectionBuilder] | Depends on the builder | Any type that supports collection expressions |
params ReadOnlySpan<T> avoids the heap allocation that params T[] always pays. In hot paths that build log lines, format strings, or compute totals millions of times, that allocation shows up in GC traces. Switch to the span form when the call site uses inline arguments and the method only reads the values.
A ReadOnlySpan<T> looks unfamiliar at first, but it iterates like an array. You can index into it, get its length, and use a foreach loop:
What you can't do with a span: store it in a field, return it from an async method, or capture it in a lambda. Spans live on the stack, so they must not escape to places that outlive the calling frame. The compiler enforces this with the ref struct rules.
If you need to keep the values around after the method returns, copy them into a regular collection:
The span itself doesn't leave the method, but the strings it referenced do (because string is a reference type and the new List<string> holds those same references).
A params parameter does not require you to use the inline syntax. You can still pass a single collection of the right shape:
Both calls work because decimal[] implicitly converts to ReadOnlySpan<decimal>, and a span passes through directly. The params modifier is mostly about the call site, not about restricting how the method can be invoked.
For params IEnumerable<T>, you can pass any sequence, including a LINQ chain:
Inline arguments work too: ShowDiscounts(5m, 10m, 15m). The compiler synthesizes a small enumerable for you in that case.
The interesting case is when a method has multiple params overloads. The compiler ranks them using a fixed preference order. From most preferred to least preferred:
params ReadOnlySpan<T>params Span<T>params T[]List<T>, IEnumerable<T>, etc.)Consider this set of overloads:
Even though all three overloads can accept the same call, the compiler picks the span form because it's the cheapest. This is the design intent. Library authors can add a ReadOnlySpan<T> overload alongside an existing T[] overload, and existing callers automatically get the faster path on recompile.
The preference order only kicks in when more than one overload is applicable. If you pass a List<T> and there's no IEnumerable<T> overload, the call fails to compile rather than boxing or copying without notice.
Two overloads that differ only in nullability or in generic constraints can still cause ambiguity. The most common case is params object[] versus params object?[]:
The fix is to cast at the call site, or to drop one of the overloads. Most APIs keep a single params object?[] and let nullable analysis flow through.
A trickier ambiguity comes from mixing ReadOnlySpan<T> and Span<T> overloads on the same method:
The first call picks the Span<int> overload because Span<T> doesn't implicitly convert to ReadOnlySpan<T> in the wrong direction (although a Span<T> is assignable to a ReadOnlySpan<T>, the more specific overload wins when the input is exactly Span<T>). The next two calls pick ReadOnlySpan<int>. If both overloads are applicable for an inline call, the compiler prefers ReadOnlySpan<T> by the rule above. The output reflects only the read-only branch because the Span<int> overload writes and returns without printing.
Designing overloads that don't fight each other matters more than memorizing the resolution table. A practical rule: pick one of Span<T> or ReadOnlySpan<T> based on whether the method needs to mutate, and skip adding the other.
params is always the last parameter, but everything before it follows the normal rules:
You can't call this method without supplying discountPct first, even though it has a default. The compiler can't tell where the default ends and the params begins for positional arguments. Use a named argument if you want the default:
Named arguments and params work together, but you can't mix inline positional arguments with prices: named syntax on the same call.
The compiler is responsible for deciding how to materialize the arguments for params Span<T> and params ReadOnlySpan<T>. For inline calls with a fixed number of arguments, it usually emits a stackalloc buffer:
The compiler generates IL roughly equivalent to:
No heap array is allocated for the parameter list itself. The values inside still live wherever they normally would ("order" is an interned string, 1234 boxes into an object on the heap, and so on), but the spine of the argument list is on the stack.
When the argument list is large or contains a non-blittable type, the compiler may fall back to a small pooled array via System.Runtime.CompilerServices. This is a runtime choice, not a language guarantee. If you depend on the no-allocation behavior, profile with an allocation tracker rather than guessing.
Boxing still happens when value types flow into params ReadOnlySpan<object?>. The span avoids one allocation, but 1234 and true each get boxed before going in. For pure value-type APIs, prefer params ReadOnlySpan<T> with a concrete T like int or decimal to avoid both costs.
The version with params T[] always allocates the backing array on the heap, even if the array immediately goes out of scope. That's the allocation ReadOnlySpan<T> removes.
Putting the pieces together, a logger built around params ReadOnlySpan<object?> looks like this:
Output (approximate):
Three calls, three different argument counts, zero heap arrays allocated for the parameter list. The boxed value types and the StringBuilder still allocate, but the per-call overhead from params itself is gone. That's the kind of saving that compounds in a service handling millions of log lines a day.
The StringBuilder here allocates. For zero-allocation logging, look at Microsoft.Extensions.Logging source generators, which emit code with no boxing and no builder per call.
When the input is already a collection (a query result, a parsed file, an inventory feed), params T[] and params ReadOnlySpan<T> both work fine because the caller passes one collection, not many inline arguments:
Note that Product is a reference type. Mutating fields through the span is fine because the span holds references to the same objects the caller's array holds. Changes are visible after the method returns. If you replace the span elements (you can't, because it's ReadOnly), you wouldn't see those replacements on the original array, but mutating the objects themselves crosses the boundary.
If you specifically want to swap elements in place, use params Span<Product> instead of params ReadOnlySpan<Product>.
A few rules the compiler enforces:
params parameter per method. void Search(params string[] a, params int[] b) does not compile.params must be the last parameter. void Bad(params string[] keywords, int limit) does not compile.params cannot be combined with ref, out, or in on the same parameter.params Span<T> and params ReadOnlySpan<T>, the method itself cannot be async or an iterator (no yield return). The span lifetime rules forbid storing the span across an await or yield boundary.params parameter. The empty case is implied: calling with no inline arguments produces an empty collection.The first three are not new. They've applied to params T[] since C# 1. The async restriction is new in C# 13 and follows from ReadOnlySpan<T> being a ref struct.
A practical decision table:
| Need | Recommended form |
|---|---|
| New library targeting .NET 9+, read-only, hot path | params ReadOnlySpan<T> |
| New library targeting .NET 9+, method mutates elements | params Span<T> |
| Library that must work on older targets (.NET 6, 7, 8) | params T[] |
| Accept any sequence (LINQ chains, lazy iterators) | params IEnumerable<T> |
Method specifically needs List<T> APIs (Insert, RemoveAt) | params List<T> or take IEnumerable<T> and call .ToList() once |
async method or iterator | params T[] or params IEnumerable<T> |
The single biggest win is adding a params ReadOnlySpan<T> overload next to an existing params T[] overload in a hot API. Existing callers keep working, callers that recompile pick up the faster overload automatically, and you ship one breaking-change-free release.
Going the other way (replacing params T[] with params ReadOnlySpan<T> outright) is a source-breaking change for any caller that stores the parameter in a field or passes it to an async method. Add overloads, don't substitute.
10 quizzes