Type something to search...
Lists vs Tuples in Python: Choosing the Right Sequence

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 an int.
  • tuple[str, int] means exactly two elements: a str then an int. Type checkers verify the length and the type at each position.
  • tuple[float, ...] (with a literal ellipsis) means any number of floats, 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 timeThe number of items is fixed
Items are the same kind of thingEach position means something different
You'll add, remove, sort, or replace itemsThe data should never change after creation
You're building results in a loopYou'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 NamedTuple or 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading