Tuples

Use tuples for fixed-shape data, with precise rules for packing, unpacking, shallow immutability, hashing, and named records.

level beginner time 13 min at Standard depth
version Python 3.14
what

A tuple is an ordered sequence whose slots cannot be reassigned, making it useful for fixed-position, fixed-length data.

trap

A tuple fixes only its element references; it does not freeze mutable objects inside it, and a tuple containing a list cannot be a dictionary key.

fix

Keep the trailing comma in a one-item tuple and make unpacking shapes explicit; use NamedTuple or a data class when field meaning matters.

What it is and why it exists

A tuple is Python’s built-in immutable sequence. It preserves element order and supports indexing, slicing, iteration, and membership tests, but it has no operation for assigning, appending, or deleting a slot. Tuples are commonly written as (region, count), although the comma creates the tuple, not the parentheses.

Tuples solve the problem of passing a fixed number of positional values as one unit. Coordinates, composite dictionary keys, multiple function results, and short records whose fields should not be added or removed all fit. A tuple tells the reader that the data shape is stable rather than a collection waiting to grow.

A list suits a changing number of similar elements; a tuple suits a fixed structure whose positions have agreed meanings. That distinction does not mean every object inside a tuple is immutable, or that a tuple is right for all read-only data. Once a record has enough fields that callers must remember what record[3] means, position alone is no longer clear enough.

A named tuple keeps tuple indexing, iteration, and unpacking while giving every position a field name. Modern typed code commonly declares such a record with typing.NamedTuple; code that generates fields dynamically can still use collections.namedtuple(). A named tuple extends a tuple, but it is not a runtime data validator.

How it works

A comma creates a tuple

When an expression list contains a comma, Python packs its items into a tuple. Parentheses often only group an expression or improve readability, so (42) is the integer 42, while (42,) is a one-item tuple. An empty tuple has no items to separate with a comma and must therefore be written as ().

Creating a tuple from several expressions is tuple packing . point = 3, 4 and point = (3, 4) produce the same value. A function that returns minimum, maximum also constructs a two-item tuple first.

tuple(iterable) instead consumes an iterable and builds a tuple. tuple("ab") produces ("a", "b"), not the one-item tuple ("ab",). If the argument is already a tuple, the language permits the implementation to return the same object; code must not rely on a redundant copy for isolation.

Constructors consume their input

The argument to tuple() is an iterable, not an arbitrary value destined for one slot. Given a generator or iterator, the constructor reads until iteration ends and then returns the complete tuple. The original iterator is normally exhausted afterward.

This conversion fixes the outer element sequence and is useful at a boundary that needs repeated traversal or stable length. It still copies only element references rather than copying elements recursively. Mutable elements that already existed in the input may remain shared with the tuple after conversion.

Python has no dedicated “tuple comprehension” syntax. (transform(item) for item in source) is a generator expression; write tuple(transform(item) for item in source) to build a tuple immediately. Eager consumption changes when exceptions appear and whether the operation can work with an infinite iterator.

Some built-in iteration tools yield tuples one at a time. enumerate(values) yields two-item tuples of indexes and values, while zip(left, right) yields tuples of corresponding input items. Unpacking in a loop target consumes those items directly, usually without converting the complete iterator to a tuple first.

The slots are immutable

After creation, a tuple’s length and the objects referenced by its slots cannot change. items[0] = value, append(), and pop() are not supported tuple updates. Concatenation, repetition, and slicing produce tuple results instead of modifying the original object.

This is shallow immutability. If a slot refers to a list, that list can still be changed through its own interface while the tuple continues to refer to it. You therefore cannot infer that the entire reachable object graph is immutable merely because the outer object is a tuple.

Rebinding a name is not tuple mutation either. When route += ("checkout",) runs, Python computes a new tuple and makes route refer to it; the original tuple stays unchanged. Other names that still refer to the original tuple do not see the new element.

Sequence operations and comparison

Tuples use the common sequence protocol. An integer index reads one element, a slice reads a new tuple, len() reports the element count, and in searches by equality. The only two additional public tuple methods are count() and index().

Two tuples compare lexicographically: Python compares the first pair of unequal elements, or puts the shorter tuple first when their common prefix is equal. The elements involved must support the requested comparison. Mixed types that cannot be ordered may raise TypeError at runtime, so an arbitrary heterogeneous tuple is not automatically a safe sort key.

