AlgoMaster Logo

params Keyword

Last Updated: May 22, 2026

Medium Priority
11 min read

The params keyword lets a caller pass a variable number of arguments to a method without bundling them into an array first. It's the reason you can write Console.WriteLine("a", "b", "c") style calls in the BCL and have them feel natural. This lesson covers the syntax, the rules the compiler enforces, the modern params ReadOnlySpan<T> form added in C# 13, and the subtle traps around overload resolution and accidental array-as-single-arg calls.

Basic Syntax

A params parameter is declared like any other parameter, with the params modifier in front and an array (or span) type. The caller can then pass zero or more comma-separated values, and the compiler bundles them into an array on your behalf.

Inside the method body, lines is a regular string[]. The params modifier only affects how the method is called, not how it behaves once you're inside it. You index it, iterate it, check its Length, and otherwise treat it like any other array.

The same method works with a different number of arguments:

One method, three different argument counts, all valid. That's the entire point of params.

The compiler is doing real work here. When you write PrintReceipt("a", "b", "c"), the emitted IL is equivalent to PrintReceipt(new string[] { "a", "b", "c" }). The convenience is at the call site only; the runtime sees an array either way.

The Two Calling Forms

A params method can be called in two interchangeable ways: pass the items individually, or pass a pre-built array. Both are valid and both produce the same result.

The two calls compile down to the same thing. Form 1 has the compiler call new string[] { "Apple", "Banana", "Cherry" } internally. Form 2 passes the existing array as is, without allocating a new one.

When should you prefer one over the other? Pass items individually when you have a small, fixed set known at compile time. Pass an array when you already have one (it came from elsewhere in your code) and you'd be wrapping it in a new array for no reason.

The Empty Case

A call with zero arguments is valid. The method receives a zero-length array, not null. This is easy to forget and easy to get wrong if you write defensive null checks where they don't belong.

The compiler emits new string[0] (or its modern equivalent, often a shared empty-array singleton) for the zero-argument call. Your method body can iterate it, check .Length, or call LINQ on it without any null check.

You can still pass null explicitly if you want to, but you have to be deliberate about it:

That third call passes a real null string[]. The fourth call is sneaky: by casting null to string (not string[]), you're saying "one argument, of type string, which happens to be null." The compiler builds a one-element array { null }.

The second call, PrintReceipt(null), is a known gotcha. The compiler sees a bare null and prefers to interpret it as the array itself rather than wrap it in a one-element array, so lines ends up null. If you actually wanted a one-element array containing a null string, the explicit cast (string)null is required.

This is why most params methods that care about correctness either iterate safely (a foreach over a null array throws, but lines.Length on a null throws too) or accept the empty array contract and write the code to handle zero items naturally.

The Rules

The compiler enforces a few rules on params to keep overload resolution and call-site parsing unambiguous.

1. It must be the last parameter.

Anything after a params parameter would be unreachable from the variable-argument form, because the compiler can't tell where the params list ends and the next argument begins.

2. Only one `params` parameter per method.

