Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·40 MIN

List vs tuple: the 16 bytes you only get from one comparison out of three, and the two operations where there is no difference

“A tuple is lighter by 16 bytes” holds only if you compare it with a list built from a literal. Against one grown by append the same difference is 72 bytes — and getsizeof reports that honestly. Meanwhile “tuples are faster” holds for exactly one operation out of five, for a reason that has nothing to do with being a tuple.

Full technical treatment

TL;DR

A list is a mutable sequence, a tuple an immutable one. Everything else follows from that: a list can grow and therefore holds a pointer to a separate array plus spare capacity for it, while a tuple keeps its elements inside itself. That is why a tuple is lighter — though by how much depends on which list you compared it against. And it is why a tuple can be a dictionary key whenever every element of it can.

Hence the main consequence: both of the stock "and therefore" clauses — lighter and faster — hold not in general but under one comparison out of several. sys.getsizeof reports a difference of 16 bytes at any length, but only against a list built from a literal: one grown by append carries spare capacity, and at seventeen elements the gap is already 72 bytes — 248 against 176. And "tuples are faster" holds for exactly one operation out of five — building from a literal, ×4.2 on 3.13.7 and ×4.7 on 3.14.7. On the other four there is a difference, but its sign belongs to the build rather than to the type: reading by index is 1.08× faster on a tuple on 3.13.7 and slower on 3.14.7 — ×0.92.

Beyond that: numbers, versions and boundaries. Those 16 bytes are exactly two fields: a pointer to the array and allocated. The spare grows not by doubling: list_resize computes it from the length — plus an eighth, plus six; because of the six the ratio between consecutive capacities starts at two and on long lists converges to 1.125. tracemalloc across a batch, net of the batch itself, gives exactly what getsizeof gives: 120.0 against 120 for a tuple, 183.9 against 184 for a list from a comprehension; what getsizeof understates is not the array but the elements — and on a list of freshly built strings that shows: 653.6 against 184. The fivefold gap on a literal is not about types: a constant tuple is not built at run time at all, it sits whole in co_consts, and putting a variable inside drops the advantage to ×1.3. A tuple is only shallowly immutable: t = ([],), and t[0].append(1) works. In 3.14 a tuple gained 8 bytes — the ob_hash field, a hash cache (gh-131525) — and the getsizeof gap halved, to 8 bytes instead of 16.

Where to start
Before this lesson it is enough to understand
  • that a list and a tuple are sequences, and how each is written;
  • that an element is taken by index and a whole sequence is walked with a loop;
  • that a dictionary has keys, and that not every object will do as one.
You do not need to know in advance
  • sys.getsizeof, tracemalloc, a list's spare capacity and list_resize;
  • bytecode, co_consts, the ob_hash field, and what a hash is.

What is actually being asked here

language contractLanguage guarantee: a list is mutable, a tuple is not. Everything else in the lesson is about what that costs in one specific build.

The stock answer to this question comes down to two clauses: a tuple is immutable, therefore it is lighter and faster.

Both consequences are imprecise, and imprecise in different ways. "Lighter" is true, but the amount depends on which list you picked to compare against, and the spread there is fourfold. "Faster" is true for one operation out of five, and for a reason unrelated to immutability.

One at a time — but first, what actually makes the two types different.

Base: a list changes, a tuple does not

The difference between them starts not with bytes but with a single word.

A list is a mutable sequence. You can add an element to it, remove one, replace one with another — and the object stays the very same object.

A tuple is an immutable one. Once it exists, the set of its elements cannot be changed: nothing added, nothing removed, nothing assigned by index. t[0] = ... gives TypeError: 'tuple' object does not support item assignment.

Everything an interviewer is normally after follows from that one difference.

First: a tuple can serve as a dictionary key and a list cannot. A key has to stay unchanged while it sits in the dictionary, which is why it must be hashable. A tuple can meet that condition: it is hashable if every element of it is. A list never can.

Second: choosing the type is a message to whoever reads the code. A tuple says "this is one record of a fixed shape, and each position means something of its own"; a list says "this is any number of uniform things, and how many is not known in advance". The interpreter demands no such distinction — but it is exactly why the type is worth choosing deliberately.

Third: the same shows in an interface. A function that returned a list has handed out something the caller is free to modify — which is why list(l) has to make a copy. A function that returned a tuple has handed out nothing to modify, and copying it is pointless: tuple(t) is t, the same object.