Equality also compares elements in order and requires equal lengths. (1, 2) == (1, 2) is true, while (1, 2) == [1, 2] is false because the sequence types differ. Each member object’s equality rules still contribute to the result.

Unpacking binds by shape

Sequence unpacking binds values produced by the iterable on the right to targets on the left. Without a starred target, the number of values must match exactly; too many or too few raises ValueError. The syntax works with any iterable, not only tuples.

The left side can contain at most one starred target, as in first, *middle, last = values. It collects every value not consumed by another target and always produces a list, even when the right side is a tuple. Nested targets keep checking the inner shape, so name, (x, y) = record expresses two structural levels.

Python evaluates the right side before assignment, which makes left, right = right, left a safe swap. Multiple function results use the same mechanism: the function returns one tuple, then the caller either keeps it whole or unpacks its shape. If a public API changes the length of a returned tuple, every fixed-length unpacking caller may break.

Starred syntax depends on context

A star means expansion or collection in three similar-looking contexts, but the results differ. When reviewing generated code, first identify the syntax position and then decide whether it produces a list, a tuple, or function arguments.

  • In the assignment target head, *tail = values, tail collects into a list.
  • In the tuple display (*left, *right), items from the iterables enter a new tuple.
  • In the function call send(*values), the items become separate positional arguments.

Call expansion does not pass “one tuple argument.” If send() declares one parameter while values has three elements, send(*values) attempts to supply three positional arguments and triggers signature checking. Write send(values) when the tuple itself must be one argument.

A starred assignment target likewise does not preserve the right-side container type. The collected part becomes a list even when the input is a tuple. If the caller needs an immutable result, convert it explicitly after unpacking according to the contract rather than assuming the starred target inherits the input type.

Hashing depends on every element

A hashable object can be a dictionary key or set member. A tuple is hashable only when every element is hashable; an immutable outer tuple cannot compensate for a list or dictionary inside it. (region, year) is usually a suitable composite key, while (region, tags_list) is not.

This rule protects the lookup invariants of hash-based containers. Data used by a key’s equality behavior must not change in a way that breaks hash consistency while the key is stored. Review the complete nested structure of a composite key rather than stopping when its outer object is a tuple.

Examples

Tuple syntax and sequence operations

The first example distinguishes a grouping expression, a one-item tuple, and an ordinary multi-item tuple. It also shows slicing and concatenation without modifying the original value.

tuple_basics.py
empty = ()
not_a_tuple = (42)
singleton = (42,)
route = ("home", "catalog", "product")

print(type(empty).__name__, len(empty))
print(type(not_a_tuple).__name__)
print(type(singleton).__name__, singleton)
print(route[0], route[-1])
print(route[1:])

extended = route + ("checkout",)
print(route)
print(extended)
print(route.count("catalog"), route.index("product"))
tuple 0
int
tuple (42,)
home product
('catalog', 'product')
('home', 'catalog', 'product')
('home', 'catalog', 'product', 'checkout')
1 2

extended is a new tuple, so printing route still shows three elements. The comma in ("checkout",) is required; ("checkout") would be a string and could not be concatenated with a tuple.

An index returns the object in one slot, while a slice returns a tuple. count() reports the number of equal elements and index() returns the position of the first equal element; index() raises ValueError when it finds no match.

Return values and nested unpacking

The next example returns a summary tuple from a function. Its caller first unpacks three positions and then collects several middle items into a starred target.

unpack_orders.py
def summarize_orders(amounts):
    total = sum(amounts)
    return len(amounts), total, total / len(amounts)


count, total, average = summarize_orders((18, 24, 30))
print(f"count={count} total={total} average={average:.1f}")

shipment = ("PKG-204", (48.86, 2.35), "packed", "priority")
tracking_id, (latitude, longitude), *labels = shipment

print(tracking_id)
print(f"{latitude:.2f}, {longitude:.2f}")
print(labels, type(labels).__name__)

left, right = "cold", "hot"
left, right = right, left
print(left, right)
count=3 total=72 average=24.0
PKG-204
48.86, 2.35
['packed', 'priority'] list
hot cold

summarize_orders() really returns a three-item tuple. Fixed-length unpacking writes the return shape into caller code, so the function and its callers should test empty input and return shape together; this example accepts only nonempty data.

The nested (latitude, longitude) target checks that the coordinates contain exactly two values. labels uses a starred target, so its result is a list. The final swap evaluates both right-side objects before updating the left-side names and needs no temporary variable.

