Using c# params span for Efficient Method Calls
Learn how to use c# params span to pass variable-length data without heap allocations, improving performance and memory usage in high-throughput methods.
The Problem with params and Heap Allocations
When you declare a method with params int[] numbers, every call creates a new array on the heap, even if you already have the data in a Span<T> or a stack-allocated buffer. For hot paths processing many calls per second, these allocations add pressure to the garbage collector and can become a measurable bottleneck. The c# params span syntax, introduced in C# 13 with .NET 9, lets you accept a variable number of arguments directly as a ReadOnlySpan<T> without forcing a heap allocation when the caller already has a span.
Consider a typical logging method:
public static void Log(string message, params object[] args) { // format and write }
Every call like Log("value: {0}", value) allocates an array for args. If you call this thousands of times per second, those small arrays quickly become garbage. The same pattern with params ReadOnlySpan<object> still allows the concise call style, but the compiler can avoid the heap allocation when the arguments are passed as a span literal or when the caller already owns a contiguous buffer.
Declaring a Method with params Span
To use c# params span, change the parameter type to ReadOnlySpan<T> and keep the params modifier:
public static double Average(params ReadOnlySpan<int> numbers) { if (numbers.IsEmpty) return 0; long sum = 0; foreach (var n in numbers) sum += n; return (double)sum / numbers.Length; }
This method can be called exactly like a traditional params array:
double avg = Average(1, 2, 3, 4);
The compiler creates an implicit ReadOnlySpan<int> from the argument list. When the arguments are literal values, the compiler often uses a stackalloc buffer or a compiler-generated static array, which may not allocate on the heap per call. When the caller passes an existing array or span, the method uses that memory directly without copying.
Calling with Arrays and Spans
The real benefit shows when you already have the data in a contiguous memory region. For example:
int[] values = [5, 10, 15, 20]; Span<int> span = values.AsSpan(); double avg1 = Average(values); // passes the array directly double avg2 = Average(span); // passes the span directly
Neither call creates a new array. The method receives a ReadOnlySpan<int> that points to the existing memory. This is impossible with params int[] because the array itself is the parameter type, and passing a span would force a copy into a new array.
You can also pass a stack-allocated buffer:
Span<int> buffer = stackalloc int[4] { 1, 2, 3, 4 }; double avg = Average(buffer);
This avoids any heap allocation entirely for the data transfer.
Practical Example: String Concatenation Helper
A common use case is building strings from a variable number of components. Here is a method that uses params ReadOnlySpan<string> to join non-empty strings with a separator:
public static string JoinNonEmpty(string separator, params ReadOnlySpan<string?> parts) { var builder = new System.Text.StringBuilder(); bool first = true; foreach (var part in parts) { if (string.IsNullOrEmpty(part)) continue; if (!first) builder.Append(separator); builder.Append(part); first = false; } return builder.ToString(); }
Callers can pass a mix of inline strings and existing collections:
string result = JoinNonEmpty(", ", "alpha", null, "gamma");
Because the parameter is a ReadOnlySpan<string?>, null entries are allowed (as with any reference type in a span). The method can iterate over the span without allocating a new array for the inline arguments.
Overload Resolution and Compatibility
When you introduce a params ReadOnlySpan<T> overload alongside an existing params T[] overload, the compiler follows specific rules. In C# 13, the span-based overload is preferred when the call site passes a span, an array, or individual arguments that can be collected into a span. This can cause unexpected behavioral changes if the existing array overload had different semantics. Test overload resolution carefully when adding a span variant to a widely used API.
For example, if you have both:
public static void Process(params int[] numbers) public static void Process(params ReadOnlySpan<int> numbers)
and you call Process(1, 2, 3), the compiler will generally choose the span overload. That is often what you want for performance, but if the array version performed extra work such as defensive copying, removing it could alter behavior. Document this change in release notes.
Runtime and Memory Cost Considerations
The core advantage of c# params span is reduced heap allocation when arguments are passed as a span or a stack-allocated buffer. In hot loops that previously allocated a new array per iteration, switching to a span can reduce garbage collection pressure. However, spans are stack-only types, which means they cannot be stored in fields, boxed, or used in asynchronous methods across an await. This restriction applies to the span variable itself, not to the underlying data. If your method needs to store the parameter beyond the method call or pass it to an iterator, you must copy the data out manually.
Another tradeoff is that ReadOnlySpan<T> cannot be used with params in methods that are iterator methods (yield return) or async methods, because those methods defer execution and need heap-allocated state machines. In such cases you are limited to the array version or you must manually convert to a collection at the start.
Calling from Async and Iterator Methods
Because Span<T> is a ref struct, it cannot be used as a parameter in async or iterator methods at all, even without params. The compiler enforces this because the span might outlive the stack frame. For example:
public static async Task<double> AverageAsync(params ReadOnlySpan<int> numbers) // Error { await Task.Delay(1); // use numbers }
This code does not compile. If you need to accept a variable number of values in an async method, you still must use a collection such as params int[] or IEnumerable<int>. The performance benefit of span does not apply in such methods.
Common Mistakes and How to Avoid Them
One mistake is assuming that params ReadOnlySpan<T> automatically avoids allocations in every scenario. When callers pass individual arguments as literals, the compiler may allocate a small stack buffer or use a static compiler-generated array, but the exact strategy is an implementation detail that can vary by compiler version and target framework. Do not rely on a specific allocation behavior unless you verify it for your own call sites. Another mistake is attempting to modify the span contents when the caller expects the original data to be unchanged. ReadOnlySpan<T> prevents writes, but if you use Span<T> with params, you can mutate the caller's data unintentionally. Only use Span<T> when you intend to modify the provided elements.
When to Prefer ReadOnlySpan Over Array
The decision between params T[] and params ReadOnlySpan<T> depends on how the method will be called. If most call sites pass inline arguments and the method runs rarely, the allocation cost is negligible. If the method is called in a tight loop with existing data in a collection or array, the span version avoids a copy and an allocation. However, if you need to pass the collected arguments to another API that requires an array (for example, string.Format with an array overload), you will have to copy the span to an array anyway, negating the benefit.
Use params ReadOnlySpan<T> when:
- The method processes the arguments internally without storing them.
- Callers commonly have the data in a span, array, or stack buffer.
- The method is on a hot path where allocations matter.
- You do not need to pass the arguments to an API requiring an array.
Stick with params T[] when:
- The method is async or an iterator.
- You need to store the arguments in a field or return them.
- Callers are external and you cannot control how they pass data.
- The extra allocation is irrelevant to performance.
Advanced Pattern: Using Span<T> for Mutating Calls
The params modifier is not limited to ReadOnlySpan<T>; you can also use Span<T>, which allows the method to modify the provided elements. This is useful for helpers that normalize or fill buffers. For example:
public static void Normalize(params Span<double> values) { double max = 0; foreach (var v in values) max = Math.Max(max, Math.Abs(v)); if (max == 0) return; for (int i = 0; i < values.Length; i++) values[i] /= max; }
A call like Normalize(buffer) works directly on the caller's memory. However, if you pass inline literals such as Normalize(1.0, 2.0), the compiler must create a writable temporary buffer. The caller does not observe changes made to those inline literals, which may be surprising. Use mutable spans only when you expect the caller to pass a dedicated buffer or array.