Tuples perform two jobs that look similar in syntax but differ in meaning. A tuple can be a sequence: an ordered, fixed collection traversed by position. It can also be a record: a product of fields whose positions stand for concepts such as latitude, longitude, and altitude.

That dual role makes tuples wonderfully lightweight and easy to misuse. (42, "open", 7) has a shape, but the shape lives in surrounding knowledge. A named tuple or data class spends additional machinery to put names, types, defaults, methods, and mutation policy closer to the value.

The engineering question is not "which object is smallest?" It is "what does this shape need to communicate and enforce, and what does that cost at the scale where it matters?"

Version note. Examples target Python 3.10 through 3.14 and were verified on 64-bit CPython 3.14.7. Tuple ordering, immutability, unpacking, equality, and hashability behavior are language-level semantics. Object sizes, free lists, bytecode instructions, and generated class layouts are CPython details.

A tuple freezes references, not their targets

Tuple immutability means its length and element references cannot be changed after construction. It does not recursively freeze the objects those references reach:

Pyodide / WebAssembly
items = ([1, 2], {"state": "new"})

items[0].append(3)
items[1]["state"] = "ready"
print("[state] tuple after referent mutation:", items)

try:
    items[0] = []
except TypeError as error:
    print("[error] tuple item assignment:", type(error).__name__)

values = ([1, 2],)

try:
    values[0] += [3]
except TypeError as error:
    print("[error] augmented assignment:", type(error).__name__, values)
([1, 2, 3], {'state': 'ready'})
TypeError
TypeError ([1, 2, 3],)

The tuple still points at the same list and dictionary. Their contents changed. This distinction matters for APIs that promise an immutable snapshot. Returning tuple(records) prevents callers from adding or removing record references, but it does not protect mutable records from modification.

If the domain needs deep immutability, every reachable component needs suitable semantics: tuples instead of lists, frozensets instead of sets, immutable mappings or copied values, and immutable domain objects. Even then, resources referenced indirectly may have external state. "Deeply immutable" is a property of a whole object graph and its abstractions, not a tuple constructor.

The augmented-assignment result follows from the same rules. In-place list addition mutates the list, then assignment tries to store the result back into tuple slot zero and fails. The exception does not roll back the mutation. Avoid compound assignment through immutable-container subscripts when the contained object can mutate.

Hashability is recursive

Tuples are often introduced as hashable lists. More precisely, a tuple is hashable only if hashing every element succeeds:

Pyodide / WebAssembly
good = ("tenant-7", 42, frozenset({"read", "write"}))
bad = ("tenant-7", [42])

print("[result] composite key hash:", hash(good))
print("[result] dictionary lookup:", {good: "allowed"}[good])

try:
    hash(bad)
except TypeError as error:
    print("[error] unhashable tuple element:", type(error).__name__)

cache = {}
tenant_id = "acme"
resource_id = 481
cache[(tenant_id, resource_id)] = "document"

print("[result] composite cache lookup:", cache[("acme", 481)])

The numeric hash is process- and implementation-dependent; the remaining output is allowed, TypeError, and document. A tuple does not hide an unhashable list from a dictionary or set.

The deeper contract is stability. Equal hashable objects must have equal hashes, and their equality-relevant state must not change while they are stored in a hash table. A tuple containing a custom object may technically hash even if that object later changes its own hash. That produces the same lost-key failure as any mutable dictionary key.

Use tuples as composite keys when positions have stable, obvious value semantics, as in the cache lookup above. When fields can be transposed, evolve independently, or need validation, a frozen named record communicates more. Hashability alone is not sufficient design guidance.

Python guarantee. Tuple equality is lexicographic element equality. A tuple can be hashed only when all elements are hashable. Exact hash values and the mixing algorithm are not stable identifiers and must not be persisted.

Parentheses do not make the tuple

Commas construct tuples; parentheses usually group expressions:

Pyodide / WebAssembly
print("[check] grouped expression type:", type((1)).__name__)
print("[check] one-item tuple type:", type((1,)).__name__)
print("[result] parsed print arguments:", (1, 2) == 1, 2)


def bounds(values):
    return min(values), max(values)


result = bounds([8, 3, 5])
low, high = result

print("[result] returned value and type:", type(result).__name__, result)
print("[result] unpacked bounds:", low, high)
int
tuple
False 2
tuple (3, 8)
3 8