Composite dictionary keys

Tuples commonly combine independent dimensions into one dictionary key. This example also verifies that an outer tuple does not make an inner list hashable.

coordinate_index.py
temperatures = {
    ("Paris", 9): 19.5,
    ("Paris", 10): 21.0,
    ("Lyon", 9): 18.0,
}

city_hour = ("Paris", 10)
print(temperatures[city_hour])
print(("Lyon", 10) in temperatures)

candidate_key = ("Paris", [9, 10])
try:
    temperatures[candidate_key] = 20.0
except TypeError as error:
    print(type(error).__name__, str(error))

stable_key = ("Paris", (9, 10))
temperatures[stable_key] = 20.0
print(temperatures[stable_key])
21.0
False
TypeError cannot use 'tuple' as a dict key (unhashable type: 'list')
20.0

Both elements of ("Paris", 10) are hashable, so the tuple can participate in stable dictionary lookup. The candidate key contains a list, and hashing the whole tuple fails at that element. Once the time range is also a tuple, every element in this particular structure is hashable.

Converting a list to a tuple only creates a snapshot; it does not automatically normalize domain data. If key casing, time zones, or numeric units can differ, define the normalization policy before constructing the composite key.

Records with NamedTuple

Once a positional record grows beyond a few fields, attribute names are usually easier to review than bare indexes. A NamedTuple is still a tuple, so it can be unpacked, used as a key, and copied with selected field changes through _replace().

shipment_record.py
from typing import NamedTuple


class Shipment(NamedTuple):
    tracking_id: str
    status: str
    checkpoints: tuple[str, ...] = ()

    def advance(self, place: str) -> "Shipment":
        return self._replace(
            status="in_transit",
            checkpoints=(*self.checkpoints, place),
        )


shipment = Shipment("PKG-204", "packed")
moved = shipment.advance("Paris")

print(shipment)
print(moved.status)
print(moved.checkpoints)
print(moved[0])
tracking_id, status, checkpoints = moved
print(tracking_id, status, len(checkpoints))
Shipment(tracking_id='PKG-204', status='packed', checkpoints=())
in_transit
('Paris',)
PKG-204
PKG-204 in_transit 1

advance() does not modify the original record; it returns a new instance built by _replace(). The checkpoints field is also a tuple, preventing callers from changing it through list methods. The output still demonstrates indexing and unpacking, but domain code should prefer the field names.

Type annotations help static checkers catch errors, but they do not make the constructor reject every mismatched value at runtime. Input from JSON, a database, or the command line still needs validation at the boundary. Use a data class or validation model when you need runtime checks, mutable fields, keyword-only initialization, or complex inheritance.

Pitfalls

Parentheses do not make a one-item tuple

Fix: use (value,), and test an empty string, an ordinary string, and an existing tuple as inputs. Keep trailing commas when formatting a tuple across several lines so adding or removing a line cannot silently change the syntax.

Immutability is not recursive

Fix: convert nested collections to immutable representations when ownership requires it, or copy the mutable objects that need isolation. Do not inspect only the outer container; tests should mutate a nested value after return and observe the original state.

A tuple is not always hashable

Fix: verify the hash contract at every level of a composite key and prefer semantically stable scalars or immutable values. Do not call str() on a mutable value merely to suppress the exception; unstable or ambiguous representations produce incorrect keys.

Fixed-length unpacking exposes the return shape

Fix: return the same shape from every branch and test success, empty input, and error paths separately. When a public record needs to evolve, an object with named fields is usually safer than extending a positional tuple again.

NamedTuple annotations do not validate at runtime

Fix: treat NamedTuple as a static typing aid and record representation, and explicitly parse and validate untrusted input at the boundary. If construction must enforce invariants, choose a validating class or model and test its rejection paths.

Mutable defaults are shared between records

Fix: use only genuinely shareable immutable defaults, such as tags: tuple[str, ...] = (). When every instance needs a new mutable object or a default factory, use a data class with default_factory and test that two default-constructed instances are isolated.

Deep Tuple boundaries and object identity

Tuple boundaries and object identity

A tuple stores object references. Reading values[0] returns the object referenced by that slot, not a copy that the language creates automatically. Two different tuples can refer to the same mutable object, so mutating the object reached through one tuple is observable through the other.

A tuple’s object identity and value equality are separate properties. Independently constructed (1, 2) tuples can compare equal without having to be the same object. Use == to describe value equality and reserve is for a genuine same-object question; implementation details such as compiler constant reuse cannot support application logic.

