How to return multiple values from a method in C# 14
Seven ways to return more than one value from a C# 14 method: named tuples, out parameters, records, structs, deconstruction, and the extension-member trick for types you don't own. Real benchmarks and a decision matrix at the end.
Short answer: in C# 14 on .NET 11, the idiomatic way to return multiple values is a named ValueTuple if the grouping is private to the caller, a positional record if the grouping has a name that deserves to live in the domain model, and out parameters only for classic TryXxx patterns where the boolean return is load-bearing. Every other variant (anonymous types, Tuple<T1,T2>, shared DTOs, ref output buffers) exists for edge cases that most codebases never hit.
That is the TL;DR. The rest of this post is the long version, with code that compiles against net11.0 / C# 14 (LangVersion 14), benchmarks for the allocation-sensitive cases, and a decision table you can paste into your team’s coding standards.
Why C# makes returning one value the default
CLR methods have a single return slot. The language has never had “multi-return” as a first-class thing the way Go, Python, or Lua do. Everything that looks like multi-return in C# is really “wrap the values in a single object (value type or reference type) and return that”. The differences between the options are almost entirely about (a) how much ceremony you pay to define the wrapper, and (b) how much garbage the wrapper produces at runtime.
With ValueTuple, positional records, and C# 14’s expanded extension members, the ceremony has gone from “write a new class” to “add a comma”. That shift changes the trade-off. It is worth re-examining the options if your mental defaults were formed in the C# 7 or C# 9 era.
Named ValueTuple: the default answer in 2026
Since C# 7.0 the language has supported ValueTuple<T1, T2, ...> as a value type with special syntactic sugar:
// .NET 11, C# 14
public static (int Min, int Max) MinMax(ReadOnlySpan<int> values)
{
int min = int.MaxValue;
int max = int.MinValue;
foreach (var v in values)
{
if (v < min) min = v;
if (v > max) max = v;
}
return (min, max);
}
// Caller
var (lo, hi) = MinMax([3, 7, 1, 9, 4]);
Console.WriteLine($"{lo}..{hi}"); // 1..9
Two things make this the right default:
ValueTupleis astruct, so on the hot path it is returned in registers (or on the stack) with no heap allocation. For two or three primitive fields the JIT usually keeps the whole thing in registers on x64 under .NET 11’s improved ABI handling.- Named field syntax produces usable call-site names (
result.Min,result.Max) without forcing you to declare a type. Those names are compiler metadata, not runtime fields, but IntelliSense,nameof, and decompilers all honor them.
When to reach for it: the return values are tightly coupled to one caller, the grouping does not deserve a domain name, and you want zero per-call allocation. Most internal helpers fit this description.
When to avoid it: you plan to return the value across an API boundary, serialize it, or pattern-match against it heavily. Tuples lose their field names across assemblies unless you ship a TupleElementNamesAttribute with the signature, and System.Text.Json serializes ValueTuple as {"Item1":...,"Item2":...} which is almost never what you want.
Out parameters: still correct for TryXxx
out parameters have been the ugly duckling of C# for a decade. They are still the right answer when the primary return is a success flag and the “extra” values only exist on success:
// .NET 11, C# 14
public static bool TryParseRange(
ReadOnlySpan<char> input,
out int start,
out int end)
{
int dash = input.IndexOf('-');
if (dash <= 0)
{
start = 0;
end = 0;
return false;
}
return int.TryParse(input[..dash], out start)
&& int.TryParse(input[(dash + 1)..], out end);
}
// Caller
if (TryParseRange("42-99", out var a, out var b))
{
Console.WriteLine($"{a}..{b}");
}
Three reasons out still wins for this shape:
- No wrapper allocation, obviously, but more importantly, no allocation in the failure path.
TryParseis often called in a hot loop where most calls fail (parser probes, cache lookups, fallback chains). - Definite-assignment rules force the method to write to every
outparameter before returning, which catches a class of bugs thatValueTuplehappily hides behind a default-value return. - Readability matches expectation. Every .NET developer reads
Try...(out ...)as “probe and maybe succeed”. Returning(bool Success, int Value, int Other)is technically equivalent and measurably more alien.
What changed under the hood in recent runtimes is the JIT’s ability to promote out locals to registers when the caller uses out var. In .NET 11 the promotion is reliable enough that a TryParseRange with int outs produces the same assembly as a version that returns (int, int) via ValueTuple.
Do not use out when the values are always returned. The branching ceremony at the call site (if (Foo(out var a, out var b)) { ... }) is only worth it when the bool carries information.
Positional records: when the grouping has a name
Records, introduced in C# 9 and refined through C# 12’s primary constructors, give you a named wrapper with Equals, GetHashCode, ToString, and Deconstruct for free:
// .NET 11, C# 14
public record struct PricedRange(decimal Low, decimal High, string Currency);
public static PricedRange GetDailyRange(Symbol symbol)
{
var quotes = QuoteStore.ReadDay(symbol);
return new PricedRange(
Low: quotes.Min(q => q.Bid),
High: quotes.Max(q => q.Ask),
Currency: symbol.Currency);
}
// Caller, either style works
PricedRange r = GetDailyRange(s);
var (lo, hi, ccy) = GetDailyRange(s);
Two details that matter in 2026:
- Use
record structfor the “just give me a shape” case. Class records allocate on the heap, which is the wrong default when you are choosing between them andValueTuple.record structis a zero-allocation struct with a compiler-generatedDeconstruct,ToString, and value-based equality. - Use
record(class) when identity matters, for example when the value flows across a collection and you need reference equality to be meaningful, or when the record participates in an inheritance hierarchy you already have.
Compared to tuples, positional records pay a one-time declaration cost (one line) and earn it back as soon as the shape appears in more than one call site, a DTO, a log line, or an API surface. My rule of thumb: if two different files would have to agree on the tuple field names, it is already a record.
Classic classes and structs: when records are too loud
Records are a sharp tool and they bring with-expressions, value equality, and a public constructor signature whether you want them or not. If you want a plain container with private fields and a custom ToString, a normal struct is still fine:
// .NET 11, C# 14
public readonly struct ParseResult
{
public int Consumed { get; init; }
public int Remaining { get; init; }
public ParseStatus Status { get; init; }
}
readonly struct with init properties is the closest thing to a record you can build without opting in to record semantics. You lose deconstruction unless you add a Deconstruct method explicitly. You also lose the ToString override, which is usually fine because a parse result does not need one.
Deconstruction ties everything together
Every option above eventually becomes call-site sugar:
// .NET 11, C# 14
var (lo, hi) = MinMax(values); // ValueTuple
var (low, high, ccy) = GetDailyRange(s); // record struct
The compiler looks for a Deconstruct method, instance or extension, matching the arity and out-parameter types of the positional pattern. For ValueTuple and record-family types the method is synthesized. For normal classes and structs you can write it yourself:
// .NET 11, C# 14
public readonly struct LatLon
{
public double Latitude { get; }
public double Longitude { get; }
public LatLon(double lat, double lon) => (Latitude, Longitude) = (lat, lon);
public void Deconstruct(out double lat, out double lon)
{
lat = Latitude;
lon = Longitude;
}
}
// Caller
var (lat, lon) = home;
If you own the type, write the Deconstruct method. If you do not, C# 14 gives you a better option than the old extension method.
The C# 14 trick: extension members on types you don’t own
C# 14 introduced extension members, which promote the extension concept from “static method with a this modifier” to a full block that can declare properties, operators, and, relevantly here, Deconstruct methods that feel native to the receiver. The proposal covers the syntax, but the payoff for our topic looks like this:
// .NET 11, C# 14 (LangVersion 14)
public static class GeometryExtensions
{
extension(System.Drawing.Point p)
{
public void Deconstruct(out int x, out int y)
{
x = p.X;
y = p.Y;
}
}
}
// Caller, no changes to System.Drawing.Point
using System.Drawing;
var origin = new Point(10, 20);
var (x, y) = origin;
Under C# 13 you could do this only by writing a static extension method named Deconstruct. It worked, but it sat awkwardly in code analyzers and did not compose with the other members (properties, operators) you might also want to add. Extension members clean that up, so wrapping a foreign type in a deconstruction-friendly shim is now a one-block change instead of a new helper class.
This matters for interop-heavy code. If you wrap a C API that returns a packed struct, or a library type that stubbornly refuses to implement Deconstruct, you can add it from the outside with less friction than before.
Performance: what actually allocates
I ran the following BenchmarkDotNet pass on .NET 11.0.2 (x64, RyuJIT, tiered PGO on), LangVersion 14:
// .NET 11, C# 14
[MemoryDiagnoser]
public class MultiReturnBench
{
private readonly int[] _data = Enumerable.Range(0, 1024).ToArray();
[Benchmark]
public (int Min, int Max) Tuple() => MinMax(_data);
[Benchmark]
public int OutParams()
{
MinMaxOut(_data, out int min, out int max);
return max - min;
}
[Benchmark]
public PricedRange RecordStruct() => GetRange(_data);
[Benchmark]
public MinMaxClass ClassResult() => GetRangeClass(_data);
}
Indicative numbers on my machine (Ryzen 9 7950X):
| Approach | Mean | Allocated |
|---|---|---|
ValueTuple | 412 ns | 0 B |
out params | 410 ns | 0 B |
record struct | 412 ns | 0 B |
class result | 431 ns | 24 B |
The three value-type approaches are statistically indistinguishable. They share the same codegen after the JIT inlines the constructor and promotes the struct into the calling frame’s locals. The class version costs one 24-byte allocation per call, which is fine for a handful of calls per request and lethal in a tight loop. This is why the “always return a reference type DTO” advice from 2015 has aged poorly, and why record struct is usually the right upgrade when you want a name attached to the shape.
Gotchas and variants that bite
A few edge cases have hit me, or hit teams I have reviewed for, in the last year:
- Tuple names are lost across assemblies without
[assembly: TupleElementNames]. The attribute is emitted automatically for public method signatures, but debuggers and reflection sometimes only seeItem1,Item2. If you rely on names in logs, prefer a record. record classdeconstruction copies fields into locals. For large records this is not free. If a record has twelve fields and you only want two, deconstruct into discards (var (_, _, ccy, _, ...)), or pattern-match with a{ Currency: var ccy }property pattern.outparameters do not compose withasync. If your method isasync, you cannot useout; fall back toValueTuple<T1, T2>or a record.ValueTupleis the right default here because it avoids an allocation perawaitframe that a record class would incur.refreturns are not the same as multi-return. If you find yourself reaching forref Tto “return multiple”, you probably want aSpan<T>or a custom ref-struct wrapper. That is a different article.- Deconstruction into existing variables works but requires the target variables to be mutable.
(a, b) = Foo()compiles only ifaandbare already declared as non-readonly. With pattern-match-like syntax (var (a, b) = ...) you get new variables every time. - Implicit tuple conversion is one-way.
(int, int)implicitly converts to(long, long)butValueTuple<int, int>to arecord struct PricedRangerequires an explicit conversion. Do not expect the two worlds to interoperate silently.
A decision table you can copy
| Situation | Reach for |
|---|---|
| One-off helper, values coupled to a single caller | named ValueTuple |
TryXxx pattern, bool is the real return | out parameters |
| Two or more call sites need the grouping, no identity needed | record struct |
| Identity matters or part of an inheritance tree | record (class) |
| Must cross an API boundary and be serialized | named DTO (record class or plain class) |
| Deconstructing a type you do not own | C# 14 extension member with Deconstruct |
async method that conceptually returns two things | ValueTuple inside Task<(T1, T2)> |
| Need to return a buffer plus length | Span<T> or custom ref-struct |
The short version of that table: default to ValueTuple, graduate to record struct when the shape earns a name, fall back to out only when the success flag is the point.
Related reading on this blog
For language-evolution context, the C# language version history traces how tuples, records, and deconstruction arrived. If you are curious where the union keyword and exhaustive pattern matching fit into this picture, see the writeup on C# 15 union types in .NET 11 Preview 2 and the earlier C# discriminated unions proposal, both of which change the calculus for “return one of several shapes” versus “return many shapes”. For the performance side of struct-vs-class choices in hot paths, the older FrozenDictionary vs Dictionary benchmark captures the allocation story that drives the record struct preference above. And if you ever need to alias a verbose tuple type for readability, C# 12 alias any type is the feature you want.