Tuples · How it works
13 min readHow it works
Basics
A: A lightweight, unnamed composite of a fixed number of typed values, written (int, string) for the type and (42, "Ada") for the literal. Since C# 7.0 the compiler maps every tuple type to System.ValueTuple<...>, so it costs no heap allocation and needs no class declaration.
(int Id, string Name) user = (42, "Ada");
Console.WriteLine(user.Name); // "Ada"
Console.WriteLine(user.Item1); // 42 - the positional name still worksSystem.Tuple and System.ValueTuple?A: System.Tuple<T1, T2> (.NET 4.0) is a class: heap-allocated, immutable, read-only properties Item1/Item2, no custom element names and no language syntax. System.ValueTuple<T1, T2> (C# 7.0) is a struct: stack or inline allocated, mutable public fields, supports element names, literals, deconstruction and ==. Every (a, b) you write is a ValueTuple. Use System.Tuple only when an old API demands it; ToValueTuple() and ToTuple() convert between them.
A: Yes. Elements are public fields, so t.Item1 = 5; compiles and mutates. Because a tuple is a struct, copying it copies the values, so mutating a copy does not affect the original. That also means a tuple stored in a readonly field or returned from a property cannot be mutated in place (you would be mutating a temporary copy, and the compiler rejects it).
var p = (X: 1, Y: 2);
var q = p; // copy
q.X = 99;
Console.WriteLine(p.X); // 1out parameters?A: Return a named tuple. The caller can use the names or deconstruct straight into locals.
static (int Min, int Max) MinMax(IReadOnlyList<int> xs)
{
int min = int.MaxValue, max = int.MinValue;
foreach (var x in xs) { if (x < min) min = x; if (x > max) max = x; }
return (min, max);
}
var (lo, hi) = MinMax(new[] { 3, 9, 1 });
This is also the only option for async methods and iterators, which cannot declare out or ref parameters: Task<(User User, bool Created)>.
Composition, not encapsulation
A: A tuple only groups values; it does not own or protect them. Encapsulation means a type hides its data behind a boundary and controls every change so that its rules (invariants) always hold. A tuple has no boundary: its elements are public mutable fields, it has no constructor that can validate, no methods, no private state and no name that carries meaning. Whatever you put in is exactly what anyone can read, and anyone holding it can change it.
// Composition: just two values travelling together. Nothing stops Start > End.
(DateOnly Start, DateOnly End) range = (end, start); // compiles, silently wrong
range.End = DateOnly.MinValue; // anyone can overwrite it
// Encapsulation: the type owns its data and enforces the rule.
public sealed record DateRange
{
public DateOnly Start { get; }
public DateOnly End { get; }
public DateRange(DateOnly start, DateOnly end)
{
if (end < start) throw new ArgumentException("End must not be before Start.");
(Start, End) = (start, end);
}
public int Days => End.DayNumber - Start.DayNumber; // behaviour lives with the data
}- No invariants. If a combination of values can be invalid (start after end, negative quantity, currency without an amount), a tuple cannot prevent it. Use a type with a validating constructor.
- No identity or meaning.
(decimal, string)could be money, a score or a price tag. The element names help the reader but are not part of the type, so(decimal Amount, string Currency)converts silently to(decimal Price, string Sku). - No behaviour. Logic about the values ends up in whoever uses them, often duplicated. An encapsulated type puts
Days,Contains(date)orAdd(Money)next to the data. - No stable contract. Callers depend on the exact shape and position of every element, so you cannot add a field, rename one or change a representation without breaking them. That is why tuples suit short-lived, local plumbing, and a named type (class or record) suits anything that crosses a boundary.
A: Four practical rules:
A: When the values have no rule tying them together and only live for a few lines: returning (min, max) from a private helper, a (TenantId, Sku) dictionary key, a LINQ intermediate, a swap, or matching on several values in a switch. Declaring a class there would be ceremony with nothing to protect.
Element names
A: No. They are compile-time only. The compiler records them in a [TupleElementNames] attribute on the method signature, field or property so other assemblies can see them, but the runtime type is plain ValueTuple<int, string>. Consequences: reflection shows Item1/Item2; dynamic access must use Item1; System.Text.Json serializes a tuple as {} (it only serializes properties, and ValueTuple has fields) unless IncludeFields = true, and even then the keys are Item1, Item2.
A: Since C# 7.1 the compiler infers an element name from the variable or member used to build it: var t = (user.Name, age); gives t.Name and t.age. Inference is skipped when it would collide with a reserved name such as Item3 or Rest, or with ToString.
(int A, int B) and (int X, int Y) the same type?A: Yes. Names are not part of the runtime identity, so the two convert freely by position. The compiler warns (CS8123) when you write a literal with explicit names that are then discarded because the target has different names, which catches swapped-argument bugs like assigning (Height: h, Width: w) into (int Width, int Height).
A: No. void F((int A, int B) t) and void F((int X, int Y) t) have the same signature F(ValueTuple<int,int>), so the second is a compile error. The same applies to overriding: an override must use the same element names as the base.
Deconstruction
A: Four common ones:
var (id, name) = GetUser(); // declare new locals, types inferred
(int id2, string name2) = GetUser(); // explicit types
(id, name) = GetUser(); // assign into existing variables
var (_, onlyName) = GetUser(); // discard what you do not need
Deconstruction also works in foreach (var (key, value) in dictionary) because KeyValuePair<TKey, TValue> has a Deconstruct method (.NET Core 2.0+).
A: Add a Deconstruct method with out parameters, either as an instance method or an extension method. Records get one generated automatically from their positional parameters.
public sealed class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
var (x, y) = new Point(3, 4);A: (a, b) = (b, a); The right-hand side is evaluated into temporaries first, so no manual temp variable is needed. The JIT usually compiles it to the same code as the three-line swap.
(X, Y) = (x, y); do in a constructor?A: It is a deconstructing assignment used as a compact multi-field initializer. It is idiomatic in expression-bodied constructors. The compiler assigns the elements directly and never builds an intermediate tuple, so it costs nothing extra.
Equality and comparison
== work on tuples?A: Since C# 7.3, t1 == t2 compares element by element, left to right, using each element type's == operator, and short-circuits. Both sides must have the same arity. Element names are ignored: (A: 1, B: 2) == (X: 1, Y: 2) is true. Lifted nullable tuples work too.
Equals / GetHashCode do on a ValueTuple?A: Structural: Equals compares each element with EqualityComparer<T>.Default, and GetHashCode combines element hashes. That makes tuples good composite dictionary keys: Dictionary<(int TenantId, string Sku), Price>. Remember that a reference-type element compares by that type's equality (reference equality for most classes), not deeply.
A: Yes. ValueTuple implements IComparable and IStructuralComparable, comparing element by element (lexicographic order). list.Sort() on List<(int Priority, DateTime At)> orders by priority, then by time. A handy trick for multi-key ordering: OrderBy(x => (x.LastName, x.FirstName)).
Under the hood
A: ValueTuple<T1..T7, TRest> nests: the eighth-and-later elements live in a Rest field holding another ValueTuple. t.Item9 is compiler sugar for t.Rest.Item2. It works, but a tuple that large is a sign you need a named type.
A: A ValueTuple itself is a struct, so creating and returning one does not allocate. It does allocate when boxed: casting to object, storing in a non-generic collection, passing to dynamic, or calling an interface method through the interface. Large tuples are also copied by value on every pass, so an eight-element tuple of decimals is a 128+ byte copy. For hot paths with big shapes, pass in or use a class.
ref, a pointer or Span<T>?A: No. Tuple elements are generic type arguments, and generic type arguments cannot be pointers, ref locals or ref structs such as Span<T> (C# 13 allows ref struct does not extend to ValueTuple).
Memory level: what a tuple is in RAM
- a local variable: in the method's stack frame (or just in CPU registers after JIT optimisation);
- a field of a class: inside that class's object on the heap;
- an element of an array or
List<T>: packed contiguously inside the array's single heap block; - a local captured by a lambda, or used across an
awaitoryield: hoisted into a compiler-generated closure or state-machine object, so on the heap. (A closure is a lambda plus the outer variables it uses; the compiler moves those variables into a hidden heap object so they outlive the method. The Closures module explains it in detail.)
A: A ValueTuple is a struct, so it has no object of its own. Its fields are stored inline, wherever the variable that holds it lives:
"Structs live on the stack" is a shorthand; the accurate rule is "value types live inside their container". Any string or other reference-type element is only a pointer (8 bytes on x64); the string itself is a separate heap object.
(int Id, string Name) t = (42, "Ada"); // local, x64
stack frame managed heap
┌──────────────────────────┐ ┌──────────────────────────┐
│ t.Item2 → ───────────────┼────────► │ string "Ada" (header, │
│ t.Item1 = 42 (+padding) │ │ method table, length…) │
└──────────────────────────┘ └──────────────────────────┘
16 bytes, no GC object for the tuple itselfA: Just the sum of its fields plus alignment padding: (int, int) is 8 bytes, (int, string) is 16 bytes on x64 (8-byte reference + 4-byte int + 4 bytes padding). There is no object header and no method-table pointer, because it is not an object. ValueTuple is declared [StructLayout(LayoutKind.Auto)], so the runtime may reorder fields to reduce padding; never rely on its field order for interop or MemoryMarshal tricks.
System.Tuple differ at the memory level?A: System.Tuple<int, int> is a class: every instance is a separate heap object with an 8-byte object header and an 8-byte method-table pointer before the data, so 24 bytes for two ints (against 8 for the ValueTuple), plus the 8-byte reference in the variable. Each one is tracked by the garbage collector. A million (int, int) in an array is one ~8 MB block; a million Tuple<int, int> is an 8 MB array of pointers plus a million 24-byte objects scattered on the heap (~32 MB and a million GC objects to trace).
A: The bytes are copied. var b = a; duplicates every field, so a and b are independent. Passing it to a method copies it again, and returning it copies it back (small tuples travel in registers; larger ones through a hidden return buffer in the caller's frame). For 8-16 byte tuples this is cheaper than allocating an object. For large tuples (for example five decimals, 80 bytes) every pass is an 80-byte copy; pass by in or use a class if that sits on a hot path.
A: When it is boxed: assigned to object, dynamic or an interface type (IEquatable<...>, IComparable), or stored in a non-generic collection. The runtime allocates a box (header + method table + a copy of the fields) and copies the struct into it. Later changes to the original do not reach the box. It also ends up on the heap when it is part of a heap object: captured by a lambda, alive across an await, or stored in a Task<(…)> result.
- type identity:
Pointhas its own runtime type; the tuple is the shared genericValueTuple<int, int>, the same type as every other pair of ints; - metadata:
Point.Xis a real property recorded in the assembly; the tuple'sXis only an attribute hint, the runtime seesItem1; - rules:
Pointcan have private fields, a validating constructor and methods; the tuple's fields are public and writable by anyone holding a copy.
A: Almost identical, which is the point. A (int X, int Y) tuple and a readonly record struct Point(int X, int Y) are both 8 bytes laid out inline. The difference is not in the bytes but in what the compiler and type system attach to them:
Encapsulation is enforced by the compiler and the type system, not by memory. That is why choosing between a tuple and a record is mostly about correctness and contracts, while choosing between a struct and a class (value vs reference type) is about memory. The full comparison is in the Records module.
Pattern matching
A: C# 8 lets switch match on several values at once by wrapping them in a tuple. It replaces nested ifs for small state machines and decision tables.
static string Move(string a, string b) => (a, b) switch
{
("rock", "scissors") or ("scissors", "paper") or ("paper", "rock") => "A wins",
var (x, y) when x == y => "draw",
_ => "B wins",
};
The compiler does not allocate a tuple here; it evaluates the pattern against the individual values.
When to use and when not to
A: Tuple: private or internal helpers, local intermediates, LINQ projections that never leave the method, composite dictionary keys, multiple return values where the names at the call site are enough. Record: anything in a public API, anything serialized, shapes reused in several places, or data that deserves a name, validation, methods or documentation. Tuples make public signatures brittle: renaming an element breaks nothing at compile time for callers using positions, and the names are lost in JSON.
A: System.Text.Json serializes public properties only by default, and ValueTuple exposes fields, so the response body is {}. Even with IncludeFields = true the JSON keys are Item1/Item2, because element names do not exist at runtime. Return a record DTO.
A: Anonymous types are classes (heap allocation, read-only properties, reference-type) and cannot leave the method without object or dynamic. Tuples are structs, can be returned from methods with their names, and are mutable. Expression trees (EF Core IQueryable providers) historically could not contain tuple literals, so Select(x => new { x.Id, x.Name }) is still the safe projection in a query that must be translated to SQL; tuples are fine once you are in memory (AsEnumerable()).
out var and the TryX pattern?A: They are an alternative: (bool ok, int value) TryParse(string s) reads well in private code, but the BCL convention stays bool TryParse(string s, out int value) because it composes with if (int.TryParse(s, out var n)). Follow the convention on public APIs.
Common interview gotchas
var t = (1, 2); object o = t; t.Item1 = 9; Console.WriteLine(((ValueTuple<int,int>)o).Item1);A: 1. Assigning to object boxes a copy of the struct; later mutation of t does not affect the boxed copy.
readonly field of tuple type let you change its elements?A: No. _range.Item1 = 5; on a readonly (int, int) _range is a compile error (CS1648): the field is readonly and the struct is copied on read. Modifying a tuple held in a List<(int, int)> via list[0].Item1 = 5 also fails (CS1612) because the indexer returns a copy; replace the whole element instead: list[0] = (5, list[0].Item2);.
default((int, string))?A: (0, null): each element takes its type's default.
Task<T>?A: Yes, and this is the main reason async code likes them: Task<(bool Found, Order? Order)> and IAsyncEnumerable<(int Page, IReadOnlyList<Item> Items)>.