Why tuples can beat lists

Constants, copy elision, exact allocation, and record-like data

A tuple allocates exactly. A list overallocates. That extra capacity is the deliberate price of efficient append. CPython's `PyListObject` uses an overallocation formula of roughly 12.5% per resize, giving amortized O(1) append. `PyTupleObject` has no overallocation because tuples are immutable. The list struct stores a separate `ob_item` pointer to a dynamically allocated array of `PyObject*` pointers. The tuple struct embeds `ob_item[]` inline after the header. For 10 floats, a list container is about 16 bytes larger than a tuple. This gap grows with the number of elements. Tuple copy elision: `tuple(t)` returns the same object when `t` is already a tuple. Tuples are hashable only when all elements are hashable. <a href="/memory-container-comparison">See how tuples compare with list and array.array in real measurements</a>. <a href="/classes-data-builders">Use namedtuple and NamedTuple for typed records</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Use tuple when fixed shape is the truth.

:)
TABLE OF CONTENTS
4.3Why tuples can beat lists

Constants, copy elision, exact allocation, and record-like data

You have used tuples for "immutable lists" and lists for "mutable sequences." That is true at the surface, but it misses the deeper design: a tuple is a fixed-size record where position encodes meaning, while a list is a resizable array with spare capacity for growth.

Think of a tuple like a shipping crate welded shut. The crate arrives with exactly N compartments — you cannot add compartments, but you can open the boxes inside and change their contents. A list is like a cargo container with expandable walls. Every time you append, the walls push outward a bit, reserving extra room so the next append is fast.

Core answer

Use a tuple when the size and ordering are part of the meaning of the value. Use a list when the collection is expected to grow, shrink, reorder, or be mutated in place.

Both containers store references to Python objects. The real difference: a tuple stores exactly n reference slots inside the tuple object, while a list stores a pointer to a separately managed resizable reference array.

# [CURRENT - 3.10-3.14] Works on Python 3.x
city = ("Sao Paulo", "BR", 12_000_000)
pipeline = ["parse", "validate"]
pipeline.append("store")
print(city)
print(pipeline)

Tuple immutability is shallow: the tuple shape cannot change, but mutable objects inside it can still be mutated through the stored references.

# [CURRENT - 3.10-3.14] Works on Python 3.x
record = ("batch-1", [])
record[1].append("row-1")
print(record)
Mechanism and internals

On CPython, the tuple object contains its header and its item-reference slots in one fixed-size allocation. A list object contains its own header plus a pointer to a separate dynamically allocated array of item references. That extra indirection is what makes resizing possible.

This is CPython-specific implementation detail. The language-level contract is only mutability and sequence behavior. The memory layout and exact byte counts can differ across interpreter versions, builds, and implementations such as PyPy.

For a 64-bit CPython 3.12.3 build, the container-only cost measured with sys.getsizeof looks like this:

  • Empty tuple: 40 bytes
  • Empty list: 56 bytes
  • Tuple of 10 references: 120 bytes
  • List of 10 references created from a known-size iterable: 136 bytes

That yields a simple CPython 3.12 rule of thumb for container-only cost:

  • Tuple: about 40 + 8 * n bytes
  • List with exactly n allocated slots: about 56 + 8 * n bytes

So for the same number of elements, the list container itself is typically about 16 bytes larger than the tuple container on this build.

# [CURRENT - 3.10-3.14] Works on Python 3.x
# Example byte counts below were measured on CPython 3.12.3, 64-bit Linux.
import sys
for n in (0, 1, 2, 3, 10, 16):
t = tuple(range(n))
l = list(range(n))
print(
n,
sys.getsizeof(t),
sys.getsizeof(l),
)

sys.getsizeof() measures only the container object itself. It does not recursively include the objects referenced by the container. For example, a list of 10 floats contains:

  • the list container
  • 10 pointer slots inside or behind the container
  • 10 references to the same float object (the literal 1.0 is a single constant in the code object)