That is already enough to answer the basic interview question: which to choose, and why. Everything below is about the clause that answer usually ends with — "and therefore a tuple is lighter and faster". By how much lighter, under which comparison, and in which operation exactly faster.

Mechanism 1: the error that does not crash

The interesting errors are never the ones that bring the program down.

The comma rule is stated plainly in the tutorial: "a tuple with one item is constructed by following a value with a comma (it is not sufficient to enclose a single value in parentheses)". The reference puts it normatively: "it is actually the comma which makes a tuple, not the parentheses".

Forgetting the comma is easy, and what follows depends on what is inside:

PYTHON
ALLOWED = ("admin")      # ← no comma: this is the string 'admin'
 
def allowed(role):
    return role in ALLOWED

A check for "is this role among the permitted ones" has turned into a substring check. Measured over six names — admin, adm, min, administrator, guest, a:

versionwho got through
("admin") — no commaadmin, adm, min, a
("admin",) — with the commaadmin

Three extra out of six, and not one exception. The program did not crash, the test for allowed("admin") passed, and allowed("a") returned True.

The second way to get a wrong answer in silence is multiplication:

PYTHON
grid = [[]] * 3          # three references to ONE list
for i, row in enumerate(grid):
    row.append(i)
grid                     # [[0, 1, 2], [0, 1, 2], [0, 1, 2]]
[sum(r) for r in grid]   # [3, 3, 3] — one row, counted three times

The right form is a comprehension, which creates a new object on each step:

PYTHON
grid = [[] for _ in range(3)]   # [[0], [1], [2]]

With a tuple it is exactly the same: ([],) * 3 gives three references to one list. Immutability has nothing to do with it here — which leads to the next section.

Mechanism 2: immutability is shallow

A tuple stores references, and the prohibition covers replacing references, not the objects they point at:

PYTHON
t = ([], [])
t[0].append("this is allowed")   # works
t[0] = ["this is not"]           # TypeError: 'tuple' object does not
                                 # support item assignment

Hence the caveat to what the Base section said about dictionary keys: "a tuple can be a dictionary key" is not always true.

PYTHON
hash((1, "a", (2, 3)))   # fine
hash((1, [2, 3]))        # TypeError: unhashable type: 'list'

A tuple is hashable only if every element is. One list inside and it will never be a key — and you find out at the moment of insertion into the dictionary, not at the moment the tuple was created.

There is a flip side of the same coin, and it is useful in practice: since a tuple is immutable, there is no reason to copy it.

PYTHON
t = (1, 2, 3);  tuple(t) is t   # True  — the same object
l = [1, 2, 3];  list(l)  is l   # False — a copy

list(l) has to copy: returning the same object would hand out a mutable reference to someone else's data. tuple(t) does not have to — there is nothing there to change.

Mechanism 3: the 16 bytes, and where they come from

implementation detail · CPython 3.13The object layout and the list's spare capacity are how CPython works on 64 bits: the numbers changed between versions and will change again.

Now to that trailing clause — "and therefore lighter". It starts with a number a single line can check:

PYTHON
import sys
sys.getsizeof([None] * 10)     # 136
sys.getsizeof((None,) * 10)    # 120

Sixteen bytes. And the gap does not move with length, provided the list came from a literal (that proviso turns out to be decisive, but ignore it for now):

lengthlisttupledifference
0564016
1644816
1013612016
10085684016
10008056804016

A constant difference means a constant field, not per-element overhead. And that is what it is — visible in the struct declarations.

Both objects carry a PyObject_VAR_HEAD: reference count, type pointer, length — 24 bytes in all. After that they diverge:

  • a tuple keeps its element array inside the object itself, right after the header;
  • a list keeps only a pointer to a separate array (8 bytes) and an allocated field (another 8) — how much room that array was given.

8 + 8 = 16. The allocated field is precisely what a tuple does not have and cannot have: a tuple has no reason to remember spare capacity, because it does not grow.

Mechanism 4: 16 bytes — against only one list out of three

Now for that proviso. The 16-byte difference was measured against a list built from a literal, and such a list has no spare capacity. Take another list of the same length:

PYTHON
sys.getsizeof((None,) * 17)     # 176 — tuple, exactly 17 slots
sys.getsizeof([None] * 17)      # 192 — list by repetition: exactly 17 too
sys.getsizeof([0, 1, 2, ..., 16])  # 200 — list from a literal: 18 slots
sys.getsizeof(list(range(17)))  # 200 — the constructor: 18 as well
grown = []
for i in range(17): grown.append(i)
sys.getsizeof(grown)            # 248 — grown by append

One length, four lists, three different numbers — and the only one with no spare capacity is the one built by repetition. The literal over-allocates too, against expectation: a list of nothing but constants is assembled by the compiler not element by element but as BUILD_LIST 0 plus LIST_EXTEND from a ready tuple, and list_extend takes the same path append does. Against the last of them the tuple is lighter not by 16 bytes but by 72. "A tuple is lighter by 16 bytes" holds for exactly one comparison out of three — and that, rather than some hidden field, is the main imprecision in the stock answer.

Note what is happening here: getsizeof reports the spare capacity honestly. 248 = 56 + 24 × 8, where 24 is how many slots were allocated. The array a list points at is counted in that number in full.

So what does getsizeof actually miss

The documentation states the limit outright: "Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to". That is about the elements, not about the array of pointers.

You can check it by putting the two tools side by side. tracemalloc across a batch of 10,000 objects, net of the list that holds the batch (which costs 8.51 bytes per object):

what is measuredtracemallocgetsizeof
tuple of 10120.0120
list via list(range(10))136.0136
list via a comprehension183.9184

They agree. For these objects getsizeof understates nothing — and the stricter way, measuring with tracemalloc across a batch instead of sys.getsizeof one object at a time, would have changed nothing here.

Why they agree is clear once you look at the elements. They are small integers, which are singletons: neither the list nor the tuple owns them, only pointers to them. There is nothing to count.

Replace the elements with strings that get built afresh each time, and the divergence appears at once:

what is measuredtracemallocgetsizeof
list of 10 fresh strings653.6184

That is where getsizeof understates — by 470 bytes, a factor of three. And it understates exactly what the documentation warns about: the strings themselves.

There is one practical conclusion from these two tables, and it is not about lists: getsizeof is reliable exactly to the extent that a container's elements belong to someone else. For a container of shared objects it is exact; for a container of its own, it shows the tip.

Back to the spare capacity — now it is clear that it is not hiding anywhere, it simply depends on how the list was built.

Mechanism 5: spare capacity — the price of being able to grow

A list grown by append holds more room than it has elements. The growth pattern is written in the comment above list_resize in Objects/listobject.c:

The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...

Objects/listobject.c, the comment above list_resize, tag v3.13.7

Verified by running it — the same numbers:

elementsgetsizeofroom allocated for
0560
1884
51208
918416
1724824

A list of 17 elements holds room for 24. A tuple of 17 holds 17.

The rule it grows by

The ladder above is the rule's output, not the rule. The rule itself is one line in list_resize:

C
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;

That is: the new length, plus an eighth of it, plus six, rounded down to a multiple of four.

This is easy to misread, so step by step. The formula computes the capacity from the length, not from the previous capacity. It answers the question "the list now holds n elements — how much room should be allocated for them", and the old value does not enter into it at all.

The formula is checked against the measurement rather than taken on trust — the first twelve steps match to the unit:

lengthmeasuredformula
144
91616
172424
415252
657676
109128128

Why "an eighth" when the table shows doubling

The formula has two terms, and which one rules depends on the length.

On a short list + 6 rules. For length 1 an eighth is zero, and the whole spare comes from the six: 1 + 0 + 6 = 7, down to four. That is why the first steps look like doubling: 4 → 8 → 16.

On a long one len / 8 rules. Against thousands the six decides nothing, and the ratio between consecutive capacities converges to 1.125. That shows only in the measurement:

lengthcapacityvs previous capacity
9162.000
17241.500
41521.300
1291481.156
6737601.131
11,97713,4801.126
49,36555,5401.125
289,053325,1881.125

That is what 1.125 is: the limit the ratio converges to on long lists, not a multiplier applied at every step. The common belief that "a list doubles" holds for exactly the first two steps and fails after that.

How this differs from doubling in practice

Doubling is what vector does in C++ and what a Go slice does up to 256 elements. The difference shows in the counts if you grow a list to three hundred thousand elements:

CPythondoubling
reallocations along the way7618
final capacity325,188524,288
spare over the length8.4 %74.8 %

That is the whole trade. Four times as many reallocations — so that the finished list does not hold nearly three quarters of its own length in slack. For a language where lists are many and long-lived that is a sensible side of the trade; for one where growth speed matters more, the other side is.

The motive is named in the same comment, and it is not saving memory: the spare is "enough to give linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc()". It exists so that a thousand consecutive append calls cost linear time rather than quadratic.

Two cases where the spare behaves differently

A large jump taken at once gets no spare. Next to the formula in list_resize there is a separate branch: "Do not overallocate if the new size is closer to overallocated size than to the old size". The difference shows on a thousand elements:

PYTHON
a = []; a.extend(range(1000))          # room for 1000
b = []
for i in range(1000): b.append(i)      # room for 1100

Exactly a thousand against eleven hundred. That is the measured price of the advice "build it with one expression, not an append loop".

Memory does not come back straight away. The condition at the top of list_resize skips the realloc entirely while the length stays in the upper half of the spare:

C
if (allocated >= newsize && newsize >= (allocated >> 1))

So a list of a thousand elements still holds room for a thousand after four hundred pop calls. The shrink fires at length 499 — the first below half — and gives exactly what the formula predicts: 499 + 62 + 6 = 567, rounded down to 564.

what was donelengthroom for
list(range(1000))1,0001,000
400 × pop()6001,000
another 200 × pop()400564

In practice this means a list that once grew to a million and then emptied by half will not give the memory back.

That explains the three different numbers from the section above: 192, 200 and 248 at one and the same length. Repetition ([None] * 17) knows the length exactly and takes no spare; the literal and list(range(17)) know it too but go through list_extend and round up to 18; the one grown by append went up the whole staircase and stopped at 24.

The practical consequence is narrow but useful: when the length is known in advance, build in one expression rather than in an append loop. Not for the sake of 56 bytes per list, but because then the size is predictable — to within one spare slot.

Mechanism 6: "tuples are faster" — one operation out of five

measured observationbench/list-vs-tuple/cost.py, CPython 3.13.7. Absolute nanoseconds depend on the machine; the difference between operations is what matters.

Now for the second half of the stock answer. Measured (3.13.7, best of seven runs of 2,000,000 repetitions):

operationlisttupletuple faster by
build from a literal43.0 ns10.3 ns×4.16
build from variables39.6 ns30.8 ns×1.29
read by index16.7 ns15.5 ns×1.08
unpack x, y, z19.5 ns19.6 ns×0.99
iterate 1000 elements8.6 µs8.4 µs×1.03

The meaningful column is the last one: each ratio comes from two numbers of a single run and therefore does not depend on what the machine was busy with at the time. The nanoseconds in the first two are an illustration of one particular run and should not be carried over to your own machine.

For each ratio the run states whether the instrument can resolve it — and to do that it repeats the whole measurement in five separate processes. Separate on purpose: inside one process all five repeats hold their value, while between processes the "build from variables" row moves from 1.29 to 1.79. The spread lives between runs rather than inside them, and a measurement repeated inside one process would report a falsely narrow range (labels translated from the script's output):

  Can the instrument resolve each ratio (range over 5 processes):
    build from a literal      4.08 ..  4.24   spread 0.16   resolvable yes
    build from variables      1.29 ..  1.29   spread 0.01   resolvable yes
    read by index             1.08 ..  1.11   spread 0.02   resolvable yes
    unpack                    0.99 ..  1.00   spread 0.01   resolvable no
    iterate 1000 elements     1.03 ..  1.03   spread 0.00   resolvable yes

The two middle rows are indexing and unpacking, the things you actually keep a container for. Unpacking gives no difference at all: its range straddles one. Reading by index does give one: 1.08, stable across all five processes.

And this is where it gets interesting, because on 3.14.7 the same row comes out at ×0.92 — the list is the faster one, and just as stably, with a range of 0.03. The same reversal on unpacking (×0.96) and on iteration (×0.83). Each of the two numbers is true inside its own run; together they say one thing: on four rows out of five the sign belongs to the build, not to the type. What is worth carrying away is not a ratio but the first row — the only one whose explanation is in the bytecode rather than in the measurement.

The first row, though, needs explaining, because a fourfold gap does not appear out of nowhere.

Mechanism 7: why the literal — the answer is in the bytecode

The gap in the first row is visible to the disassembler and needs no stopwatch at all (disassembler on 3.13.7):

x = (1, 2, 3)   ->  LOAD_CONST  STORE_NAME
                    co_consts: ((1, 2, 3), None)

x = [1, 2, 3]   ->  BUILD_LIST  LOAD_CONST  LIST_EXTEND  STORE_NAME
                    co_consts: ((1, 2, 3), None)

A constant tuple is not built at run time at all. It is assembled at compile time and sits whole in co_consts; where the expression stands there is one instruction — fetch the finished thing. A list cannot work that way: it is mutable, and returning the same object on every pass of a loop would hand out shared state. So a list is assembled afresh each time — and, curiously, out of the very same constant: LIST_EXTEND unpacks into it the same (1, 2, 3) tuple that sits in co_consts for both.

That also marks the boundary of the advantage. Let one non-constant expression appear inside:

x = (a, 2, 3)   ->  LOAD_NAME  LOAD_CONST  LOAD_CONST  BUILD_TUPLE  STORE_NAME
x = [a, 2, 3]   ->  LOAD_NAME  LOAD_CONST  LOAD_CONST  BUILD_LIST   STORE_NAME

The same shape, a different last instruction — and the gap falls from over fivefold to one and a half.

On 3.14 the same lines look slightly different: small integer constants are loaded by a separate LOAD_SMALL_INT instruction. That does not change the conclusion — a constant tuple still sits whole in co_consts, and a list is still assembled.

The practical conclusion: "tuples are faster" is about constants, not about types. If your tuple is assembled from variables, what you get is one and a half times on object construction, which is rarely the thing that matters in a hot loop.

What to do about it

Do not choose a tuple for speed. On indexing and unpacking there is no difference; on building from variables it is one and a half times. The fivefold win exists only for a constant literal, and it is about co_consts, not about types.

Choose a tuple for meaning. The tutorial frames it as a difference of intent: tuples "usually contain a heterogeneous sequence of elements", while for lists "their elements are usually homogeneous". A tuple tells the reader of the code "this is one record of a fixed shape"; a list says "this is any number of uniform things".

Put the comma in, and check the type. ("admin") is a string. If the value arrives from outside or is assembled conditionally, an assert isinstance(x, tuple) is cheaper than working out why the user a turned out to be an administrator.

Do not settle the memory difference with one comparison. The 16 bytes (8 on 3.14) only appear against a list built from a literal; against one grown by append the same difference is 72 bytes. And keep getsizeof's own boundary in mind: it counts a list's array in full, and the elements not at all. For a container of shared objects it is exact; for a container of its own it shows the tip.

Remember that immutability is shallow. A tuple with a mutable element inside protects neither against mutation nor against the TypeError you get when you try to make it a key.

Deeper: what changed in 3.14

Across four versions the layout changed exactly once, and it is worth knowing: the version decides the very number you will see in your own console.

PYTHON
sys.getsizeof(())    # 40 on 3.11, 3.12, 3.13
                     # 48 on 3.14

The increase is constant — 8 bytes at any length — which means one field was added to the object. It is ob_hash, a hash cache:

C
// Include/cpython/tupleobject.h, tag v3.14.0
typedef struct {
    PyObject_VAR_HEAD
    Py_hash_t ob_hash;      /* Cached hash. Initially set to -1 */
    PyObject *ob_item[1];
} PyTupleObject;

The gap between a list and a tuple halved: 8 bytes instead of 16.

There is a temptation here, and it is worth naming: to notice that re-hashing runs faster on 3.14 and write "hashing got four times faster". Comparing the time of two versions is not allowed with a number or without one — the builds these numbers come from differ by more than the language version, and such a comparison proves nothing.

The cache can be demonstrated within a single run, and that is legitimate on any version: if there is no cache, re-hashing the same object costs what the first hash cost; if there is one, it costs noticeably less.

versiongetsizeof(())repeat hash cheaper than the first by
3.11.1540×0.9
3.12.340×0.9
3.13.740×0.9
3.14.748×4.5

Three versions in a row say "no cache", the fourth says "there is one" — and in the same place the tuple gained 8 bytes. Each row compares two numbers from one run; there is nothing to compare between rows, and no need to.

The claimed effect is stated in the issue itself, narrowly and honestly: "the mdp benchmark increased by 86%", followed immediately by "no measurable improvement on any other benchmark, but it also seems to have no downside, including for memory usage". That last part is about max_rss across a benchmark suite, not about the size of a single object: every tuple grew by 8 bytes, and getsizeof shows it. Both statements are true at once, and this is exactly the case that makes it worth separating "memory of the process" from "size of the object".

Version history

VersionChangeWhat it means for your code
3.11The layout of both types is the same as in 3.12 and 3.13: an empty tuple is 40 bytes, an empty list 56. The shape of the bytecode for a constant literal matches too: LOAD_CONST for the tuple against BUILD_LIST and LIST_EXTEND for the list. Verified by running it on all four versions.
3.14Tuples gain an ob_hash field — a hash cache (gh-131525, PR #131529). Every tuple grows by 8 bytes and the getsizeof gap against a list halves. Re-hashing the same tuple becomes cheaper than the first hash for the first time — verified within a single run on all four versions. Separately: small integer constants are loaded by a LOAD_SMALL_INT instruction, which makes the disassembly of the same lines look different from 3.13.

How to answer in an interview

The short answer: a list is a mutable sequence and a tuple an immutable one, and that — not bytes — is what you choose between. A tuple can be a dictionary key whenever every element of it can, and it tells the reader "this is one record of a fixed shape"; a list says "this is any number of uniform things". There is a difference in memory, but it is not what you would change the type for.

That is enough to answer correctly. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On memory: a list holds a pointer to a separate array plus spare capacity, a tuple holds the elements inside itself — hence the getsizeof difference. But that difference is 16 bytes only under one comparison: a tuple against a list built from a literal. A list grown by append carries spare capacity, and at seventeen elements the gap is already 72 bytes.

What separates a good answer: not agreeing that "a tuple is faster" in general, but naming where. Of five operations only one favours the tuple — construction from a literal — and not because it is a tuple: a constant tuple is not built at run time at all, it sits in co_consts. Put a variable inside and the advantage drops to one and a half times. On indexing and unpacking there is no difference whatsoever. And separately: a tuple is only shallowly immutable — t = ([],), and t[0].append(1) works.

Next they ask

Next they ask

A tuple is immutable — so it can always be a dictionary key?

Short answer

No: the immutability is shallow. A tuple holds references, and if a list is inside, the tuple stops being hashable — while the list inside can still be changed.

Next they ask

If a tuple is leaner, should lists be turned into tuples to save memory?

Short answer

The sixteen bytes appear only against one list out of three — the one built the same way the tuple was. A list carries room to grow, and that room is the price of being able to grow; trading mutability for a difference your profile almost certainly cannot see is a bad exchange.

Common misconceptions

Claim

a tuple is lighter than a list because it is immutable

Actually

Lighter, yes — but by how much depends on which list you took. Against a list built from a literal the difference is exactly 16 bytes at any length, and those are two header fields: a pointer to the array and allocated. Against one grown by append it is already 72 bytes at length 17 (248 against 176). getsizeof reports the spare capacity honestly; what is imprecise is not the tool but a comparison that picked the most compact of the three possible lists.

Claim

tuples are faster than lists

Actually

On one operation out of five. Measured on 3.13.7: building from a literal ×4.16, from variables ×1.29, reading by index ×1.08, unpacking ×0.99, iterating a thousand elements ×1.03. The fourfold gap in the first row is not about types: a constant tuple is not built at run time at all, it sits in co_consts. Put a variable inside and 1.3× is what remains. And of the other four rows, three reverse their sign on 3.14.7: there the list is faster.

Claim

tuples iterate faster because they are simpler

Actually

The direction depends on the build. On 3.13.7 iterating a thousand elements is 1.03× faster for a tuple; on 3.14.7 the list wins instead, ×0.83. Both numbers were measured within their own run, both hold across five processes and both are correct. What follows is not "3.14 got worse" (that comparison is not allowed) but that the direction on iteration is not a property of the language.

Claim

a tuple is immutable, so its contents will not change

Actually

A tuple stores references, and the prohibition covers replacing them. t = ([], []) then t[0].append(1) works; t[0] = [1] gives TypeError: 'tuple' object does not support item assignment. The difference between "cannot replace the reference" and "cannot change the object" is decisive here.

Claim

a tuple can be a dictionary key, a list cannot

Actually

A tuple is hashable only if every element is. hash((1, [2, 3])) gives TypeError: unhashable type: 'list'. And you find out at the moment of insertion into the dictionary, not at the moment the tuple was created — that is, far from wherever the list got in.

Claim

parentheses make a tuple

Actually

The comma does. The reference puts it normatively: “it is actually the comma which makes a tuple, not the parentheses”. (1) is an integer; (1,) and 1, are tuples. Hence an error that does not crash: ALLOWED = ("admin") turns a role check into a substring check, and "a" in ALLOWED is True.

Claim

[[]] * 3 creates three empty lists

Actually

It creates three references to one list: grid[0] is grid[1] is True. Append one element to each “row” and you get [[0, 1, 2], [0, 1, 2], [0, 1, 2]], with sums [3, 3, 3] instead of [0, 1, 2]. The right form is a comprehension: [[] for _ in range(3)]. With a tuple it is the same: ([],) * 3.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

Two lists of the same seventeen numbers: one written as a literal, the other built with append. What does this code print?
import sys

literal = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
grown = []
for n in range(17):
  grown.append(n)

print(literal == grown)
print(sys.getsizeof(literal) == sys.getsizeof(grown))
print(sys.getsizeof(literal), sys.getsizeof(grown))

Practice · estimate

Construction from a literal: [1, 2, 3] against (1, 2, 3). How many times faster is the tuple?
times

Knowledge check

Question 1 of 5

sys.getsizeof gave a 16-byte difference between a list and a tuple. Under what condition does it give a different one?

Sources & further reading

6 SOURCES

  1. Tutorial — Tuples and SequencesOfficial documentation. The source of the comma rule the “error that does not crash” section rests on: “a tuple with one item is constructed by following a value with a comma (it is not sufficient to enclose a single value in parentheses)”. The same section states the difference in intent: a tuple “usually contain a heterogeneous sequence of elements”, while for a list “their elements are usually homogeneous and are accessed by iterating over the list”. That is about what the author of the code means, not about what the interpreter forbids.https://docs.python.org/3.14/tutorial/datastructures.html
  2. Built-in Types — Sequence TypesOfficial documentation. The normative wording of the same rule: “it is actually the comma which makes a tuple, not the parentheses”, along with the exceptions: “The parentheses are optional, except in the empty tuple case, or when they are needed to avoid syntactic ambiguity”.https://docs.python.org/3.14/library/stdtypes.html#tuples
  3. Objects/listobject.c — list_resize and the spare capacityCPython source code. The comment above list_resize at tag v3.13.7, the source of the growth pattern: “The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...”. The same comment names the motive, and it is not saving memory: “linear-time amortized behavior over a long sequence of appends() in the presence of a poorly-performing system realloc()”. Verified by running it: getsizeof after append gives exactly 4, 8, 16, 24. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/listobject.c
  4. Include/cpython/tupleobject.h — PyTupleObject at tag v3.14.0CPython source code. The struct behind both the 16-byte difference and its change in 3.14: PyObject_VAR_HEAD, then `Py_hash_t ob_hash` with the comment “Cached hash. Initially set to -1.”, then ob_item[]. There is no ob_hash field in 3.13 — hence 40 bytes for an empty tuple against 48. CPython tag 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Include/cpython/tupleobject.h
  5. CPython gh-131525 — Caching the tuple hash calculation speeds up some code significantlySource. The issue opened by mdboom on 20 March 2025 that produced the ob_hash field (PR #131529, merged 27 March 2025). The claimed effect is stated narrowly and honestly: “the mdp benchmark increased by 86%”, and alongside it “no measurable improvement on any other benchmark, but it also seems to have no downside, including for memory usage”. The latter is about max_rss across a benchmark suite, not about the size of a single object: every tuple grew by 8 bytes, and getsizeof shows it.https://github.com/python/cpython/issues/131525
  6. sys.getsizeof — what exactly it countsOfficial documentation. “Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to.” That is about the ELEMENTS, not about the array of pointers: a list's array, spare capacity included, is counted in full — verified by the convergence with tracemalloc in bench/list-vs-tuple/layout.py.https://docs.python.org/3.14/library/sys.html#sys.getsizeof