The third print call is parsed as two arguments: (1, 2) == 1 and 2. This is a compact reminder that tuple displays and surrounding syntax can interact unexpectedly. Parenthesize deliberately where a tuple expression would otherwise be ambiguous.

Function returns are no exception. return left, right returns one two-element tuple. Multiple assignment then unpacks an iterable; it is not a special multiple-return channel:

That tuple allocation may be optimized in some calling patterns by a present or future interpreter, but the observable value behaves as a tuple.

Packing and unpacking in CPython 3.14 bytecode

Disassembly distinguishes language semantics from one interpreter's execution plan:

import dis


def swap(left, right):
    left, right = right, left
    return left, right


dis.dis(swap)


def split(values):
    first, *middle, last = values
    return first, middle, last


print(split(range(5)))
dis.dis(split)

The relevant CPython 3.14 instructions are:

LOAD_FAST_LOAD_FAST                 16 (right, left)
STORE_FAST_STORE_FAST               16 (right, left)
LOAD_FAST_BORROW_LOAD_FAST_BORROW    1 (left, right)
BUILD_TUPLE                           2
RETURN_VALUE

There is no BUILD_TUPLE for the swap. The compiler emits loads and stores directly, so left, right = right, left does not need to construct an observable temporary tuple in this function. The return does build a tuple.

Do not code against exact opcode names. CPython 3.11 introduced adaptive execution, 3.13 and 3.14 continued changing instruction forms, and another Python implementation can execute the same semantics differently. Disassembly is evidence about a particular build, useful for investigation rather than a portable API.

Extended unpacking has a different cost shape:

The result is:

(0, [1, 2, 3], 4)

The disassembly includes UNPACK_EX. Crucially, middle is a new list, even when the input is a tuple or range. Starred assignment promises a list for the collected values. On a hot path or huge iterable, use an iterator and explicit consumption if allocating that list is not the intended operation.

Ordinary unpacking validates arity and can consume any iterable:

Pyodide / WebAssembly
one, two = iter([10, 20])
print("[result] exact unpacking:", one, two)

try:
    one, two = [10, 20, 30]
except ValueError as error:
    print("[error] excess values during unpacking:", type(error).__name__)

The output is 10 20 and ValueError. Unpacking communicates a shape assertion: exactly two values must arrive. That is often preferable to indexing because malformed input fails immediately near the boundary.

CPython's tuple layout is deliberately small

Conceptually, a CPython tuple is a variable-sized object header followed by an inline array of pointers to its elements:

+-------------------------+
| object header, length   |
+-------------------------+
| pointer to element 0    |
| pointer to element 1    |
| pointer to element 2    |
+-------------------------+

The tuple owns references, not embedded copies of arbitrary Python objects. This is why sys.getsizeof() grows linearly with the number of slots while excluding the referenced values:

import sys


for length in range(6):
    value = tuple(range(length))
    print(length, sys.getsizeof(value))

print("tuple list")
for length in (0, 1, 3, 10, 100):
    values = list(range(length))
    print(length, sys.getsizeof(tuple(values)), sys.getsizeof(values))

On our CPython 3.14.7 build:

0 48
1 56
2 64
3 72
4 80
5 88

Each additional pointer costs eight bytes on this 64-bit build. The integer objects are not counted. Some small integer values happen to be shared by CPython, which makes recursively adding sizes especially easy to get wrong.

A list is also an object containing element pointers, but it needs mutable capacity and stores its pointer array separately. It may reserve spare slots for future append operations:

0 48 56
1 56 72
3 72 88
10 128 136
100 848 856

Constructing a list from a sized iterable can allocate close to exact capacity, so this experiment does not demonstrate every list's over-allocation. Append-driven growth has stepped capacity. The durable conclusion is semantic: tuples have fixed length and need no spare capacity; lists support mutation and growth. Exact differences are implementation details.

Shallow memory of five record shapes

Compare a three-field value represented as a tuple, list, named tuple, ordinary data class, and slotted data class:

import sys
from collections import namedtuple
from dataclasses import dataclass


PointTuple = namedtuple("PointTuple", "x y label")


@dataclass
class Point:
    x: int
    y: int
    label: str


@dataclass(slots=True)
class SlottedPoint:
    x: int
    y: int
    label: str