# [CURRENT - 3.10-3.14] Works on Python 3.x
# Example byte counts below were measured on CPython 3.12.3, 64-bit Linux.
import sys
n = 10
values = [float(i) for i in range(n)]
list_total = sys.getsizeof(values) + sum(sys.getsizeof(v) for v in values)
values_t = tuple(float(i) for i in range(n))
tuple_total = sys.getsizeof(values_t) + sum(sys.getsizeof(v) for v in values_t)
print(list_total) # 376 on CPython 3.12.3, 64-bit
print(tuple_total) # 360 on CPython 3.12.3, 64-bit

The 16-byte gap remains because the element objects are identical in both cases; only the container representation differs.

CPython internals

List implementation (Objects/listobject.c). The PyListObject struct (Python 3.12) contains:

typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
  • ob_size (inherited from PyObject_VAR_HEAD) is the logical length — what len() returns
  • allocated is the number of slots reserved in ob_item
  • ob_item is a separately allocated PyObject** array

When a list grows beyond allocated, list_resize() reallocates ob_item. The overallocation formula in CPython 3.12 (Objects/listobject.c, list_resize) is:

new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);

This means a list of 1000 elements has about 1000 + 125 + 6 = 1131 allocated slots — about 13% spare capacity. The spare capacity protects append performance at the cost of extra memory.

Tuple implementation (Objects/tupleobject.c). The PyTupleObject struct contains:

typedef struct {
PyObject_VAR_HEAD
PyObject *ob_item[1];
} PyTupleObject;

The ob_item array is allocated inline as part of the tuple object itself (flexible array member in C99 terms). There is no separate allocation, no spare capacity, and the size is fixed at creation. This is why sys.getsizeof(tuple(range(1000))) reports exactly 40 + 8*1000 = 8040 bytes — the entire object is one contiguous memory block.

Copy elision. CPython optimizes tuple(t) where t is already a tuple. The PyTuple_Type's tp_new implementation checks whether the argument is already a tuple and simply returns the same object (with incremented reference count). This is safe because tuples are immutable. The list version always creates a new list.

# [CURRENT - 3.10-3.14] Works on Python 3.x
t = (1, 2, 3)
l = [1, 2, 3]
print(tuple(t) is t) # True -> same object reused
print(list(l) is l) # False -> new list created

Constant folding. The compiler (Python/compile.c) folds tuple literals of constants into a single code-object constant. A function returning (1, 2, 3) uses RETURN_CONST to return the pre-built tuple. A function returning [1, 2, 3] must build the list at runtime because lists are mutable.

Two patterns exist: "tuple as record" (positional unpacking, field names via NamedTuple) and "tuple as immutable list" (an unchangeable sequence). The CPython allocation optimization (one contiguous block) is a bonus, not the primary design goal — the language contract is immutability and fixed size.

Allocation and performance

The main reason lists cost more is not just the larger base header. It is the resizing strategy. CPython over-allocates list capacity so repeated append() is amortized O(1) instead of reallocating on every push. Tuples do not need that mechanism because they never grow.

# [CURRENT - 3.10-3.14] Works on Python 3.x
# Example byte counts below were measured on CPython 3.12.3, 64-bit Linux.
import sys
items = []
for value in range(12):
print(len(items), sys.getsizeof(items))
items.append(value)

On the interpreter used for these measurements, append growth went like this:

  • length 0: 56 bytes
  • length 1 through 4: 88 bytes
  • length 5 through 8: 120 bytes
  • length 9 through 12: 184 bytes

That spare capacity improves append throughput, but it means a list can consume noticeably more memory than a tuple with the same current logical length if it reached that length through incremental growth.

Algorithmically, the important differences are:

  • Tuple indexing: O(1)
  • List indexing: O(1)
  • Tuple construction from known values: fixed-size allocation
  • List append: amortized O(1) because of overallocation
  • List insert/delete near the front or middle: O(n) because references must shift

