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:
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:
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:
// 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:
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:
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
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:
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.