A tuple is the simplest compound value in Scala: a fixed number of values, each with its own type, grouped without a name. ("ada", 36) is a pair of a String and an Int, and its type is written (String, Int). Tuples are how a function returns two things, how a Map stores its entries, how zip pairs two collections and how a fold carries several running totals at once. Because they are everywhere, it pays to know exactly what they are, what they cost and where they stop being the right tool.
Scala 3 changed tuples more than most people noticed. They became a type-level list that code can be generic over, regardless of arity, the old limit of 22 elements disappeared, and named tuples arrived and were stabilised in Scala 3.7.0. This article covers the basics that apply to both Scala 2 and Scala 3, then the Scala 3 machinery, then performance, pitfalls and a decision rule for tuples versus case classes.
The basics: construction, access, equality
You build a tuple with parentheses and commas, and read positions with _1, _2 and so on. The arrow -> is a method that builds a pair, which is why Map("k" -> 42) works. Tuples are immutable, compare by value and have a matching hashCode, so they are safe as Map keys and in Sets as long as their elements are. Tuple2 also has swap.
val t: (String, Int, Boolean) = ("ada", 36, true) // Tuple3[String, Int, Boolean]
t._1 // "ada"
val pair = "k" -> 42 // ("k", 42): -> builds a Tuple2
pair.swap // (42, "k")
val (name, age, active) = t // destructuring definition
t match
case (n, a, true) if a >= 18 => s"$n is an active adult"
case (n, _, _) => s"$n"
("a", 1) == ("a", 1) // true: structural equality and hashCodeThe destructuring definition val (name, age, active) = t is really a pattern match. If the right-hand side has the exact tuple type, it cannot fail. If it is something wider, for example an Any or a value that might be a different shape, a mismatch throws MatchError at runtime. Scala 3 warns when such a pattern is refutable; treat that warning as an error.
What a tuple is at runtime
In both Scala 2 and Scala 3, a tuple of up to 22 elements is an instance of one of the library classes scala.Tuple1 to scala.Tuple22. Each is a final case class with one field per position, so a pair is a small object with two references. In Scala 2 those 22 classes are the whole story: there is no common supertype with useful operations beyond Product, so you cannot write a method that works on tuples of any arity, and 22 is a hard limit, the same limit that once applied to case class fields and function parameters.
Scala 3 keeps the same classes for arities 1 to 22, so tuples remain binary compatible and cheap, and adds scala.runtime.TupleXXL for larger tuples, which stores its elements in an array. Code that needs a 30-column row can now have one, though it is rarely a good idea in source code; it mostly matters for generated code and derivation.
Scala 3: tuples as a type-level list
In Scala 3 every tuple type is also a chain built from two pieces: EmptyTuple and the infix type constructor *:. The type (Int, String, Double) is the same as Int *: String *: Double *: EmptyTuple. The common supertype is Tuple, and NonEmptyTuple sits under it. Because the type is a list, the standard library can define operations whose result types are computed: ++ concatenates and the result type is the concatenation, head and tail have exact types, size is known at compile time, and map applies a polymorphic function to each element while tracking each element's type.
// Scala 3: tuples are a type-level list built from *: and EmptyTuple.
val t = (1, "two", 3.0)
val longer = t ++ (true, 'c') // (1, two, 3.0, true, c), Int *: String *: ...
longer.size // 5, known at compile time
t.head // 1: Int
t.tail // ("two", 3.0)
(0 *: t) // prepend: (0, 1, two, 3.0)
t.zip(("a", "b", "c")) // ((1,a), (two,b), (3.0,c))
// A polymorphic function maps every element, and the result type tracks each one.
val wrapped = t.map([X] => (x: X) => Option(x))
// wrapped: (Option[Int], Option[String], Option[Double])
// Type-level arithmetic over tuple types.
type Row = (Int, String)
summon[Tuple.Concat[Row, (Boolean, Double)] =:= (Int, String, Boolean, Double)]
// Generic code over any arity: render every element, whatever the tuple's size.
def render(t: Tuple): List[String] = t.toList.map(_.toString)The companion object Tuple holds match types that compute these results, such as Tuple.Concat, Tuple.Size, Tuple.Map and Tuple.Zip. Library authors use them, with Mirror from scala.deriving, to write generic derivation: a case class can be viewed as the tuple of its field types, so one function that recurses over a tuple type can derive an encoder, an equality instance or a schema for any case class. Application code rarely writes match types itself, but it benefits every time a JSON or database library derives an instance without macros you have to maintain.
A limit to keep in mind: the precise types only survive while the compiler knows the tuple's shape. Once a value is typed as plain Tuple, as in the render function above, you are back to toList and runtime inspection, and element types are lost.
Tuples in collections and folds
Most tuples in real code come from the collections library. Map entries are pairs, zip and zipWithIndex produce pairs, unzip and unzip3 split them apart, partition returns a pair of collections, and groupBy followed by map often produces pairs to build a new Map. Folds use tuples to carry several accumulators in one pass, which avoids traversing a large collection three times. Fold, map and filter covers the combinators themselves and Scala collections covers which collection to choose.
// One pass, three results, no mutable state: the accumulator is a tuple.
def stats(xs: Seq[Double]): Option[(min: Double, max: Double, mean: Double)] =
if xs.isEmpty then None
else
val (lo, hi, sum) = xs.foldLeft((Double.MaxValue, Double.MinValue, 0.0)) {
case ((lo, hi, sum), x) => (lo min x, hi max x, sum + x)
}
Some((min = lo, max = hi, mean = sum / xs.size))
stats(Seq(4.0, 9.0, 2.0)).map(s => s.max - s.min) // Some(7.0)
// Collections produce and consume tuples everywhere.
val prices = Map("apple" -> 1.2, "pear" -> 0.9) // entries are (String, Double)
val (names, values) = prices.toList.unzip
val indexed = List("a", "b").zipWithIndex // List((a,0), (b,1))
prices.map((k, v) => k.toUpperCase -> v * 2) // Scala 3 parameter untupling
// Scala 2 needs a pattern: prices.map { case (k, v) => ... }Worked through for Seq(4.0, 9.0, 2.0): the accumulator starts at (MaxValue, MinValue, 0.0), becomes (4.0, 4.0, 4.0), then (4.0, 9.0, 13.0), then (2.0, 9.0, 15.0). The mean is 15.0 / 3 = 5.0 and max minus min is 7.0. The function returns a named tuple, so callers read s.max rather than s._2, which is the subject of the next section.
Scala 3 also adds parameter untupling: a function literal with two parameters is accepted where a function of one pair is expected, so prices.map((k, v) => ...) compiles. In Scala 2 you write a pattern-matching anonymous function, prices.map { case (k, v) => ... }, which is equivalent but noisier.
Named tuples
Named tuples, proposed in SIP-58, were experimental in Scala 3.5 and are a stable feature from Scala 3.7.0. A named tuple type lists a name for each element, and its values are built with name = value syntax. Fields are read by name, and patterns can match by name. At runtime the names are erased: a named tuple is the same TupleN object as an unnamed one, so they cost nothing extra.
// Scala 3.7+: named tuples are stable.
type Person = (name: String, age: Int)
val ada: Person = (name = "Ada", age = 36)
ada.name // "Ada"
ada match
case (name = n, age = a) if a > 30 => s"$n, over 30"
case (name = n, age = _) => n
val plain: (String, Int) = ada.toTuple // drop the names explicitly
val back: Person = ("Grace", 45) // an unnamed tuple conforms to the named type
case class City(name: String, population: Long)
type CityFields = NamedTuple.From[City] // (name: String, population: Long)The conversion rules are deliberately one-way. An unnamed tuple with matching element types conforms to a named tuple type, so you can assign ("Grace", 45) to a Person. Going the other way you call toTuple, which forgets the names; the compiler inserts it automatically when the expected type is an unnamed tuple, but not inside type constructors, so a List of named tuples is not a List of plain tuples. NamedTuple.From turns a case class type into the named tuple of its fields, which query and data-frame libraries use to describe result rows.
Named tuples fill the gap between a bare pair and a case class: a local result with two or three fields where _1 and _2 would be unreadable, but where a top-level class with a name, a file and a companion object is too much ceremony.
What tuples cost
Every tuple is an object allocation. A pair holds two references, so its elements are boxed when they are primitives in a generic context: a (Int, Int) produced inside generic collection code generally holds two java.lang.Integer objects, plus the tuple itself. For a few thousand values this does not matter. In a hot loop over millions of elements it shows up as allocation rate and GC pressure.
The JIT compiler can remove short-lived tuples through escape analysis when a tuple is created and destructured within inlined code and never escapes, which is why a fold with a tuple accumulator is often cheaper than it looks. You cannot rely on it across megamorphic call sites or when tuples are stored in collections. If a profile shows tuple allocation as a hotspot, the fixes are a specialised case class with primitive fields, parallel primitive arrays, or a mutable local accumulator inside a function that stays pure from the outside. Measure before and after; the profile, not intuition, decides.
Arrays inside tuples deserve a separate warning. Tuple equality delegates to element equality, and JVM arrays compare by reference, so (Array(1), 1) == (Array(1), 1) is false. IArray does not help, because it is an opaque alias over Array. Use Vector or ArraySeq, compare explicitly with sameElements, or keep arrays out of values you compare.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
| Positional soup | x._2._1 travels across modules and nobody remembers what it means | Named tuple or case class at module boundaries |
| Refutable destructuring | val (a, b) = value of a wider type throws MatchError at runtime | Match explicitly; treat the warning as an error |
| Chained arrows | a -> b -> c is ((a, b), c), not a triple | Write (a, b, c) |
| Argument adaptation in Scala 2 | f(1, 2) on a one-parameter method silently becomes f((1, 2)) | Compile with -Xlint:adapted-args |
| Arrays as elements | Equality and hashing are by reference | Use immutable sequences |
| Tuples in public APIs | Adding a field changes the type and breaks every caller | Case class, which can evolve with defaults |
| Spark Datasets of tuples | Columns are named _1 and _2 in every downstream query | Case class or select with aliases |
Tuple or case class
A case class gives you a type name, field names, documentation, a place for methods and validation, and the ability to add a field with a default value without breaking callers. A tuple gives you none of that and needs no declaration. The decision is mostly about how far the value travels. Case classes in depth covers what the compiler generates for them.
| Situation | Use |
|---|---|
| Intermediate value inside one function or a short pipeline | Plain tuple |
| Small result returned from a private or local helper | Named tuple |
| Map entries, zip results, fold accumulators | Plain tuple, destructured immediately |
| Value in a public API, a message, a database row or JSON | Case class |
| Something with invariants or behaviour | Case class with a smart constructor |
| Either a value or an error | Not a tuple at all: Either |
A practical rule many teams adopt: a tuple may not cross a module or service boundary, and destructure it on the line where it arrives. Following it keeps tuples doing what they are good at, gluing values together briefly, and stops them from becoming an anonymous data model.
What to do next
- Search your codebase for chained position accessors such as ._2._1 and replace them with destructuring, named tuples or case classes.
- If you are on Scala 3.7 or later, use named tuples for multi-value returns from local helpers.
- Turn on -Xlint:adapted-args in Scala 2 builds, and fail the build on refutable pattern warnings in Scala 3.
- Write one fold with a tuple accumulator over a real dataset, then profile it, so you know whether tuple allocation matters in your workload.
- Rewrite one small generic utility with Scala 3 tuple operations such as ++, map or zip to learn how the result types are computed.
- Audit public APIs, messages and Spark Datasets for tuples and move them to case classes before they gain more callers.