out parameters: semantics, Try pattern, limits and alternatives such as tuples and result types.
An out parameter passes a reference to the caller's variable that the method must assign before returning; the caller need not initialise it. The compiler enforces definite assignment on both sides. C# 7 added out var (declare at the call site) and discards (out _). The classic use is the Try pattern: bool TryParse(string s, out int value) returns success and the result without exceptions. Under the hood out is a ref with an [Out] attribute, so no copy is made. Async methods and iterators cannot have out (or ref) parameters, because the method returns before the value would be written. Annotate Try methods with [NotNullWhen(true)] or [MaybeNullWhen(false)]. Prefer a tuple or result type when there are several outputs, when you need async, or when failure needs a reason.
An out parameter lets a method return an extra value through a reference to the caller's variable. The callee must assign it before returning, and the caller's variable is definitely assigned afterwards. Its main legitimate use today is the Try pattern (bool TryParse(string s, out int value)). I'd cover definite assignment, out var and discards, the Try pattern, how out relates to ref, why async/iterators can't have them, nullable annotations, and when tuples or result types are better.
public static bool TryParsePostcode(string? input, out int postcode)
{
postcode = 0; // must assign on every non-throwing path (CS0177)
if (input is not { Length: 4 }) return false;
return int.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out postcode);
}
out parameter starts unassigned; reading it before assignment is CS0269; every return path must assign it.out var and discardsif (int.TryParse(text, out var quantity)) Use(quantity); // declare inline (C# 7)
if (dict.TryGetValue(key, out _)) { ... } // discard when you don't need the value
if (DateOnly.TryParse(s, out DateOnly date)) { ... } // explicit type
The variable's scope is the enclosing statement/block (it "leaks" into the containing scope for if statements), which allows if (!int.TryParse(s, out var n)) return; Use(n);.
The BCL's convention for operations where failure is expected and common:
bool TryXxx(input, out T result): returns true + result on success, false (with default result) on failure — no exceptions.Xxx(input) for callers who treat failure as exceptional.int.TryParse, Dictionary.TryGetValue, ConcurrentQueue.TryDequeue, Enum.TryParse, IPAddress.TryParse, TryFormat(Span<char>, out int written).This avoids exceptions in hot paths (parsing user input) while keeping the API simple.
out relates to ref under the hoodIn IL, out and ref are the same: a managed pointer (T&) to the caller's storage. out adds [Out] metadata and compiler-enforced definite-assignment rules. Consequences:
M(out int) and M(ref int) — same signature at the CLR level.out lets the callee write directly into it.out (or ref/in) parametersAsync methods and iterators are compiled into state machines whose locals are hoisted into heap objects so they survive across await/yield. An out parameter is a managed pointer that may point to the caller's stack frame; storing it in a heap object would allow it to outlive that frame — a dangling reference. So C# forbids it (CS1988). Async "Try" APIs instead return tuples or result types: Task<(bool Found, Order? Order)>, ValueTask<Order?>.
public bool TryGetCustomer(int id, [NotNullWhen(true)] out Customer? customer) { ... }
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) { ... } // generic: default on false
if (repo.TryGetCustomer(id, out var c)) Console.WriteLine(c.Name); // no warning, no '!'
[NotNullWhen(true)] on a nullable out reference: non-null when the method returns true.[MaybeNullWhen(false)] on a generic out T: may be default when returning false.!.out parameters for several outputs are awkward; return a tuple (private) or record (public).out isn't allowed.Result<T, Error>) or discriminated records carry the error.out doesn't compose in expressions; tuples do.out parameter.I keep out for: Try patterns, low-level performance APIs (TryFormat(..., out int charsWritten)), and interop.
An API's ValidateOrder(Order o, out string error, out int errorCode, out bool isWarning) was called from new async code, forcing awkward wrappers. We replaced it with ValidationResult Validate(Order o) (a record with a list of errors and severity), which worked naturally in async handlers and in ASP.NET Core's ValidationProblem mapping. We kept TryParseOrderNumber(string, [NotNullWhen(true)] out OrderNumber?) for the hot parsing path.
The interviewer checks basic C# parameter semantics and API judgement.
Green flags
out var/discards.Red flags
bool TryParse(string s, out int value)
// .method ... bool TryParse(string s, [out] int32& value)
Calls pass the address of the caller's variable (ldloca.s value). The JIT may keep the value in a register after inlining. For struct out parameters, the callee writes directly into the caller's storage — no copy on return.
The compiler's flow analysis treats out parameters as unassigned at method entry and requires assignment before any normal return. Throwing paths don't need assignment. After the call, the caller treats the argument as assigned — even if the method returned false (it will hold whatever the callee assigned, typically default).
out with ref struct and spansout Span<T> parameters are allowed in synchronous methods but subject to ref safety (the callee can't assign a span over its own stack memory to the caller's variable).
| Approach | Cost on failure |
|---|---|
TryParse returning false |
Nanoseconds |
Parse + catch FormatException |
Microseconds+ (exception throw/catch), allocations |
For user input and external data, failures are common — Try patterns are the right default.
bool; put the result in the last out parameter.default on failure.ReadOnlySpan<char> overloads for parsing to avoid allocations.public async Task<(bool Found, Order? Order)> TryFindAsync(OrderId id, CancellationToken ct) { ... }
var (found, order) = await repo.TryFindAsync(id, ct);
public async Task<Order?> FindAsync(OrderId id, CancellationToken ct) { ... } // often simplest: null = not found
[NotNullWhen(true)] and observe nullable warnings disappear at call sites.async Task<bool> TryX(out int v) and read CS1988.TryParse vs Parse + catch for invalid inputs.using System.Diagnostics.CodeAnalysis;
using System.Globalization;
public readonly record struct PromoCode(string Prefix, int Percent);
public sealed record Customer(int Id, string Name);
public static class Promo
{
// Try pattern: expected failure, no exceptions
public static bool TryParse(string? input, out PromoCode code)
{
code = default; // definite assignment on failure paths
if (input is not { Length: >= 4 }) return false;
ReadOnlySpan<char> s = input;
int dash = s.IndexOf('-');
if (dash <= 0) return false;
if (!int.TryParse(s[(dash + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out int percent)
|| percent is < 1 or > 90) return false;
code = new PromoCode(s[..dash].ToString(), percent);
return true;
}
}
public sealed class CustomerCache
{
private readonly Dictionary<int, Customer> _items = new() { [1] = new(1, "Mia") };
public bool TryGet(int id, [NotNullWhen(true)] out Customer? customer)
=> _items.TryGetValue(id, out customer);
// async cannot use out: return a tuple instead
public async Task<(bool Found, Customer? Customer)> FindAsync(int id)
{
await Task.Yield();
return TryGet(id, out var c) ? (true, c) : (false, null);
}
}
public static class Program
{
public static async Task Main()
{
foreach (string input in new[] { "SPRING-20", "BAD", "WINTER-95" })
Console.WriteLine(Promo.TryParse(input, out var code) ? $"{code.Prefix} {code.Percent}%" : $"invalid: {input}");
var cache = new CustomerCache();
if (cache.TryGet(1, out var mia)) Console.WriteLine(mia.Name); // no '!' thanks to NotNullWhen
if (!cache.TryGet(2, out _)) Console.WriteLine("2 not cached"); // discard
var (found, customer) = await cache.FindAsync(1);
Console.WriteLine($"{found} {customer?.Name}");
}
}
out parameters for complex resultsWhat happens: methods with three or four out parameters.
Why it is wrong: awkward, unreadable and impossible in async code.
Do instead: return a record or result type.
What happens: TryParse that throws on bad input.
Why it is wrong: defeats the pattern's purpose.
Do instead: return false for expected failures.
What happens: out Customer? c without [NotNullWhen(true)].
Why it is wrong: false warnings and ! usage at call sites.
Do instead: annotate.
out value when the method returned falseWhat happens: reading value regardless of the result.
Why it is wrong: it's default, not meaningful.
Do instead: use it only on success.
out in async methodsWhat happens: compile errors lead to workarounds like mutable holder objects. Why it is wrong: convoluted code. Do instead: return tuples, nullable results or result types.
out on all pathsWhat happens: compile error CS0177.
Why it is wrong: the contract requires assignment.
Do instead: assign default early.
ref vs outWhat happens: M(ref int) and M(out int).
Why it is wrong: not allowed — same CLR signature.
Do instead: different method names.
out where a return value sufficesWhat happens: void GetTotal(out decimal total).
Why it is wrong: unnecessary complexity.
Do instead: return the value.
out is best seen as an API design choice for the expected-failure path. The Try pattern is idiomatic .NET, cheap, and clear when there is one result.
| Failure type | API style |
|---|---|
| Expected and frequent (parsing, lookups) | TryX(out) |
| Exceptional (bugs, broken invariants, I/O failure) | exceptions |
| Expected with a reason the caller needs | result type |
| Async expected failure | Task<T?> or result type |
On hot paths, out also has a mechanical advantage: the callee writes straight into the caller's storage, so large struct results are not copied. Senior engineers use that knowingly, not as a default.
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.
C# tuples like (int Id, string Name) are System.ValueTuple structs with public, mutable fields Item1 and Item2. The names you write exist only at compile time: they're stored in a TupleElementNames attribute, so reflection, serialisers and dynamic only see Item1 and Item2. Tuples have value equality and support ==, and since names aren't part of the type, (int A, int B) converts to (int X, int Y). System.Text.Json ignores fields by default, so a tuple serialises as {}. I use tuples for private multiple returns, and records for anything public or serialised.
Nullable reference types are compile-time flow analysis, not a run-time guarantee, so every ! and = default! is a promise the compiler cannot check. In review I remove ! that hides real nulls, replace = default! with required members or constructor parameters, and teach the compiler my helpers' contracts with attributes: [NotNullWhen(true)] on Try methods, [MemberNotNull] on initialisers, [NotNullIfNotNull], [DoesNotReturn] on throw helpers. For generics, T? on an unconstrained T means "may be default". At boundaries the compiler cannot see (JSON, EF Core queries, reflection, old libraries) I validate at run time, because a non-nullable string can still hold null there. With NRT on, EF Core also maps non-nullable reference properties to NOT NULL columns, so annotations change your schema.
Last updated 30 Oct 2026.