📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do params work in modern C#, and what changed with C# 13 params collections? Explain params arrays and their hidden allocations, params ReadOnlySpan<T>, params IEnumerable<T> and List<T>, how overload resolution chooses between them, the new BCL overloads in .NET 9, and API design guidance.

params arrays versus C# 13 params collections, allocations, overload resolution and API design.

Mid-Level Theory Intermediate api-design csharp-13 params performance span

🎯 Key points

  • Classic params arrays allocate a new array per call, except for zero arguments
  • C# 13 params collections allow ReadOnlySpan, Span, IEnumerable, IReadOnlyList, List and other collection types
  • params ReadOnlySpan<T> often stores arguments on the stack with no heap allocation
  • Span overloads beat array overloads in overload resolution
  • .NET 9 added params ReadOnlySpan overloads to string.Join, Path.Combine, Task.WhenAll and more
  • Do not store span params; use a collection type if values are kept
  • Async methods cannot take span params

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. params arrays and their hidden allocations

public 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.

2. 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.

3. params IEnumerable<T>, List<T> and other collections

C# 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.

4. How overload resolution chooses

When multiple params overloads exist (e.g. a library adds params ReadOnlySpan<T> next to params T[]):

  • For expanded form calls (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).
  • Non-params overloads with exact arity (e.g. M(T a, T b)) still win over params.
  • Library authors can use [OverloadResolutionPriority] (C# 13) to steer resolution and avoid ambiguities.
  • Passing an array to methods with both overloads binds to the array overload (identity conversion) unless priorities say otherwise.

5. New BCL overloads in .NET 9

.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+.

6. API design guidance

  • For new hot-path APIs taking a variable number of inputs: use params ReadOnlySpan<T> (the method can only read and not store them — usually what you want).
  • If you need to store the values, use params T[] (the caller's array may be passed directly; copy it if you need immutability) or a collection type.
  • Keep fixed-arity overloads for the most common counts if they enable important optimisations (e.g. Log(string, object)).
  • Avoid params object[] for value-heavy APIs (boxing) — use generic overloads or interpolated string handlers.
  • When adding span overloads to an existing library, check for ambiguities and use OverloadResolutionPriority.
  • Validate null for params T[] (callers can pass null explicitly); spans can't be null (but can be empty).

7. A production example

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.

🔍 What the interviewer is checking

The interviewer checks awareness of a recent language change and its performance impact.

Green flags

  • Explains the hidden array allocation of classic params.
  • Knows C# 13 params collections and prefers params ReadOnlySpan<T> for synchronous reads.
  • Understands overload resolution and the .NET 9 BCL overloads.
  • Knows the limits: storage, async, ref structs.

Red flags

  • Unaware params allocates.
  • Uses params object[] in hot logging code.
  • Tries to store span params.

📚 Learn in depth

1. Lowering of 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.

2. params and lambdas/delegates

C# 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.

3. params with object and boxing

params 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.

4. Null handling

  • 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.

5. Interaction with 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.

Try it yourself

  1. Benchmark params int[] vs params ReadOnlySpan<int> with [MemoryDiagnoser].
  2. Decompile a params ReadOnlySpan<T> call to see the inline array.
  3. Recompile code calling Path.Combine(a, b, c) with .NET 8 vs .NET 9 targets and compare allocations.
  4. Call a params T[] method with null and observe behaviour.

💻 Code

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"));
    }
}

⚠ Common mistakes

1. params T[] in hot paths

What 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.

2. params object[] with value types

What 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.

3. Storing params spans

What 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.

4. Ignoring null for 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.

5. Creating ambiguities when adding overloads

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.

6. Mutating params arrays that alias caller data

What 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.

7. Not recompiling to get .NET 9 benefits

What happens: targeting older language versions. Why it is wrong: span overloads aren't selected. Do instead: use C# 13+ with .NET 9+.

8. Using params for required structured data

What happens: params string[] for parameters with specific meanings. Why it is wrong: positional ambiguity and weak typing. Do instead: named parameters or options records.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes params arrays. Why: Checks classic params basics.
    How to answer

    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.

  2. When: Candidate passes [a, b] to a params method. Why: params collections build on collection expressions.
    How to answer

    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.

  3. When: Candidate mentions logging overhead. Why: LoggerMessage removes params object[] allocations in logging.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Logging wrapper allocating arrays on every request
Situation
A shared logging wrapper with a `Log(string template, params object[] args)` signature was used in [all 40] microservices.
Signals
Allocation profiles in several services showed object[] and boxed integers from the wrapper among the top allocations.
Investigation
dotnet-trace allocation sampling across [three] services pointed to the wrapper on hot request paths.
Resolution
Moved hot paths to [LoggerMessage] source-generated methods and changed the wrapper's signature to params ReadOnlySpan<object?> for C# 13 callers, keeping the array overload for old binaries.
Prevention
Benchmarks in the shared library and a release note telling teams to recompile against the new version.

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.

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me