3. It must be a single-dimensional array (or, since C# 13, certain span types).

Jagged arrays (string[][]) are technically one-dimensional arrays whose elements happen to be arrays, but the compiler is strict here and rejects them as params element types in older versions. In practice the rule you remember is: params T[] only.

4. Required parameters come before `params`, but optional ones don't mix well.

Combining params with optional parameters (parameters with default values) is technically allowed but rarely a good idea. The reader can't tell at the call site whether a value is the optional argument or the first of the params list. Pick one tool per method.

params ReadOnlySpan<T> (C# 13)

C# 13 added a modern alternative: params can now take a ReadOnlySpan<T> (and a few related types) instead of an array. The call site looks identical, but the compiler is allowed to avoid the array allocation entirely. For hot paths this matters; for everyday code the difference is invisible.

A quick refresher: ReadOnlySpan<T> is a stack-allocated, read-only view over a contiguous block of memory. For this lesson, treat it as "an array-like thing the compiler can build without heap allocation."

The call site is byte-for-byte identical to the array version. The difference is what the compiler emits: for the span version, it can stash the three strings in a stack-allocated buffer and pass a span over them, with no heap garbage. The method body iterates and indexes the span the same way it would an array.

Each call to a params T[] method allocates a fresh array on the heap, even if the call passes zero arguments (the modern compiler caches Array.Empty<T>() for that one case, but every non-empty call still allocates). A params ReadOnlySpan<T> method avoids that allocation when the compiler can use a stack buffer. In a hot loop that calls a logging or formatting helper millions of times, this is the difference between visible GC pressure and zero allocations.

A few constraints apply. A ReadOnlySpan<T> is a ref struct, which means it can't be stored on the heap, returned from async methods, or captured in lambdas. So your method body can iterate and read it, but it can't, for example, save the span to a field for later use. If you need to keep the values around, copy them to a real array inside the method: string[] copy = lines.ToArray(); (which re-introduces an allocation, but only when you actually need persistence).

Use params ReadOnlySpan<T> when:

  • The method is on a hot path (logging, formatting, parsing).
  • The method only reads the arguments and doesn't need to store them.
  • You're targeting .NET 9 / C# 13 or later.

Stick with params T[] when:

  • You need to keep the values past the method's return (store in a field, return them, capture in a closure).
  • You're on an older language version.
  • The method is called occasionally, not in a loop, and the allocation doesn't show up in a profile.

Real BCL Examples

The BCL uses params in places you've already been using without thinking about it. Looking at a few of those signatures makes the pattern click.

Output (on a system using `/` as the path separator):

Each of these methods has a params parameter, which is why you can call them with any number of arguments. string.Format and Console.WriteLine accept params object?[] (a question mark on the element type since C# 8 to indicate the elements can be null). string.Join has several overloads, including a params string?[] one. Path.Combine uses params string[] to let you build a path from any number of segments.

In .NET 9, several of these have been augmented with params ReadOnlySpan<T> overloads. When the compiler resolves your call, it picks the span version when available, so the same source code now allocates less without you changing anything.

You don't have to think about which overload wins; the compiler picks the more efficient one when both are visible.

Overload Resolution and params

This is where params gets interesting. A method without params is preferred over one with params when both fit. The rule is: the compiler picks the "more specific" overload, and a non-params signature is more specific than a params one.

This connects to method overloading. Recall that when multiple overloads are callable, the compiler ranks them by how exactly the arguments match.

Walk through each call. Process(1) could match either overload (the single-int version, or the params version with one argument). The compiler picks the non-params one because it's more specific. Process(1, 2, 3) only matches the params overload, so that's the one chosen. Process(new int[] { 1 }) matches the params overload directly (passing an array as the array parameter), so the params version wins.

The takeaway: if you add a params overload to a class, existing callers that pass a single argument continue to bind to the more specific overload (if one exists), but their behavior could change if you remove the specific overload later.

A more realistic example with a cart:

One argument, specific overload wins. Three arguments, only the params overload matches.

The "specific is preferred" rule is one reason string.Format has so many overloads. The BCL provides string.Format(string format, object? arg0), string.Format(string format, object? arg0, object? arg1), and string.Format(string format, object? arg0, object? arg1, object? arg2) in addition to the params version, so common calls with one, two, or three arguments avoid the array allocation. The params overload is the fallback for four or more.

Comparison: params T[] vs params ReadOnlySpan<T> vs IEnumerable<T>

When you need a method that accepts a variable number of values, you've got three shapes to choose from. The right pick depends on call-site ergonomics and whether allocations matter.

FormCall siteAllocates per callCan store / returnAvailable since
params T[]Method(a, b, c) or Method(arr)Yes (one array per call)Yes (it's a real array)C# 1.0
params ReadOnlySpan<T>Method(a, b, c) or Method(span)Usually no (stack buffer)No (it's a ref struct)C# 13 / .NET 9
IEnumerable<T> (no params)Method(new[] { a, b, c }) or Method(myList)None at call site (caller's collection)Yes (defer with ToArray if needed)C# 1.0 (with generics in 2.0)

A diagram showing how each one is wired at the call site:

What this is showing. With params T[], the convenience of the comma-separated call site costs you a heap allocation. With params ReadOnlySpan<T>, you keep the convenience and skip the allocation, at the cost of being unable to store the span. With plain IEnumerable<T>, the caller has to assemble the values themselves, but you gain the ability to accept lazy sequences (LINQ chains, generators) without forcing them to materialize.

Picking between them:

Same result, different trade-offs. The third form is the most flexible (accepts a List<decimal>, a decimal[], a Queryable<decimal>, a LINQ chain) but the least friendly at the call site for inline values. The first and second forms are equally friendly at the call site; the second is just faster.

Common Mistakes

Common mistakes with params:

Passing an object[] to params object[]

This is the famous one. When you have a params object?[] method and you pass it a single object[] argument, the compiler treats the array as the args list, not as a single argument wrapped in an array.

You passed one argument (a single string[]), so why did the method see three?

Because string[] is assignable to object[] (array covariance, an old language feature). The compiler sees a call with one argument of type string[], sees a method that takes params object?[], notices that string[] is an object?[] (by covariance), and decides to use your array directly as the args list. Your one "argument" got unpacked into three.

If you wanted to pass the array as a single value of type object, you have to be explicit:

The cast (object)customerNames tells the compiler "treat this as a single object, not as the args array." Now values is a one-element object?[] containing the string[]. The second form makes the wrapping explicit by constructing the args array yourself.

This is the kind of bug that shows up in formatting and logging code, where someone passes an array intending to format it as one value and instead gets each element formatted individually. If you see weird output from string.Format or a logger, this is one of the first things to check.

Calling .Length on params that might be null

Mostly a non-issue because params never produces a null array from a normal call. But if a caller does Method((string[])null), your method body sees a null array. Defensive code:

For most internal methods, you can trust that no one is going to pass a literal null array, and skip the check. For public API surface that users might abuse, the null check is worth it.

Forgetting that allocations are real

The biggest mistake is treating params T[] like it's free. It isn't. Each call allocates a new array.

Output (rough order of magnitude):

That loop allocates one million arrays, plus one million strings from i.ToString(). In a typical app this isn't catastrophic, but in a hot path it's measurable, and it puts pressure on the GC.

Every non-empty params T[] call allocates a fresh array on the heap. The zero-argument case uses a cached empty array (since .NET 4.6 / .NET Core), but every other size allocates. For hot loops, either switch to params ReadOnlySpan<T> (no allocation when the compiler uses a stack buffer), build a StringBuilder once and reuse it, or accept the value(s) without params to make the cost visible at the call site.

Confusing params with a default parameter

A params parameter is not the same as an optional parameter with a default. You can omit the args entirely, but you don't have to give the parameter a default value, and there's no syntax to do so.

If you want a default, encode it in the method body when the array is empty. Or use two methods: a no-arg overload that supplies the default, and a params version for explicit callers.

Putting It Together

A small, realistic example pulling several of these ideas together. A method that sends an order confirmation to one or more email addresses, with a fallback when none are given.

Four call sites, four different shapes, all clear from context. The single-address overload exists to save an allocation on the most common path. The params overload handles everything else, including the zero-arg fallback and the pre-built-array case.