Params Collections: Beyond Arrays with params

Since C# 1.0, the params keyword has been exclusively tied to arrays. Every params call allocates an array on the heap, even for zero or one argument. C# 13 broadens params to support any collection type — including Span<T>, ReadOnlySpan<T>, List<T>, IEnumerable<T>, and custom collections. This is a meaningful performance improvement for hot paths.

The Allocation Problem

Every call to a params method allocates an array:

Example.cs
void Log(params string[] messages) { ... }

Log("Starting");         // Allocates string[1]
Log("Step 1", "Step 2"); // Allocates string[2]
Log();                   // Allocates string[0]

For methods called frequently — logging, validation, string formatting — these small allocations add up and create GC pressure.

The New Syntax

C# 13 lets you use params with spans and other collection types:

Example.cs
void Log(params ReadOnlySpan<string> messages)
{
    foreach (var message in messages)
        Console.WriteLine(message);
}

Log("Starting");         // No heap allocation — uses stack
Log("Step 1", "Step 2"); // No heap allocation
Log();                   // No allocation at all

When you use params ReadOnlySpan<T>, the compiler can stack-allocate the arguments using an inline array, completely avoiding the heap.

Supported Collection Types

The params modifier now works with:

Example.cs
// Spans — best for performance
void A(params ReadOnlySpan<int> values) { }
void B(params Span<int> values) { }

// Interfaces — best for flexibility
void C(params IEnumerable<int> values) { }
void D(params IReadOnlyList<int> values) { }

// Concrete types
void E(params List<int> values) { }
void F(params HashSet<int> values) { }

// Custom types with CollectionBuilder
void G(params ImmutableArray<int> values) { }

The compiler uses collection expressions internally to construct the collection, so any type that works with collection expressions works with params.

Migration Strategy

If you maintain a library with params array methods, you can add a span overload alongside the existing array version:

Example.cs
public class Logger
{
    // Existing — keep for binary compatibility
    public void Info(params string[] messages) => Info(messages.AsSpan());

    // New — avoids allocation for direct callers
    public void Info(params ReadOnlySpan<string> messages)
    {
        foreach (var message in messages)
            WriteMessage("INFO", message);
    }
}

Callers recompiling against the new library will automatically pick up the span overload thanks to overload resolution preferring ReadOnlySpan<T> over T[] when both are available.

Overload Resolution

When multiple params overloads exist, the compiler follows specific precedence rules. ReadOnlySpan<T> and Span<T> are preferred over arrays, and arrays are preferred over interfaces:

Example.cs
void Process(params ReadOnlySpan<int> values) { } // Preferred
void Process(params int[] values) { }              // Fallback
void Process(params IEnumerable<int> values) { }   // Last resort

This ensures existing code continues to work while new compilations benefit from the more efficient overload.

Practical Example: A Fluent Validator

Example.cs
public class Validator<T>
{
    private readonly T _value;
    private readonly List<string> _errors = [];

    public Validator(T value) => _value = value;

    public Validator<T> Must(
        Func<T, bool> predicate,
        string error,
        params ReadOnlySpan<string> details)
    {
        if (!predicate(_value))
        {
            _errors.Add(error);
            foreach (var detail in details)
                _errors.Add($"  - {detail}");
        }
        return this;
    }

    public bool IsValid => _errors.Count == 0;
}

var validator = new Validator<string>(email)
    .Must(e => e.Contains('@'), "Invalid email",
        "Must contain @ symbol",
        "Must have a domain part");

No array is allocated for the detail strings — they are passed via a stack-allocated span.

Combining with Collection Expressions

You can still pass an explicit collection to a params parameter:

Example.cs
void Render(params ReadOnlySpan<string> lines) { }

string[] cached = ["Header", "Footer"];
Render(cached);                    // Pass existing array
Render("Line 1", "Line 2");       // Pass individual args
Render([..cached, "Extra line"]);  // Use collection expression with spread

When to Adopt

For library authors, adding params ReadOnlySpan<T> overloads is a straightforward performance win that benefits all callers without breaking existing code. For application code, prefer params ReadOnlySpan<T> for new methods, especially those called in loops or hot paths. The syntax at the call site is identical — only the allocation behaviour changes.