A bag for things that don't match
You're writing a function that returns the minimum and maximum from a list of numbers. Two values, both i32, but they mean different things. You don't want to make a whole struct just for this. And you definitely don't want two separate function calls (slow, awkward). What you want is a tiny container that holds two values together and lets you pull them apart when you need them. That's a tuple.
A tuple in Rust is a fixed-size group of values, where each slot can be a different type. Think of it like a small, sealed envelope: you decide at the moment of creation what's inside (one number, one float, one byte, say), and from then on the shape doesn't change. You can't add a fourth item later. You can't swap out the second slot for a string. The shape is part of the type.
That last part trips up people coming from Python or JavaScript. In Python, a tuple is sort of a frozen list. In Rust, a tuple is more like a one-off struct without a name. The compiler tracks every slot's type individually. (i32, f64, u8) and (i32, f64, i32) are different types and the compiler will treat them as such.
The basics: making one and taking it apart
Here's the smallest useful example.
fn main() {
// Three values, three different types, all bundled into one variable.
let tup: (i32, f64, u8) = (500, 6.4, 1);
// Destructuring: pull each slot into its own named variable.
// The pattern (x, y, z) on the left mirrors the shape on the right.
let (x, y, z) = tup;
println!("{x}, {y}, {z}");
// Or grab a single slot by index. Tuples use .0, .1, .2 syntax,
// not [0], because tuple slots can be different types and arrays can't.
println!("{:.1}", tup.1);
}
When this runs, you get 500, 6.4, 1 and then 6.4. Notice two things. First, the type annotation (i32, f64, u8) is optional here. Rust would infer it. We wrote it out so the shape is obvious. Second, tup.1 is how you reach into a tuple by position. You can't write tup[1] because the index has to be a compile-time constant. That's not just a syntactic quirk: Rust needs to know at compile time which type comes out, since each slot's type is independent.
If you tried to do tup[1], the compiler would say:
error[E0608]: cannot index into a value of type `(i32, f64, u8)`
Why the shape is fixed
Here's an "ah-ha" worth pausing on. A tuple isn't a list. A list (Vec<T>) is for an unknown number of values, all the same type. A tuple is for a known number of values, possibly different types. The compiler bakes the size and the layout of each slot directly into the type.
That has a consequence. You can't loop over a tuple like you can loop over a vec, because the types of each slot are not the same and a for loop expects to bind a single T per iteration. Try this and watch the compiler refuse:
let tup = (1, 2.0, "three");
// for item in tup { // won't compile
// println!("{}", item);
// }
The compiler will tell you tup doesn't implement IntoIterator. That's not a bug. That's the language saying "you're trying to treat heterogeneous data uniformly. Use a struct or an enum instead."
Returning multiple values from a function
The most common real use of tuples is functions that produce more than one result. Standard library function str::split_at is a classic example: it splits a string at an index and returns both halves. Let's write something similar for ourselves.
// Returns (min, max) from a slice. Both are i32, but they mean different
// things, and a tuple is the lightest way to bundle them.
fn min_max(values: &[i32]) -> (i32, i32) {
// Start with the first element as both min and max.
// We're assuming the slice is non-empty; otherwise we'd return an Option.
let mut min = values[0];
let mut max = values[0];
// Walk every element, narrowing min and stretching max.
for &v in values {
if v < min { min = v; }
if v > max { max = v; }
}
// Build the tuple in one expression and return it.
(min, max)
}
fn main() {
let scores = [42, 17, 99, 8, 73];
// Destructure right at the call site. This reads cleanly:
// "give me the lo and hi of scores".
let (lo, hi) = min_max(&scores);
println!("Range: {lo} to {hi}");
}
You could have made a Range struct with named fields min and max. For a one-shot helper, that's overkill. The tuple is throwaway, the destructuring at the call site reads almost like English, and there's no boilerplate.
But there's a flip side. If your tuple has four or five fields, the call site stops being readable. let (a, b, c, d, e) = stuff(); is a code smell. At that point, name your fields. Make a struct.
The unit type, a tuple with zero slots
This one surprises people. The empty tuple () is a real type in Rust, called the unit type. It has exactly one value, which is also written (). Functions that don't return anything actually return () implicitly:
// Both of these have the same return type: ().
fn does_nothing() {}
fn does_nothing_explicit() -> () {}
You'll see () show up in a few places. Result<(), io::Error> means "an operation that either succeeds with no useful value or fails with an io error". println! returns (). The unit type is Rust's way of saying "there is nothing to give back, but the slot is still typed".
Pattern matching on tuples
Tuples shine inside match. Because the shape is part of the type, you can pattern-match on each slot at once. This is great for handling pairs of related conditions.
// Decide what to print based on whether each value is positive, zero, or negative.
fn describe(pair: (i32, i32)) -> &'static str {
match pair {
// Both zero. The literal pattern matches exact values.
(0, 0) => "origin",
// First is zero, second is anything. _ means "match anything, ignore it".
(0, _) => "on the y-axis",
// Same idea, swapped.
(_, 0) => "on the x-axis",
// Both non-zero. Bind to names so the body can use them.
(x, y) if x > 0 && y > 0 => "first quadrant",
// Catch-all. Any other combination falls here.
_ => "somewhere else",
}
}
The if x > 0 && y > 0 part is a match guard. It lets you add a runtime condition on top of a structural pattern. Pattern matching is one of the places Rust feels most expressive, and tuples let you exploit it for related-but-not-identical pairs of values.
Common pitfalls
A few things bite newcomers.
The single-element tuple needs a trailing comma. (5) is just an i32 in parentheses. (5,) is a tuple of one element. The comma is what makes it a tuple.
let not_a_tuple = (5); // type: i32
let one_tuple = (5,); // type: (i32,)
Tuples don't auto-print with {}. You need {:?} and the type has to derive Debug. Standard primitive tuples already do, so:
let t = (1, 2, 3);
println!("{:?}", t); // works
// println!("{}", t); // error E0277: doesn't implement Display
You can't grow a tuple. If you need to "add" a fourth slot, you're really creating a new tuple of a new type. That's fine for short pipelines, but it tells you the moment you want growth or named fields, you've outgrown the tuple.
When to use a tuple, when to reach for a struct
A useful rule of thumb. If the values together have a name in your head (a Point, a Range, a User), make a struct. Named fields document themselves and survive refactoring. If the bundle is local, throwaway, and the names would be redundant noise, a tuple is fine. Multiple return values are the textbook case.
For more than three fields, almost always struct. Once you write tup.3 your reader has to count slots to know what they're looking at. That's a tax you pay every time someone reads the code.
If you're collecting heterogeneous values that need to grow at runtime, you don't want a tuple at all. You want either a Vec<Enum> where the enum encodes the variants, or, for fixed shape with named slots, a struct. Tuples sit in a narrow but useful sweet spot.
Where to go next
Tuples are one of the small, foundational shapes Rust gives you. Once you're comfortable with them, the next steps are the things they shade into: full structs (named fields), pattern matching (more exhaustive), and the _ placeholder (which is half-cousin to tuple destructuring).
How does ownership work with structs in Rust