params arrays versus C# 13 params collections, allocations, overload resolution and API design.
params lets callers pass a variable number of arguments without building a collection: Log("a", "b"). Before C# 13 the parameter had to be an array, so every call allocated a new array (except zero arguments, which use Array.Empty). C# 13 params collections allow any collection type that a collection expression can create: ReadOnlySpan<T>, Span<T>, IEnumerable<T>, IReadOnlyList<T>, List<T> and others. With params ReadOnlySpan<T>, the compiler can put the arguments on the stack, so calls are often allocation-free. Overload resolution prefers span overloads over array overloads when both apply, which is how .NET 9 added params ReadOnlySpan<T> overloads to string.Join, string.Concat, Path.Combine, Task.WhenAll and others so existing code got faster after recompiling.
params lets callers pass a variable number of arguments without building a collection themselves. Classic params T[] always allocates an array per call. C# 13 params collections allow params on spans and other collection types — notably params ReadOnlySpan<T>, which can put the arguments on the stack with no allocation. .NET 9 added span-based params overloads across the BCL, so existing code got faster just by recompiling. I'd cover hidden allocations, the new forms, overload resolution, and API guidance.
params arrays and their hidden allocationspublic static string JoinTags(params string[] tags) => string.Join(",", tags);
JoinTags("a", "b", "c"); // compiler: JoinTags(new[] { "a", "b", "c" }) - allocates an array EVERY call
JoinTags(); // compiler uses Array.Empty<string>() - no allocation
JoinTags(existingArray); // passes the array directly - no allocation
JoinTags(null); // passes a NULL array (not an array containing null)!
In hot paths (logging, string building, Task.WhenAll(t1, t2), Path.Combine(a, b, c)), these per-call arrays add GC pressure. With params object[], value-type arguments are also boxed.
params ReadOnlySpan<T> (C# 13)public static int Sum(params ReadOnlySpan<int> values)
{
int total = 0;
foreach (var v in values) total += v;
return total;
}
Sum(1, 2, 3); // arguments placed in a stack-allocated inline array - NO heap allocation
Sum([1, 2, 3]); // collection expression also works
Sum(array); // arrays convert to ReadOnlySpan<int>
The compiler uses an [InlineArray] buffer on the stack (or static data for constants) and passes a span over it. The method can't store the span (it's a ref struct), so it's safe.
params IEnumerable<T>, List<T> and other collectionsC# 13 allows params on any type that supports collection expressions:
params Span<T> / params ReadOnlySpan<T>.params IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T>, IList<T> — the compiler picks an implementation (often an array or List<T>).params List<T>, params HashSet<T>, params ImmutableArray<T>, custom types with [CollectionBuilder].This is mostly about flexibility (e.g. accepting a List<T> or IEnumerable<T> directly and individual arguments), while ReadOnlySpan<T> is about performance.
When multiple params overloads exist (e.g. a library adds params ReadOnlySpan<T> next to params T[]):
M(1, 2, 3)), C# 13 prefers params ReadOnlySpan<T> over params T[] — so recompiling picks the allocation-free overload.ReadOnlySpan<T> is generally preferred over Span<T>, and span over array, which is better than IEnumerable<T> ("better conversion" rules for collection expressions).M(T a, T b)) still win over params.[OverloadResolutionPriority] (C# 13) to steer resolution and avoid ambiguities..NET 9 added params ReadOnlySpan<T> overloads to many APIs, including string.Join, string.Concat, string.Format-related helpers, Path.Combine/Path.Join, Task.WhenAll/WhenAny, CancellationTokenSource.CreateLinkedTokenSource, StringBuilder.AppendJoin, Console.WriteLine formatting overloads, TextWriter.Write, ImmutableArray.Create and more. Code like Path.Combine(root, folder, file) or Task.WhenAll(t1, t2, t3) now avoids the array allocation after recompiling with C# 13+.
params ReadOnlySpan<T> (the method can only read and not store them — usually what you want).params T[] (the caller's array may be passed directly; copy it if you need immutability) or a collection type.Log(string, object)).params object[] for value-heavy APIs (boxing) — use generic overloads or interpolated string handlers.OverloadResolutionPriority.null for params T[] (callers can pass null explicitly); spans can't be null (but can be empty).A high-throughput API used Path.Combine(a, b, c), string.Concat(x, y, z, w) and Task.WhenAll(t1, t2) in per-request code, plus our own Metrics.Tag(params KeyValuePair<string, object>[] tags) helper. Upgrading to .NET 9/C# 13 removed the BCL array allocations automatically. We changed our helper to params ReadOnlySpan<KeyValuePair<string, object?>> (and later to TagList), which cut per-request allocations in the metrics path to zero.
The interviewer checks awareness of a recent language change and its performance impact.
Green flags
params ReadOnlySpan<T> for synchronous reads.Red flags
params object[] in hot logging code.params ReadOnlySpan<T>Sum(a, b, c);
// becomes roughly
<>y__InlineArray3<int> buffer = default;
InlineArrayElementRef(ref buffer, 0) = a;
InlineArrayElementRef(ref buffer, 1) = b;
InlineArrayElementRef(ref buffer, 2) = c;
Sum(InlineArrayAsReadOnlySpan(ref buffer, 3));
[InlineArray(3)] (C# 12/.NET 8) structs provide fixed-size inline buffers. For constant primitive arguments, the compiler may use static data instead.
params and lambdas/delegatesC# 12 allows params arrays in lambdas (var f = (params int[] xs) => xs.Length;), and the synthesised delegate type carries the params flag. C# 13 extends to params collections.
params with object and boxingparams object?[] args boxes value types and allocates the array. string.Format overloads with 1–3 explicit object parameters avoid the array but still box; CompositeFormat and interpolated string handlers avoid boxing entirely.
params T[]: M(null) passes a null array; M((T)null) or M(null, null) passes an array with nulls.params ReadOnlySpan<T>: passing null creates an empty span via the conversion from T[]?... (a null array converts to an empty span). Spans themselves have no null state.OverloadResolutionPriority[OverloadResolutionPriority(1)]
public static void Log(params ReadOnlySpan<object?> args) { }
public static void Log(params object?[] args) { }
The attribute raises priority within the same type when candidates are otherwise ambiguous or to prefer new overloads.
params int[] vs params ReadOnlySpan<int> with [MemoryDiagnoser].params ReadOnlySpan<T> call to see the inline array.Path.Combine(a, b, c) with .NET 8 vs .NET 9 targets and compare allocations.params T[] method with null and observe behaviour.public static class Metrics
{
// Pre-C# 13 style: every call allocates an array
public static int SumArray(params int[] values)
{
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
// C# 13: arguments can live on the stack; no heap allocation for typical calls
public static int Sum(params ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
// Stores the values, so take a collection type rather than a span
private static readonly List<string> Tags = [];
public static void AddTags(params IEnumerable<string> tags) => Tags.AddRange(tags);
// Async cannot take span params (ref struct across await); use a read-only list
public static async Task<int> SumLaterAsync(params IReadOnlyList<int> values)
{
await Task.Yield();
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
public static string TagList => string.Join(",", Tags);
}
public static class Program
{
public static async Task Main()
{
Console.WriteLine(Metrics.SumArray(1, 2, 3)); // allocates int[3]
Console.WriteLine(Metrics.Sum(1, 2, 3)); // no heap array
Console.WriteLine(Metrics.Sum([4, 5])); // collection expression also works
Console.WriteLine(Metrics.Sum()); // empty span
Metrics.AddTags("nsw", "vip");
Metrics.AddTags(["express"]);
Console.WriteLine(Metrics.TagList); // nsw,vip,express
Console.WriteLine(await Metrics.SumLaterAsync(10, 20));
// .NET 9+: binds to string.Join(string, params ReadOnlySpan<string?>) automatically
Console.WriteLine(string.Join("/", "api", "v1", "orders"));
Console.WriteLine(Path.Combine("data", "2026", "report.csv"));
}
}
params T[] in hot pathsWhat happens: helpers called per item or request with params arrays.
Why it is wrong: an array allocation per call.
Do instead: params ReadOnlySpan<T> or fixed-arity overloads.
params object[] with value typesWhat happens: logging or metrics helpers taking params object[].
Why it is wrong: boxing plus array allocation.
Do instead: generic overloads, TagList, or source-generated logging.
params spansWhat happens: trying to keep the span in a field. Why it is wrong: not allowed; spans are stack-only. Do instead: copy to an array if you must store values.
params T[]What happens: M(null) causes NRE inside.
Why it is wrong: callers can pass a null array.
Do instead: validate or treat null as empty.
What happens: adding params ReadOnlySpan<T> next to params IEnumerable<T> and others.
Why it is wrong: ambiguous calls or unexpected binding.
Do instead: use OverloadResolutionPriority and test call sites.
params arrays that alias caller dataWhat happens: method modifies the array passed via params.
Why it is wrong: if the caller passed an existing array, their data changes.
Do instead: treat params input as read-only.
What happens: targeting older language versions. Why it is wrong: span overloads aren't selected. Do instead: use C# 13+ with .NET 9+.
params for required structured dataWhat happens: params string[] for parameters with specific meanings.
Why it is wrong: positional ambiguity and weak typing.
Do instead: named parameters or options records.
C# 13 turned params from a convenience with a hidden cost into a zero-allocation API pattern. The BCL update in .NET 9 shows the strategy: add span overloads, rely on overload resolution, and let recompiled code get faster without source changes.
| API need | params type |
|---|---|
| Read arguments synchronously | params ReadOnlySpan<T> |
| Store or enumerate later | params IEnumerable<T> or IReadOnlyList<T> |
| Async method | params IReadOnlyList<T> |
| Binary compatibility with old callers | keep the params T[] overload too |
The senior detail is that changes are compile-time: binaries built earlier keep calling the array overloads, so libraries should be recompiled and multi-targeted to get the benefit.
C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.
A collection expression (C# 12) is [e1, e2, ..other]: a single syntax that creates arrays, List<T>, Span<T>, ReadOnlySpan<T>, immutable collections and more. It is target-typed: the type on the left (or the parameter type) decides what is built, so var x = [1, 2]; does not compile. Supported targets are arrays, spans, types with a collection initialiser (IEnumerable plus Add), types marked [CollectionBuilder] (such as ImmutableArray<T>), and the interfaces IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T> and IList<T>. .. spreads another collection's elements. The compiler picks efficient code: [] to an array uses Array.Empty<T>(), constant data for ReadOnlySpan<byte> lives in the assembly, a non-escaping Span<T> can avoid the heap, and known sizes pre-size lists.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
How to tell it (STAR)
**Situation:** our shared [logging wrapper] showed up in allocation profiles across many services. **Task:** as maintainer of the shared library I had to cut the cost without breaking [40] consumers. **Action:** I added a params ReadOnlySpan overload, which C# 13 callers bind to automatically, kept the old array overload for compatibility, and migrated the hottest logs to LoggerMessage. **Result:** allocations from logging fell by [70%] in the busiest service after a simple package upgrade and rebuild.
Last updated 30 Oct 2026.