C# tuples: ValueTuple internals, compile-time names via TupleElementNames, equality, mutable fields, boxing and size, JSON serialisation gotchas, and choosing tuples versus records.
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.
C# tuples ((int Id, string Name)) are syntax over System.ValueTuple<...> — mutable structs with fields Item1, Item2, ... The element names are compile-time sugar that disappear at run time. Tuples are great for private, local multi-value results; for anything public, serialised or long-lived, I use a record. I'd cover ValueTuple vs the old System.Tuple, names, equality, mutability, performance and serialisation pitfalls.
System.ValueTuple vs System.TupleSystem.Tuple<T1,...> (.NET 4) |
System.ValueTuple<T1,...> (C# 7) |
|
|---|---|---|
| Kind | Class (heap allocation) | Struct (no allocation by itself) |
| Members | Read-only properties Item1, Item2 |
Public mutable fields Item1, Item2 |
| Language syntax | Tuple.Create(1, "a") |
(1, "a"), (int Id, string Name) |
| Element names | No | Yes (compile-time) |
| Equality | Equals by value; == reference |
Equals by value; ==/!= supported (C# 7.3) |
| Use today | Legacy APIs only | Default |
(int Count, decimal Total) Summarise(IEnumerable<Order> orders)
{
var list = orders.ToList();
return (list.Count, list.Sum(o => o.Total));
}
var (count, total) = Summarise(orders); // deconstruction
var s = Summarise(orders);
Console.WriteLine(s.Total); // named element
Console.WriteLine(s.Item2); // same field
var inferred = (order.Id, order.Total); // names inferred: inferred.Id, inferred.Total (C# 7.1)
The runtime type is just ValueTuple<int, decimal> with fields Item1, Item2. Names are stored in a [TupleElementNames] attribute on the method return, parameter, field or property, used only by the compiler. Consequences:
Item1, Item2.System.Text.Json serialises... nothing by default — it ignores fields unless IncludeFields = true, then writes Item1, Item2).dynamic, data binding, logging templates and ToString() ((42, 99.5)) don't know the names.(int A, int B) and (int X, int Y) are the same type and assignable (with a warning if names mismatch in some contexts).Since C# 7.3, == and != compare element-wise (with each element's own ==), and Equals uses EqualityComparer<T>.Default per element:
(1, "a") == (1, "a"); // true
Tuples implement IEquatable<ValueTuple<...>>, IComparable, IStructuralEquatable, making them usable as dictionary keys and in GroupBy/Distinct — e.g. Dictionary<(string Region, int Year), decimal>.
ValueTuple fields are mutable: s.Total = 0; compiles. Because it's a struct, you mutate your copy, which is usually fine for locals but surprising when tuples are stored in collections or properties (modifying a copy). Treat tuples as immutable values by convention.
out parameters or small classes.TRest tuple (ValueTuple<T1..T7, ValueTuple<T8...>>) — a sign you need a named type.System.Text.Json ignores fields by default → a tuple serialises as {}; with IncludeFields, as {"Item1":42,"Item2":99.5} — element names are lost.(42, 99.5).Use a record when the data:
public readonly record struct Summary(int Count, decimal Total); gives names that survive at run time, immutability and value equality with similar performance to a tuple.
A minimal API endpoint returned (int Count, decimal Total) and the mobile app received {} — System.Text.Json ignored the fields. A developer "fixed" it with IncludeFields = true globally, and the app then received Item1/Item2, which the app team hard-coded. When the tuple's order was later swapped in a refactor, totals and counts were silently reversed in the app. We replaced it with a record SummaryResponse(int Count, decimal Total) and removed IncludeFields.
Checks practical knowledge of ValueTuple and its pitfalls.
Green flags: ValueTuple versus Tuple; TupleElementNames; equality; mutability; JSON {} pitfall; clear rule for tuples versus records.
Red flags: thinks names exist at run time; returns tuples from APIs; unaware of System.Tuple versus ValueTuple.
(int Count, decimal Total) Summarise(...) →
[return: TupleElementNames(new[] { "Count", "Total" })]
ValueTuple<int, decimal> Summarise(...)
Access to s.Total compiles to s.Item2. Names in nested generic types (e.g. List<(int Id, string Name)>) are encoded in the attribute's flattened array.
t1 == t2 with two-element tuples compiles to t1.Item1 == t2.Item1 && t1.Item2 == t2.Item2, using each element type's == operator (so for strings, value equality; for classes without == overloads, reference equality). Nullable tuples and mixed types follow lifted/implicit conversion rules.
ValueTuple<T1, ..., T7, TRest>: the eighth element and beyond live in a nested tuple in Rest. t.Item8 is compiler sugar for t.Rest.Item1. Performance and readability degrade; use a named type.
out parameters vs records| Option | Async-compatible | Named at run time | Allocation |
|---|---|---|---|
out parameters |
No | N/A | None |
| ValueTuple | Yes | No | None (struct) |
record struct |
Yes | Yes | None (struct) |
record class |
Yes | Yes | Heap |
For async methods returning multiple values, tuples (Task<(bool Found, Order? Order)>) are convenient internally; records are better publicly.
Tuple literals and tuple == can't be used in expression trees (CS8143/CS8382), so EF Core projections must use anonymous types or records. You can use tuple-typed properties on entities only with custom value conversions (rarely a good idea).
var sales = new Dictionary<(string Region, int Year), decimal>();
sales[("NSW", 2026)] = 1_000m;
Efficient (no allocation for keys, good hashing via HashCode.Combine-style implementations). For string elements, the default comparer is ordinal and case-sensitive; for case-insensitive keys, use a custom IEqualityComparer<(string, int)> or normalise.
TupleElementNamesAttribute.System.Text.Json with and without IncludeFields.== and use them as dictionary keys.record struct and compare JSON output.using System.Text.Json;
namespace Advanced.Tuples;
public sealed record SummaryResponse(decimal Total, int Count);
public static class Stats
{
// Fine: private/internal helper returning two values
internal static (decimal Total, int Count) Summarise(IEnumerable<decimal> amounts)
{
var total = 0m;
var count = 0;
foreach (var a in amounts) { total += a; count++; }
return (total, count);
}
}
public static class Program
{
public static void Main()
{
var summary = Stats.Summarise([10m, 20m, 30m]);
Console.WriteLine($"{summary.Total} over {summary.Count}");
// Names are not part of the type
(decimal Sum, int N) renamed = summary;
Console.WriteLine(renamed.Sum);
// Value equality and composite keys
Console.WriteLine((1, "a") == (1, "a")); // True
var sales = new Dictionary<(string Region, int Month), decimal> { [("AU", 1)] = 150m };
Console.WriteLine(sales[("AU", 1)]);
// Mutable struct: copies are independent
var copy = summary;
copy.Count = 99;
Console.WriteLine(summary.Count); // 3
// Swap idiom
var (a, b) = (1, 2);
(a, b) = (b, a);
Console.WriteLine($"{a},{b}"); // 2,1
// Serialisation pitfall
Console.WriteLine(JsonSerializer.Serialize(summary)); // {}
Console.WriteLine(JsonSerializer.Serialize(summary, new JsonSerializerOptions { IncludeFields = true })); // {"Item1":60,"Item2":3}
Console.WriteLine(JsonSerializer.Serialize(new SummaryResponse(summary.Total, summary.Count))); // {"Total":60,"Count":3}
}
}
What happens: controllers or library methods return tuples.
Why it is wrong: names are lost at run time; JSON is {} or Item1/Item2; contracts are fragile.
Do instead: return records.
IncludeFields globally to serialise tuplesWhat happens: a global serialiser setting to make tuples serialise.
Why it is wrong: exposes Item1/Item2 and fields of other types unexpectedly.
Do instead: use records for serialised data.
What happens: list[0].Total = 5 or modifying a tuple property.
Why it is wrong: modifies a copy (or doesn't compile).
Do instead: treat tuples as immutable; replace whole values.
What happens: tuples with five or more elements. Why it is wrong: unreadable and error-prone (swapping same-typed elements compiles). Do instead: use a named record.
What happens: assuming (int Count, int Total) and (int Total, int Count) are different types.
Why it is wrong: they're the same type; swapping values compiles.
Do instead: use records with distinct names (or strongly typed values).
What happens: Select(o => (o.Id, o.Total)) on an IQueryable.
Why it is wrong: not supported in expression trees.
Do instead: anonymous types or records.
System.Tuple in new codeWhat happens: Tuple.Create(...) returning class tuples.
Why it is wrong: allocations and no element names.
Do instead: ValueTuple syntax or records.
What happens: LogInformation("Result {Result}", tuple).
Why it is wrong: logs (42, 99.5) without names; hard to query.
Do instead: log named values or records.
Tuples are an excellent private-implementation tool and a poor public contract. Seniors know names are erased, that tuples are mutable structs, and that serialisers do not see them the way the compiler does. My rule: tuples inside a class or assembly, records at boundaries. I also like tuples for composite dictionary keys and swap/deconstruction idioms, where they read clearly and cost nothing.
Deconstruction unpacks a value into separate variables, like var (id, name) = customer;. Tuples support it built in, positional records generate a Deconstruct method, and any other type works if it has a Deconstruct(out ..., out ...) method, which can be an extension, so you can add it to types you don't own. You can skip parts with _, deconstruct dictionary entries in foreach, and since C# 10 mix new and existing variables. Positional patterns like p is (0, var y) use the same Deconstruct method.
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 record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.
How to tell it (STAR)
**S:** A new dashboard widget showed blank data in production. **T:** Fix it quickly. **A:** I found the endpoint returned a tuple, which System.Text.Json serialises as an empty object because tuples expose fields and their names are compile-time only. I replaced it with a record and added contract tests. **R:** Fixed within [timeframe], and the tests have caught [number] other contract regressions since.
Last updated 30 Oct 2026.