C# parameter passing in depth: copies of values versus copies of references, the ref/out/in/ref readonly modifiers, params collections (C# 13), optional and named arguments, and their performance and versioning trade-offs.
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.
The headline is: C# passes everything by value by default — including references. For a value type the value is a copy of the data; for a reference type the value is a copy of the reference. ref, out, in and ref readonly switch to pass-by-reference, which passes the address of the caller's variable.
void Bump(int n) => n++; // caller's int unchanged
void Rename(Customer c) => c.Name = "New"; // caller SEES the change (same object)
void Replace(Customer c) => c = new(); // caller does NOT see it (copy of reference)
| Modifier | Caller must initialise? | Callee must assign? | Callee may write? | Call-site syntax | Typical use |
|---|---|---|---|---|---|
ref |
Yes | No | Yes | ref x |
Swap, update in place, large structs you intend to mutate |
out |
No | Yes, on every path | Yes | out x / out var x |
Try-pattern: int.TryParse(s, out var n) |
in |
Yes | No | No | in x or nothing |
Pass large readonly structs without copying |
ref readonly (C# 12) |
Yes | No | No | ref x or in x (warning if omitted) |
Like in, but documents that a variable (not an rvalue) is expected |
Rules worth knowing:
out and ref require the caller to pass a variable, not a property or expression.in lets you pass an rvalue (Calc(in 42) or just Calc(42)); the compiler creates a temporary.in on a non-readonly struct can trigger hidden defensive copies whenever you call a member, wiping out the benefit. Use it with readonly struct.async methods or iterators (the reference could outlive the stack frame), though C# 13 relaxed this for ref locals and ref struct locals that do not cross an await/yield.paramsparams T[] values lets callers pass a variable number of arguments: Log("a", "b", "c").Array.Empty<T>()).params collections: params ReadOnlySpan<T>, params IEnumerable<T>, params List<T> and others. With params ReadOnlySpan<T> the arguments go on the stack — no allocation. .NET 9+ BCL APIs such as string.Join, Path.Combine and Task.WhenAll added span overloads, so existing code gets faster just by recompiling.public Task SendAsync(string to, string subject, bool highPriority = false,
int retries = 3, CancellationToken ct = default) { ... }
await SendAsync("[email protected]", "Hi", retries: 5, ct: token);
default/new() for value types), and they are copied into the caller — the same versioning trap as const.highPriority: true is clearer than true) and let you skip optionals.A pricing engine passed a 96-byte Quote struct by value through a deep call chain in a tight loop. Profiling with dotnet-trace showed significant time in memcpy-like copying. Making the struct readonly and passing it by in removed the copies. Separately, a shared library changed SendAsync(..., int retries = 3) to retries = 5; services that were not rebuilt kept retrying 3 times, which only surfaced when the downstream API was flaky. We now add a new overload rather than changing public defaults, and keep CancellationToken ct = default as the last parameter on every async API.
The interviewer is testing whether the candidate understands the memory model behind calls, a frequent source of bugs and misconceptions.
Green flags
ref, out and in, and the defensive-copy issue.ref readonly or C# 13 params collections.Red flags
ref on every reference-type parameter "to avoid copying".out must be assigned by the callee.void A(int x) -> parameter type int32
void B(ref int x) -> parameter type int32& (managed pointer)
void C(out int x) -> int32& + [Out] attribute
void D(in int x) -> int32& + modreq(InAttribute) + [IsReadOnly]
void E(ref readonly int x) -> int32& + [RequiresLocation] + [IsReadOnly]
At the CLR level ref, out, in and ref readonly are all the same thing: a managed pointer (T&) to the caller's storage. The differences are compiler-enforced rules encoded as attributes. That is why you cannot overload M(ref int) and M(out int) — the CLR signatures are identical.
A managed pointer can point into the stack, into the middle of a heap object (a field or array element), or into unmanaged memory. The GC understands these interior pointers and updates them when it moves objects. Because a managed pointer can point to the stack, it must never outlive the frame — which is the reason for the ref safety rules and why refs cannot be stored in heap fields or captured by lambdas.
Passing a struct by value copies all its bytes. Rough guidance:
in/ref readonly with a readonly struct wins.public struct Counter { public int Value; public void Increment() => Value++; }
void Use(in Counter c)
{
c.Increment(); // compiles! but mutates a hidden COPY
Console.WriteLine(c.Value); // original unchanged
}
The compiler must guarantee c is not modified through an in parameter. For a non-readonly struct it cannot know whether Increment mutates, so it copies c to a temp and calls the method on the temp. This silently costs a copy per member call and hides bugs. Marking the struct readonly (or the member readonly) removes the copy and makes mutation a compile error.
out and definite assignmentout flips definite-assignment responsibility:
if (dict.TryGetValue(k, out var v)) Use(v); works.out var _ (discard) ignores a value without declaring a variable.The Try-pattern returns bool for the expected failure case and uses out for the result. With nullable annotations, [NotNullWhen(true)] out T? value tells the compiler the value is non-null when the method returns true.
params works, old and newClassic params T[]:
Log("a", "b"); // compiled as Log(new[] { "a", "b" })
Log(); // compiled as Log(Array.Empty<string>())
Log(null); // passes a null array, not an array containing null!
C# 13 params ReadOnlySpan<T>:
static int Sum(params ReadOnlySpan<int> values) { ... }
Sum(1, 2, 3); // compiler uses an InlineArray on the stack - zero allocations
Overload resolution prefers the span overload over the array overload when both exist, which is how the BCL made string.Concat, Path.Combine and similar calls allocation-free in .NET 9 without source changes.
The default value is stored as metadata on the parameter (.param [n] = int32(3)) and the caller's compiler reads it and emits the constant at the call site. Consequences:
MissingMethodException. Add an overload instead.dynamic callers see the default through ParameterInfo.DefaultValue.M(int) and M(ref int) can coexist (different ref-kind), but ref/out/in among themselves cannot.params expanded form.in, call the method, and print the field.in with BenchmarkDotNet.params int[] call and a params ReadOnlySpan<int> call.namespace Basics;
public readonly record struct Quote(decimal Price, decimal Fx, decimal Margin, decimal Tax);
public static class Parameters
{
public static void Main()
{
// Reference copied by value
var items = new List<int> { 1, 2, 3 };
Reset(items);
Console.WriteLine(items.Count); // 0: Clear() hit the caller's list, new() did not
// ref
int a = 1, b = 2;
Swap(ref a, ref b);
Console.WriteLine($"{a} {b}"); // 2 1
// out + discard
if (TryParseAge("42", out var age)) Console.WriteLine(age);
_ = TryParseAge("oops", out _);
// in with a readonly struct: no copies
var q = new Quote(100m, 1.5m, 0.1m, 0.1m);
Console.WriteLine(Total(in q));
// params collections (C# 13): no array allocation
Console.WriteLine(Sum(1, 2, 3, 4));
// named + optional arguments
Send("[email protected]", urgent: true);
}
private static void Reset(List<int> list)
{
list.Clear(); // visible to caller
list = new() { 99 }; // only changes the local copy of the reference
}
private static void Swap(ref int x, ref int y) => (x, y) = (y, x);
private static bool TryParseAge(string text, out int age)
=> int.TryParse(text, out age) && age is >= 0 and <= 150;
private static decimal Total(in Quote q) => q.Price * q.Fx * (1 + q.Margin) * (1 + q.Tax);
private static int Sum(params ReadOnlySpan<int> values)
{
int total = 0;
foreach (var v in values) total += v;
return total;
}
private static void Send(string to, int retries = 3, bool urgent = false)
=> Console.WriteLine($"to={to} retries={retries} urgent={urgent}");
}
What happens: a candidate expects void Reset(Customer c) => c = new Customer(); to replace the caller's object.
Why it is wrong: references are passed by value; reassigning the parameter only changes the local copy.
Do instead: return the new object, or use ref Customer c if you genuinely need to reassign the caller's variable (rare).
What happens: a method receives a list or DTO and quietly modifies it (sorting, trimming, adding defaults).
Why it is wrong: callers do not expect side effects; bugs appear far from the cause.
Do instead: accept read-only abstractions (IReadOnlyList<T>), return new values, and make DTOs immutable with records and init.
in with non-readonly structsWhat happens: in BigStruct s is added for performance, then members are called on s.
Why it is wrong: the compiler inserts defensive copies for every member call, often making it slower than passing by value, and mutating calls silently affect a copy.
Do instead: use in only with readonly struct (or readonly members), and benchmark.
out for multiple return values in new APIsWhat happens: void Calculate(out decimal tax, out decimal total, out string error).
Why it is wrong: awkward to call, impossible in async methods, and hard to evolve.
Do instead: return a record or a named tuple; keep out for the Try-pattern.
What happens: int timeoutSeconds = 30 becomes 60 in a NuGet package.
Why it is wrong: defaults are baked into callers, so un-rebuilt consumers keep the old value.
Do instead: treat public defaults as part of the contract; add an overload or an options object (SendOptions) instead.
What happens: Send(string to) becomes Send(string to, bool urgent = false).
Why it is wrong: it is source compatible but binary breaking — existing compiled callers throw MissingMethodException.
Do instead: add a new overload and keep the old signature.
params T[] in hot pathsWhat happens: logging or helper methods with params object[] called millions of times.
Why it is wrong: an array (and boxing for value types) is allocated on every call, adding GC pressure.
Do instead: provide fixed-arity overloads, use params ReadOnlySpan<T> (C# 13), or use source-generated logging (LoggerMessage).
null to a params methodWhat happens: Log(null) expecting an array with one null element.
Why it is wrong: null binds to the array parameter itself, so the method receives a null array and may throw.
Do instead: pass new string?[] { null } explicitly, and guard params arrays against null.
What happens: Export(data, true, false, true).
Why it is wrong: unreadable and easy to swap arguments silently.
Do instead: use named arguments (includeHeaders: true), an enum, or an options record.
CancellationToken or putting it in the wrong placeWhat happens: async APIs without a token, or with the token in the middle of the parameter list.
Why it is wrong: callers cannot cancel work when a request is aborted, and the convention (CA2016 analyser) expects it last.
Do instead: add CancellationToken cancellationToken = default as the last parameter and pass it through every call.
Parameter choice is an API design and performance decision.
| Goal | Choice |
|---|---|
| Clear public API | by-value parameters, records for groups of values |
| Try pattern | bool TryX(..., out T result) |
| Avoid copying big structs | readonly struct + in (measure first) |
| Variable arguments without allocation | params ReadOnlySpan<T> |
| Optional settings that will grow | an options object or overloads, not many optional parameters |
| Async API | no ref/out/in; return a tuple or result type |
in adds indirection.urgent: true) or small enums make call sites readable.The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.
A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.
A method is a named block of code with a signature: its name plus the number, types, order and ref/out/in kinds of its parameters (the return type is not part of it, so you cannot overload by return type alone). Instance methods run on an object and can use this; static methods belong to the type and cannot. Overloading means several methods with the same name and different signatures; the compiler picks the best match at compile time, preferring exact types over conversions and normal forms over params. Expression-bodied methods (=> expr) are short forms for one expression. Extension methods are static methods with a this parameter that look like instance methods; an instance method always wins if one fits. C# 14 adds extension blocks, which also allow extension properties and static extension members.
How to tell it (STAR)
**S:** Our pricing engine [at company] was missing its throughput target. **T:** I was asked to find the bottleneck without changing results. **A:** I profiled it, saw time going into copying a large mutable struct, proved with BenchmarkDotNet that in on a non-readonly struct made it worse due to defensive copies, then made the struct readonly and passed it by in. **R:** Throughput improved by [x%], CPU dropped, and the benchmark now guards it in CI.
Last updated 30 Oct 2026.