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.
- 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.
sys.getsizeof,tracemalloc, a list's spare capacity andlist_resize;- bytecode,
co_consts, theob_hashfield, and what a hash is.
What is actually being asked here
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:
ALLOWED = ("admin") # ← no comma: this is the string 'admin'
def allowed(role):
return role in ALLOWEDA 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:
| version | who got through |
|---|---|
("admin") — no comma | admin, adm, min, a |
("admin",) — with the comma | admin |
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:
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 timesThe right form is a comprehension, which creates a new object on each step:
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:
t = ([], [])
t[0].append("this is allowed") # works
t[0] = ["this is not"] # TypeError: 'tuple' object does not
# support item assignmentHence the caveat to what the Base section said about dictionary keys: "a tuple can be a dictionary key" is not always true.
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.
t = (1, 2, 3); tuple(t) is t # True — the same object
l = [1, 2, 3]; list(l) is l # False — a copylist(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
Now to that trailing clause — "and therefore lighter". It starts with a number a single line can check:
import sys
sys.getsizeof([None] * 10) # 136
sys.getsizeof((None,) * 10) # 120Sixteen 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):
| length | list | tuple | difference |
|---|---|---|---|
| 0 | 56 | 40 | 16 |
| 1 | 64 | 48 | 16 |
| 10 | 136 | 120 | 16 |
| 100 | 856 | 840 | 16 |
| 1000 | 8056 | 8040 | 16 |
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
allocatedfield (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:
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 appendOne 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 measured | tracemalloc | getsizeof |
|---|---|---|
| tuple of 10 | 120.0 | 120 |
list via list(range(10)) | 136.0 | 136 |
| list via a comprehension | 183.9 | 184 |
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 measured | tracemalloc | getsizeof |
|---|---|---|
| list of 10 fresh strings | 653.6 | 184 |
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, ...
Verified by running it — the same numbers:
| elements | getsizeof | room allocated for |
|---|---|---|
| 0 | 56 | 0 |
| 1 | 88 | 4 |
| 5 | 120 | 8 |
| 9 | 184 | 16 |
| 17 | 248 | 24 |
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:
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:
| length | measured | formula |
|---|---|---|
| 1 | 4 | 4 |
| 9 | 16 | 16 |
| 17 | 24 | 24 |
| 41 | 52 | 52 |
| 65 | 76 | 76 |
| 109 | 128 | 128 |
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:
| length | capacity | vs previous capacity |
|---|---|---|
| 9 | 16 | 2.000 |
| 17 | 24 | 1.500 |
| 41 | 52 | 1.300 |
| 129 | 148 | 1.156 |
| 673 | 760 | 1.131 |
| 11,977 | 13,480 | 1.126 |
| 49,365 | 55,540 | 1.125 |
| 289,053 | 325,188 | 1.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:
| CPython | doubling | |
|---|---|---|
| reallocations along the way | 76 | 18 |
| final capacity | 325,188 | 524,288 |
| spare over the length | 8.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:
a = []; a.extend(range(1000)) # room for 1000
b = []
for i in range(1000): b.append(i) # room for 1100Exactly 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:
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 done | length | room for |
|---|---|---|
list(range(1000)) | 1,000 | 1,000 |
400 × pop() | 600 | 1,000 |
another 200 × pop() | 400 | 564 |
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
Now for the second half of the stock answer. Measured (3.13.7, best of seven runs of 2,000,000 repetitions):
| operation | list | tuple | tuple faster by |
|---|---|---|---|
| build from a literal | 43.0 ns | 10.3 ns | ×4.16 |
| build from variables | 39.6 ns | 30.8 ns | ×1.29 |
| read by index | 16.7 ns | 15.5 ns | ×1.08 |
unpack x, y, z | 19.5 ns | 19.6 ns | ×0.99 |
| iterate 1000 elements | 8.6 µs | 8.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.
sys.getsizeof(()) # 40 on 3.11, 3.12, 3.13
# 48 on 3.14The increase is constant — 8 bytes at any length — which means one field was
added to the object. It is ob_hash, a hash cache:
// 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.
| version | getsizeof(()) | repeat hash cheaper than the first by |
|---|---|---|
| 3.11.15 | 40 | ×0.9 |
| 3.12.3 | 40 | ×0.9 |
| 3.13.7 | 40 | ×0.9 |
| 3.14.7 | 48 | ×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
| Version | Change | What it means for your code |
|---|---|---|
| 3.11 | The 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.14 | Tuples 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
A tuple is immutable — so it can always be a dictionary key?
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.
If a tuple is leaner, should lists be turned into tuples to save memory?
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
a tuple is lighter than a list because it is immutable
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.
tuples are faster than lists
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.
tuples iterate faster because they are simpler
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.
a tuple is immutable, so its contents will not change
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.
a tuple can be a dictionary key, a list cannot
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.
parentheses make a tuple
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.
[[]] * 3 creates three empty lists
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
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
Knowledge check
sys.getsizeof gave a 16-byte difference between a list and a tuple. Under what condition does it give a different one?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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.getsizeofreports a difference of 16 bytes at any length, but only against a list built from a literal: one grown byappendcarries 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_resizecomputes 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.tracemallocacross a batch, net of the batch itself, gives exactly whatgetsizeofgives: 120.0 against 120 for a tuple, 183.9 against 184 for a list from a comprehension; whatgetsizeofunderstates 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 inco_consts, and putting a variable inside drops the advantage to ×1.3. A tuple is only shallowly immutable:t = ([],), andt[0].append(1)works. In 3.14 a tuple gained 8 bytes — theob_hashfield, a hash cache (gh-131525) — and thegetsizeofgap halved, to 8 bytes instead of 16.
In fact
- 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 byappendit is already 72 bytes at length 17 (248 against 176).getsizeofreports the spare capacity honestly; what is imprecise is not the tool but a comparison that picked the most compact of the three possible lists. - 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. - 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.
- A tuple stores references, and the prohibition covers replacing them.
t = ([], [])thent[0].append(1)works;t[0] = [1]givesTypeError: 'tuple' object does not support item assignment. The difference between "cannot replace the reference" and "cannot change the object" is decisive here. - A tuple is hashable only if every element is.
hash((1, [2, 3]))givesTypeError: 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. - 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,)and1,are tuples. Hence an error that does not crash:ALLOWED = ("admin")turns a role check into a substring check, and"a" in ALLOWEDisTrue. - It creates three references to one list:
grid[0] is grid[1]isTrue. 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.
By version
- 3.11
- The 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_CONSTfor the tuple againstBUILD_LISTandLIST_EXTENDfor the list. Verified by running it on all four versions.< - 3.14
- Tuples gain an
ob_hashfield — a hash cache (gh-131525, PR #131529). Every tuple grows by 8 bytes and thegetsizeofgap 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 aLOAD_SMALL_INTinstruction, which makes the disassembly of the same lines look different from 3.13.<
What is covered
- What is actually being asked here
- Base: a list changes, a tuple does not
- Mechanism 1: the error that does not crash
- Mechanism 2: immutability is shallow
- Mechanism 3: the 16 bytes, and where they come from
- Mechanism 4: 16 bytes — against only one list out of three
- Mechanism 5: spare capacity — the price of being able to grow
- Mechanism 6: "tuples are faster" — one operation out of five
- Mechanism 7: why the literal — the answer is in the bytecode
- What to do about it
- Deeper: what changed in 3.14
- Version history
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
6 SOURCES
- 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
- 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
- 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
- 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
- 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
- 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