
Lists vs Tuples in Python: Choosing the Right Sequence
Lists and tuples are Python's two general-purpose sequence types. Both hold ordered collections of any objects, both support indexing, slicing, iteration, len(), and in, and both can be nested and unpacked. Beginners often learn "tuples are lists you can't change" and stop there, which leaves a real question unanswered: when should you actually use a tuple?
The answer is about more than mutability. Tuples are hashable (when their contents are), they're slightly cheaper to create and store, and, most importantly, they communicate a different intent: a fixed-shape record rather than a growable collection. This post compares the two across syntax, behavior, performance, and type hints, then gives practical rules for choosing.
Syntax at a Glance
Lists use square brackets. Tuples are created by commas, with parentheses usually added for clarity:
nums = [3, 1, 2]
point = (3, 4)
print(type(nums), type(point))
<class 'list'> <class 'tuple'>
The comma is what makes a tuple, not the parentheses. That matters for single-element tuples:
a = (1)
b = (1,)
c = 1,
print(type(a), type(b), type(c), ())
<class 'int'> <class 'tuple'> <class 'tuple'> ()
(1) is just the integer 1 in grouping parentheses. You need the trailing comma: (1,). The empty tuple () is the one case where parentheses alone are enough.
There's also no "tuple comprehension". (n * n for n in range(3)) is a generator expression; wrap it in tuple() if you want a tuple.
Mutability: The Obvious Difference
Lists can be changed in place. You can append, insert, remove, sort, and assign to indexes and slices:
nums.append(4)
nums.sort()
print(nums)
[1, 2, 3, 4]
Tuples can't. There's no way to add, remove, or replace an element:
point[0] = 10
# TypeError: 'tuple' object does not support item assignment
point.append(5)
# AttributeError: 'tuple' object has no attribute 'append'
That's reflected in their methods. A tuple has exactly two public methods, count() and index(). A list has eleven: append, clear, copy, count, extend, index, insert, pop, remove, reverse, and sort.
Immutable Doesn't Mean Frozen All the Way Down
A tuple's immutability is shallow. The tuple can't change which objects it holds, but if one of those objects is mutable, that object can still change:
t = (1, [2, 3])
t[1].append(4)
print(t)
(1, [2, 3, 4])
The tuple still holds the same two objects. One of them, a list, just has new contents. The post on variables and mutability explains why "same object, different contents" is the key distinction.
The += Puzzle
This shallow immutability produces one of Python's strangest results:
t = ([1, 2],)
try:
t[0] += [3]
except TypeError as e:
print("TypeError:", e)
print(t)
TypeError: 'tuple' object does not support item assignment
([1, 2, 3],)
It raised an error and changed the list. t[0] += [3] runs in two steps: first t[0].__iadd__([3]) extends the list in place (which succeeds), then Python tries to store the result back into t[0] (which fails). Use t[0].extend([3]) or t[0].append(3) instead, which skip the assignment step entirely. Better still, avoid putting mutable objects in tuples you intend to treat as fixed.
Hashability: Tuples Can Be Dict Keys
This is the practical difference that matters most often. Dictionary keys and set elements must be hashable, and a hashable object's value must never change. Lists are mutable, so they're unhashable. Tuples of hashable items are hashable:
distances = {(0, 0): "origin", (3, 4): "five away"}
print(distances[(3, 4)])
visited = {(1, 2), (1, 2), (3, 4)}
print(visited)
five away
{(1, 2), (3, 4)}
Trying the same with a list fails:
{[0, 0]: "origin"}
# TypeError: unhashable type: 'list'
Coordinates, composite keys like (user_id, date), and memoization keys for multi-argument functions are all natural tuple uses. functools.lru_cache relies on this too: it builds a cache key from the arguments, which is why it can't cache calls with list arguments.
A tuple is hashable only if everything inside it is. The (1, [2, 3, 4]) tuple from earlier can't be a key:
hash((1, [2, 3, 4]))
# TypeError: unhashable type: 'list'
Different Intent: Records vs. Collections
Beyond the mechanics, there's a long-standing convention in Python about what each type is for:
- A list is a collection of similar things, where the number of items can vary: a list of users, a list of file paths, a list of scores. Each element plays the same role, and you'd typically loop over them.
- A tuple is a record with a fixed structure, where position carries meaning: a
(latitude, longitude)pair, an(r, g, b)color, a(date, level, code)log row. Each position plays a different role, and you'd typically unpack it.
row = ("2026-09-17", "ERROR", 503)
date, level, code = row
print(date, level, code)
2026-09-17 ERROR 503
You'll see this convention throughout the standard library. Functions that return several values return a tuple (divmod(), str.partition(), os.path.split()), and dict.items() yields (key, value) tuples. Database drivers commonly return rows as tuples, as you'll see in using Python with databases.
Returning multiple values from your own functions works the same way, because return a, b builds a tuple:
def min_max(values: list[int]) -> tuple[int, int]:
return min(values), max(values)
lo, hi = min_max([4, 9, 1])
print(lo, hi, min_max([4, 9, 1]))
1 9 (1, 9)
The unpacking on the left is covered in depth in unpacking in Python.
When a Plain Tuple Isn't Enough: NamedTuple
Positional records get hard to read once they have more than two or three fields. row[2] doesn't say much. A typing.NamedTuple keeps all the tuple behavior and adds field names:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(3, 4)
print(p, p.x, p[1], p._replace(x=0))
x, y = p
print(x, y)
Point(x=3, y=4) 3 4 Point(x=0, y=4)
3 4
It's still a real tuple: hashable, unpackable, indexable, and immutable. _replace() returns a modified copy. For records that need mutability, defaults with complex logic, or methods, a dataclass is usually a better fit; the dataclasses post covers that, and the collections module post covers collections.namedtuple.
Performance
Tuples have a modest performance edge. It's rarely a reason to choose one by itself, but it's worth understanding where it comes from.
Memory
A tuple stores exactly as many element slots as it needs. A list needs extra bookkeeping to support resizing, and it over-allocates so that append() doesn't have to reallocate every time:
import sys
print(sys.getsizeof([1, 2, 3]), sys.getsizeof((1, 2, 3)))
print(sys.getsizeof([]), sys.getsizeof(()))
88 64
56 40
(These are CPython 3.13 numbers on a 64-bit build, and they count only the container itself, not the elements.) You can see the over-allocation by watching a list grow:
lst = []
sizes = []
for i in range(10):
lst.append(i)
sizes.append(sys.getsizeof(lst))
print(sizes)
[88, 88, 88, 88, 120, 120, 120, 120, 184, 184]
The list grows in jumps, reserving room for future appends. That's what makes append() fast on average, at the cost of some unused space.
Creation Speed
A tuple literal made of constants is built once at compile time and stored as a single constant. A list literal must be built fresh every time the line runs:
import dis
dis.dis(compile("x = (1, 2, 3)\ny = [1, 2, 3]", "<demo>", "exec"))
0 RESUME 0
1 LOAD_CONST 0 ((1, 2, 3))
STORE_NAME 0 (x)
2 BUILD_LIST 0
LOAD_CONST 0 ((1, 2, 3))
LIST_EXTEND 1
STORE_NAME 1 (y)
RETURN_CONST 1 (None)
The tuple is one LOAD_CONST. The list needs a new list object and a copy of the elements. Measured with timeit on my machine, the five-element tuple literal took about 4 ns and the list literal about 30 ns. That difference only matters in very hot loops, but it's why constant lookup tables are often tuples.
Another small optimization: tuple(t) on an existing tuple returns the same object instead of copying, since there's no way the copy could ever differ. list(l) always makes a new list.
What's Not Faster
Indexing and iteration speed are essentially the same for both types. And for membership tests, neither is the right tool for large collections: x in some_tuple and x in some_list both scan linearly. For a fixed set of allowed values, a frozenset gives constant-time lookups. The sets post covers why.
Shared Operations
Most read-only operations work identically on both:
print((1, 2) + (3,), (1, 2) * 2, (1, 2) < (1, 3), [1, 2] == (1, 2))
(1, 2, 3) (1, 2, 1, 2) True False
Concatenation, repetition, slicing, len(), in, min(), max(), and sorted() all work. Comparisons are lexicographic, element by element, which makes tuples convenient sort keys. But a list and a tuple never compare equal, even with the same contents.
Converting between them is cheap and explicit:
print(tuple(nums), list(point))
(1, 2, 3, 4) [3, 4]
Type Hints
The two types are annotated differently, which reflects their different roles:
def total(prices: tuple[float, ...]) -> float:
return sum(prices)
record: tuple[str, int] = ("Lena", 34)
scores: list[int] = [90, 85, 77]
list[int]means a list of any length where every element is anint.tuple[str, int]means exactly two elements: astrthen anint. Type checkers verify the length and the type at each position.tuple[float, ...](with a literal ellipsis) means any number offloats, the homogeneous variable-length form.
When a function only reads a sequence, consider annotating the parameter as collections.abc.Sequence[int] instead of either concrete type. Callers can then pass lists, tuples, or ranges, and the signature tells readers the function won't mutate its argument. The type hints guide goes further into this.
Defensive Use: Exposing Data You Don't Want Changed
If a class hands out a list it stores internally, callers can modify it behind the class's back. Returning a tuple closes that door:
class Config:
def __init__(self, hosts: list[str]) -> None:
self._hosts = tuple(hosts)
@property
def hosts(self) -> tuple[str, ...]:
return self._hosts
cfg = Config(["a", "b"])
print(cfg.hosts)
('a', 'b')
Copying the input into a tuple in __init__ also protects the object from changes the caller makes to their original list later. Similarly, module-level constants like VALID_METHODS = ("GET", "POST", "PUT") are clearer as tuples: anyone reading the code knows the values are fixed.
How to Choose
| Use a list when... | Use a tuple when... |
|---|---|
| The number of items changes over time | The number of items is fixed |
| Items are the same kind of thing | Each position means something different |
| You'll add, remove, sort, or replace items | The data should never change after creation |
| You're building results in a loop | You're returning several values from a function |
| You need a dict key or set element | |
| You're defining a constant sequence |
A few shortcuts:
- If you'd describe the data as "a list of X", it's probably a list.
- If you'd describe it as "an X with a Y and a Z", it's probably a tuple, or a
NamedTupleor dataclass once the fields need names. - If you need it as a dictionary key, it must be a tuple (or another hashable type).
- If you're unsure and nothing needs to change, a tuple is the safer default, because it can't be mutated by accident.
Conclusion
Lists and tuples share most of their read-only behavior, but they're built for different jobs. Lists are mutable, growable collections of similar items. Tuples are fixed-size records that can be hashed when their contents allow it, are slightly lighter in memory and faster to create, and signal to readers that the structure won't change.
Pick by intent first: a growing collection of similar things is a list, and a fixed group of related values is a tuple. The performance and hashability benefits then come along for free.