Slicing, concatenation, and repetition specify the value and type of their result, but they do not promise to copy elements recursively. In particular, ([0],) * 3 repeats one list reference rather than creating three independent lists. Construct a nested object explicitly on each iteration when the objects must be independent.

For a tuple, += on a name attempts the in-place addition protocol, receives a concatenated result because the tuple cannot be changed in place, and then rebinds the name. Seeing the value under a name grow does not mean the original tuple mutated. Track the container object, element objects, and name bindings separately when diagnosing shared state.

Recursive conditions for hashing and equality

Tuple equality delegates to corresponding elements in order. Hashing must likewise combine element hashes, so any unhashable element makes the complete tuple unhashable. This condition is recursive: an inner tuple helps the outer tuple become a key only if every one of its elements also meets the requirement.

Hashable does not mean “syntactically immutable.” A custom class may keep the default identity hash even though it has mutable attributes, while another immutable-style class may deliberately disable hashing after defining equality. Choose dictionary keys from their equality and hash contract, not a guess based on class names or surface syntax.

A composite key also makes data normalization part of the interface. ("Paris", 9), ("paris", 9), and ("Paris", "09") are distinct tuple values. If the domain treats them as equivalent, normalize casing and types before building the key and reuse the same normalization function on write and read paths.

Plain tuples, named tuples, and data classes

Start record-shape decisions from the interface callers need. A plain tuple suits very short data whose positional meaning is obvious in its local context. Once a position needs a comment to explain it, field names usually reduce review cost.

FormSuitable contractMain limitation
Plain tupleShort, fixed sequence consumed by positionField meaning does not travel with the value
NamedTupleRead-only record that still needs indexing and unpackingAnnotations do not validate at runtime; shape changes affect unpacking callers
Data classDomain object needing clear fields, custom initialization, or a mutability policyDoes not expose tuple indexing and unpacking by default

A NamedTuple class is a subclass of tuple, so its instances retain positional operations. _fields exposes field names, _asdict() provides a field mapping, and _replace() returns a new instance with selected changes. These leading-underscore names are documented named-tuple APIs, although a domain wrapper can still provide more meaningful methods.

Tuple compatibility also makes positional order part of the contract. Inserting a field into the middle of a named tuple changes indexes, iteration, and unpacking even when attribute-based code appears unaffected. A public model that will evolve, needs keyword-only parameters, or requires runtime validation should not preserve the tuple protocol merely to remain “lightweight.”

Tuple shapes at API boundaries

When a function returns a tuple, element count, order, and meaning together form its return contract. The annotation tuple[int, str] can describe a fixed shape, while tuple[str, ...] describes any number of similar elements. Neither annotation automatically checks the actual return value at runtime.

How a caller uses the result determines change risk. Code that forwards the tuple as a whole may tolerate a new trailing element, fixed-length unpacking fails immediately, and code reading old numeric indexes may keep running while ignoring new information. The latter two behaviors both need compatibility tests; checking the function alone is insufficient.

Return-value changeEffect on fixed unpackingEffect on index access
Add an element at the endRaises ValueErrorOld indexes usually retain their fields
Swap two elementsStill runs, but bindings change meaningStill runs, but fields change meaning
Return None on one branchRaises TypeErrorRaises TypeError on access

An order change is especially dangerous because code may continue with incorrect semantics. Tests for tuple-returning functions should assert the complete result or use domain-named variables instead of checking only the element count. Once a returned record has several long-lived consumers across modules, named fields are usually easier to evolve and review.

When a function collects positional arguments as *args, its body receives a tuple. That tuple represents the positional arguments supplied for one call; it does not prove that all the values share a type. Forwarding it as target(*args) expands it again, so a wrapper must also handle keyword arguments, signature constraints, and error propagation correctly.

Plain tuples work best for stable, short shapes in a public API. If callers need optional fields, a versioning policy, or runtime validation, choose a record type that can state those rules explicitly. The container form is an interface decision, not merely an implementation detail.

A compatibility review for a tuple API should answer four questions:

  • Does every position have one stable domain meaning?
  • Do all return branches provide the same element count and order?
  • Do callers forward the result whole, use numeric indexes, or unpack a fixed length?
  • Should a new field extend the positional contract or move the API to a named record?

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

next up Functions Collections soon Dataclasses soon Type hints soon Sets
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?