values = {
    "tuple": (10, 20, "origin-ish"),
    "list": [10, 20, "origin-ish"],
    "namedtuple": PointTuple(10, 20, "origin-ish"),
    "dataclass": Point(10, 20, "origin-ish"),
    "slotted": SlottedPoint(10, 20, "origin-ish"),
}

for name, value in values.items():
    shallow = sys.getsizeof(value)
    instance_dict = getattr(value, "__dict__", None)
    dict_bytes = sys.getsizeof(instance_dict) if instance_dict is not None else 0
    print(name, shallow, dict_bytes)

Our run printed:

tuple 72 0
list 88 0
namedtuple 72 0
dataclass 48 296
slotted 56 0

This table needs careful interpretation.

  • The plain tuple and named tuple have the same per-instance shallow layout here because a named tuple is a tuple subclass with field accessors on the class.
  • The ordinary data class appears smaller if you count only the instance, but its fields live in a separate __dict__. Ignoring that dictionary reverses the apparent comparison.
  • CPython key-sharing dictionaries can reduce ordinary-instance dictionary cost across many similarly shaped objects, so multiplying this first instance's 296 bytes is not a sound population estimate.
  • The slotted data class stores field references in fixed slots and has no ordinary instance dictionary by default.
  • None of the lines count the shared class object, descriptors, generated methods, integers, or string.

Measure populations, not isolated specimens, when class overhead and key sharing matter. Define whether shared referents belong to the measurement. tracemalloc around construction of 100,000 representative records often answers the operational question better than recursively summing getsizeof().

Named tuples: tuple behavior with a schema

typing.NamedTuple and collections.namedtuple() create tuple subclasses. Instances retain tuple indexing, iteration, unpacking, equality, and hash behavior while adding named field access:

Pyodide / WebAssembly
from typing import NamedTuple


class Coordinate(NamedTuple):
    latitude: float
    longitude: float


point = Coordinate(51.5, -0.1)
latitude, longitude = point

print("[check] named and positional access:", point.latitude, point[0])
print("[result] unpacked coordinate:", latitude, longitude)
print("[check] tuple-compatible equality:", point == (51.5, -0.1))

moved = point._replace(longitude=-0.2)
print("[result] original and replaced coordinates:", point, moved)
51.5 51.5
51.5 -0.1
True
Coordinate(latitude=51.5, longitude=-0.1) Coordinate(latitude=51.5, longitude=-0.2)

That final equality is both useful and potentially surprising. Named tuple equality inherits tuple semantics; different named-tuple classes with equal element values can also compare equal. Field names and record type do not participate.

Choose a named tuple when tuple interoperability is desirable: compact immutable rows, unpacking, positional APIs, and natural hashability when fields are hashable. Avoid it when type identity must affect equality, fields need validation or derived initialization, mutation is required, or adding a field must not change iterable/positional behavior.

_replace() creates a new instance rather than mutating, as the final output demonstrates. This is excellent for small value records. Frequent updates to large records may signal that an immutable positional product is the wrong model.

Data classes: nominal records with generated behavior

A data class is an ordinary class transformed to generate methods such as __init__, __repr__, and usually value-based __eq__. Unlike named tuples, unrelated data-class types do not compare equal merely because their field values align.

Pyodide / WebAssembly
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class UserId:
    tenant: str
    number: int


left = UserId("acme", 7)
right = UserId("acme", 7)

print("[check] equal values and equal hashes:", left == right, hash(left) == hash(right))

try:
    left.number = 8
except (AttributeError, TypeError) as error:
    print("[error] frozen field assignment:", type(error).__name__)

On the tested interpreter this prints True True and FrozenInstanceError. frozen=True generates guards against ordinary assignment and, when the data-class rules permit it, a compatible hash. It does not recursively freeze fields and is not a security boundary; object.__setattr__ and mutable referents remain relevant.

Use an ordinary data class when instances need named mutable state, easy extension, pattern matching, methods, inheritance, weak references, or dynamic attributes. Use slots=True when a fixed field set is intentional and population measurements justify removing per-instance dictionaries. Slots are a semantic restriction as well as a memory optimization: arbitrary new attributes fail, multiple inheritance becomes more constrained, and weak-reference support needs weakref_slot=True when required.