There is also a locality effect. A tuple's reference slots live contiguously with the tuple object itself. A list requires one extra pointer chase from the list object to the separately allocated reference array. That does not usually dominate whole-program performance, but in very hot loops and memory-dense workloads it can show up in cache behavior.

Compilation and copying

Tuples also have advantages when the value is known at compile time. In current CPython, a tuple literal of constants can be emitted as one constant object, while a list literal must still build a new mutable list at runtime.

# [CURRENT - 3.10-3.14] Works on Python 3.x
import dis
def states_tuple():
return ("open", "closed", "pending")
def states_list():
return ["open", "closed", "pending"]
dis.dis(states_tuple)
dis.dis(states_list)

On CPython 3.12, the tuple version uses RETURN_CONST, while the list version still performs list construction with BUILD_LIST and LIST_EXTEND. That is another CPython optimization detail, not a language guarantee, but it explains why tuples are often slightly cheaper for constant fixed records.

Copying shows the semantic difference directly:

# [CURRENT - 3.10-3.14] Works on Python 3.x
t = (1, 2, 3)
l = [1, 2, 3]
print(tuple(t) is t) # True
print(list(l) is l) # False

Reusing the same tuple is safe because tuples are immutable. Reusing the same list would violate caller expectations because either alias could mutate it.

Version context

The semantic differences between list and tuple are stable across Python 3. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.

The exact byte counts in this guide are CPython 3.12.3, 64-bit measurements taken locally. They are useful because they expose the shape of the costs, but they are not language guarantees. Different CPython releases or alternative interpreters may report different sizes.

Edge cases and gotchas

Do not use tuple as a vague synonym for "faster list." The memory savings are real but modest for ordinary object references: usually one pointer-sized slot difference in base overhead plus the absence of spare capacity. The bigger gain is semantic clarity: fixed shape, immutability of the container, and safer reuse.

A tuple is hashable only if all of its elements are hashable. A tuple containing a list is immutable as a container but still unhashable and still exposes mutation through that inner list.

# [CURRENT - 3.10-3.14] Works on Python 3.x
print(hash(("region", "BR")))
try:
hash(("region", []))
except TypeError as exc:
print(exc)

If the workload is numeric and dense, neither list nor tuple is the real memory-efficient choice. A flat container such as array.array avoids one Python object header per element and can reduce memory dramatically.

# [CURRENT - 3.10-3.14] Works on Python 3.x
# Example byte counts below were measured on CPython 3.12.3, 64-bit Linux.
import sys
from array import array
n = 10
print(sys.getsizeof([1.0] * n) + n * sys.getsizeof(1.0)) # 376
print(sys.getsizeof((1.0,) * n) + n * sys.getsizeof(1.0)) # 360
print(sys.getsizeof(array("d", [1.0] * n))) # 160

See for that tradeoff.

Production usage

Use tuples for:

  • return values whose arity is fixed and small
  • coordinate-like records
  • immutable cache keys
  • fixed snapshots that should not be mutated accidentally

Use lists for:

  • builders and accumulators
  • queues only if you mutate at the right end; otherwise prefer deque
  • collections with repeated append or in-place update
  • APIs where callers are expected to mutate the returned sequence

If the shape needs field names, not positional indexing, prefer NamedTuple or @dataclass(frozen=True) over a long anonymous tuple. See .

Further depth
  • Sequence types: list, tuple, range
  • sys.getsizeof
  • dis module
  • Data model: objects, values, and types
  • CPython source: Objects/listobject.c
  • CPython source: Objects/tupleobject.c
  • tuple and list docs
  • sys.getsizeof
BOARD NOTESContext
WHY NO BENCHMARK?

This topic is better taught with structure, semantics, and cross-references than with a synthetic chart.

Use tuple when fixed shape is the truth.

RELATED GUIDES
NEXT CHECKS
Contribute