Do not add unsafe_hash=True merely to make a mutable record fit into a set. If equality-relevant fields change after insertion, the hash-table contract still breaks. Prefer a frozen value key or a separate stable identifier.

Shape changes are API changes

Positional records expose field order. Adding a field changes unpacking arity; reordering fields can silently reinterpret consumers; boolean and integer fields are especially easy to transpose. Returning a plain tuple from a private two-line helper is different from publishing it across packages or over years.

Named access absorbs some evolution but not all. A named tuple remains iterable and positional, so consumers may depend on exact length. A data class is not automatically iterable, which can intentionally prevent positional coupling. Keyword-only data-class fields can make call sites resilient to field reordering:

Pyodide / WebAssembly
from dataclasses import dataclass


@dataclass(kw_only=True)
class RetryPolicy:
    attempts: int
    backoff_seconds: float


policy = RetryPolicy(attempts=3, backoff_seconds=0.5)
print("[result] keyword-only policy:", policy)

Static type annotations improve tooling but do not enforce runtime types by themselves. Put validation in a deliberate constructor, __post_init__, parsing layer, or schema system when untrusted input crosses the boundary.

Choosing the semantic shape

Use a plain tuple when position is naturally understood, the shape is small and local, and sequence behavior is useful. Coordinates in tight numerical code, dictionary keys with two obvious components, and private multi-value returns are reasonable examples.

Use a named tuple when the value should remain tuple-compatible but field names improve readability. Database rows and compact immutable records often fit, provided positional compatibility is wanted rather than accidental.

Use a frozen, slotted data class when you want a compact nominal value object: type-sensitive equality, named fields, generated representation, methods, and stable hash semantics from immutable hashable fields.

Use an ordinary data class when mutation, extensibility, inheritance, dynamic attributes, or ecosystem expectations outweigh per-instance dictionary cost. Use a dictionary when fields are truly dynamic, keys come from data, or mapping operations are central. Use a list when length and elements are intended to change.

Apply these questions in order:

  1. Is this a homogeneous sequence or a heterogeneous record?
  2. Should consumers address values by position, name, or dynamic key?
  3. Is mutation of fields part of the model?
  4. Must record type participate in equality?
  5. Should the value be hashable, and are all equality fields deeply stable enough?
  6. Is positional iteration compatibility a feature or a future constraint?
  7. Does measured population memory justify slots or a more specialized representation?

For millions of numeric rows, none of these object-per-record choices may be suitable. Packed arrays, columnar storage, database cursors, or domain-specific libraries can avoid one Python object and several pointers per field. Semantic clarity should lead the decision; scale then determines whether a different storage layer is necessary.

Exercises: test the model

  1. Create a tuple containing a hashable custom object whose hash depends on mutable state. Insert it as a dictionary key, mutate the object, and explain the failed lookup.
  2. Disassemble ordinary unpacking, starred unpacking, swapping, and tuple return on CPython 3.14. Label which observations are language semantics and which are opcodes.
  3. Measure 100,000 instances of each five record shapes with tracemalloc. Include class creation either inside or outside every measurement consistently.
  4. Convert a positional public return value into a named tuple and then a frozen data class. List compatibility gains and breaks for indexing, unpacking, equality, and serialization.
  5. Demonstrate shallow immutability by placing a list inside a frozen data class. Design a version whose equality-relevant graph cannot be mutated through its public fields.
  6. Model a cache key with tenant, resource, and revision fields. Decide whether a tuple, named tuple, or frozen data class best prevents transposition and omission.

Keep this model

A tuple is a fixed sequence of references. It freezes its own shape, not the objects it references. Its hashability follows its elements, and its compact CPython layout is a consequence of fixed length rather than a guarantee about exact bytes.

Records add meaning to shape. Named tuples preserve tuple semantics while naming positions. Data classes provide nominal class semantics and generated behavior. Slots trade dynamic instance dictionaries for a declared layout. Each choice changes equality, mutation, evolution, and interoperability, not just memory.

When choosing a record representation, ask:

  1. Which semantics do consumers need: sequence, tuple-compatible record, nominal value, or mutable object?
  2. What changes when the shape evolves?
  3. Am I counting complete population cost rather than one shallow object?

The smallest representation is only economical when its unwritten positional contract does not cost more in maintenance than it saves in bytes.

Primary sources