Codetail

Article 6 of 15

Tuples

Immutability and why it matters.

14 min read

Creating tuples

A tuple is a sequence of values grouped together. Unlike lists, you cannot add, remove, or change items after creation. That constraint is the whole point.

The most common syntax uses parentheses, but the parentheses are not what makes a tuple. The comma is what makes a tuple. Parentheses are just punctuation that improves readability.

Python
1# Standard syntax
2point = (3, 7)
3rgb = (255, 128, 0)
4empty = ()
5
6# Parentheses are optional, the comma creates the tuple
7point = 3, 7 # same as (3, 7)
8x = 1, 2, 3 # same as (1, 2, 3)
9
10print(type(point)) # <class 'tuple'>
11print(type(x)) # <class 'tuple'>

The single-element gotcha

Creating a tuple with exactly one item trips up almost everyone the first time. Writing (42) gives you the integer 42, not a tuple. The parentheses are just grouping. You need the trailing comma.

Python
1not_a_tuple = (42) # just the integer 42
2single = (42,) # a tuple containing 42
3also_single = 42, # same thing, no parens needed
4
5print(type(not_a_tuple)) # <class 'int'>
6print(type(single)) # <class 'tuple'>
7print(single) # (42,)
8print(len(single)) # 1

Python always displays single-element tuples with the trailing comma: (42,). That is a reminder that the comma is the real syntax, not the parentheses.

The tuple() constructor

tuple() converts any iterable into a tuple. This is how you freeze a list, range, or string into an immutable sequence.

Python
1from_list = tuple([1, 2, 3]) # (1, 2, 3)
2from_range = tuple(range(5)) # (0, 1, 2, 3, 4)
3from_str = tuple("abc") # ('a', 'b', 'c')
4from_set = tuple({3, 1, 2}) # (1, 2, 3): order not guaranteed
5
6print(from_list) # (1, 2, 3)
7print(from_range) # (0, 1, 2, 3, 4)
8print(from_str) # ('a', 'b', 'c')

Immutability

A tuple is immutable. Once created, you cannot change its contents. You cannot add items, remove items, or replace an item at a given index. Any attempt raises a TypeError.

Python
1t = (1, 2, 3)
2
3t[0] = 99 # TypeError: 'tuple' object does not support item assignment
4t.append(4) # AttributeError: 'tuple' object has no attribute 'append'
5del t[0] # TypeError: 'tuple' object doesn't support item deletion

Reading, slicing, and searching all work the same as with lists.

Python
1t = (10, 20, 30, 40, 50)
2
3print(t[0]) # 10
4print(t[-1]) # 50
5print(t[1:3]) # (20, 30)
6print(30 in t) # True
7print(len(t)) # 5
8print(t.count(20)) # 1
9print(t.index(40)) # 3

The mutable-inside gotcha

A tuple cannot be changed, but if it contains a mutable object, that object can still be changed. The tuple holds a reference to the object, not a frozen copy of it. This surprises people, and it is worth understanding clearly.

Python
1t = ([1, 2], [3, 4]) # a tuple of two lists
2
3# The tuple itself is immutable
4# t[0] = [9, 9] # TypeError: can't reassign the reference
5
6# But the list inside can be mutated
7t[0].append(99)
8print(t) # ([1, 2, 99], [3, 4])
9print(t[0]) # [1, 2, 99]

Immutability means the tuple's references cannot change. The objects those references point to follow their own rules. A tuple of lists is not deeply frozen.

Try the operations below to see exactly what a tuple allows and what it blocks.

Mutability Exploreritems = [1, 2, 3, 4, 5] ยท t = (1, 2, 3, 4, 5)
Try an operation
pick an operation above

Packing and unpacking

Putting values into a tuple is called packing. Pulling them back out into separate variables is called unpacking. Both happen in a single assignment line and both are used constantly in real Python code.

Python
1# Packing
2point = 3, 7 # packs 3 and 7 into a tuple
3
4# Unpacking
5x, y = point # assigns 3 to x and 7 to y
6print(x, y) # 3 7
7
8# Pack and unpack in one line
9x, y = 3, 7 # the right side creates a temporary tuple
10print(x, y) # 3 7

Swap without a temp variable

The classic interview trick. Python evaluates the right side completely before doing any assignment, so this works cleanly with no temporary variable needed.

Python
1a, b = 10, 20
2print(a, b) # 10 20
3
4a, b = b, a # right side (b, a) = (20, 10) is packed first, then unpacked
5print(a, b) # 20 10

Starred unpacking

The * prefix in an unpacking assignment collects all remaining items into a list. It can go anywhere in the assignment, not just at the end.

Python
1nums = (1, 2, 3, 4, 5)
2
3first, *rest = nums
4print(first) # 1
5print(rest) # [2, 3, 4, 5]
6
7*init, last = nums
8print(init) # [1, 2, 3, 4]
9print(last) # 5
10
11first, *middle, last = nums
12print(middle) # [2, 3, 4]

Starred unpacking always produces a list, even when collecting from a tuple. Only one starred variable is allowed per unpacking expression.

Ignoring values with _

When a tuple has values you do not need, assign them to _. It is a legal variable name but signals to any reader that the value is intentionally discarded.

Python
1# Only care about the score, not name or city
2_, score, _ = ("Alice", 95, "NYC")
3print(score) # 95
4
5# Unpack a 3-tuple but only use the first value
6x, *_ = (10, 20, 30, 40)
7print(x) # 10

See it live. Pick a pattern below and watch how the tuple items map to variables.

Unpacking Explorer
Pick a pattern
pick a pattern above

Named tuples

A plain tuple forces you to remember that index 0 is the x coordinate and index 1 is the y coordinate. A named tuple gives each position a name so you can write p.x instead of p[0]. It is still a tuple underneath and has no extra memory cost.

collections.namedtuple

Python
1from collections import namedtuple
2
3Point = namedtuple("Point", ["x", "y"])
4
5p = Point(3, 7)
6print(p) # Point(x=3, y=7)
7print(p.x) # 3
8print(p[0]) # 3: index access still works
9print(p.y) # 7
10
11# It is a real tuple
12print(isinstance(p, tuple)) # True
13print(len(p)) # 2
14x, y = p # unpacking works too

typing.NamedTuple (preferred)

The modern way uses class syntax with type annotations. It reads more clearly, supports default values, and plays well with type checkers.

Python
1from typing import NamedTuple
2
3class Point(NamedTuple):
4 x: float
5 y: float
6 label: str = "" # default value, optional field
7
8p1 = Point(3.0, 7.0)
9p2 = Point(x=1.5, y=2.5, label="origin")
10
11print(p1) # Point(x=3.0, y=7.0, label='')
12print(p2.label) # origin
13
14# Methods work normally
15def distance(p: Point) -> float:
16 return (p.x ** 2 + p.y ** 2) ** 0.5
17
18print(distance(p1)) # 7.615773105863909

_replace() for immutable updates

You cannot modify a named tuple in place, but _replace() returns a new instance with specific fields changed. The original is untouched.

Python
1from typing import NamedTuple
2
3class Employee(NamedTuple):
4 name: str
5 dept: str
6 salary: int
7
8alice = Employee("Alice", "Engineering", 90000)
9promoted = alice._replace(salary=110000, dept="Engineering Lead")
10
11print(alice) # Employee(name='Alice', dept='Engineering', salary=90000)
12print(promoted) # Employee(name='Alice', dept='Engineering Lead', salary=110000)

Named tuple vs dataclass

If you need immutability and interoperability with tuple operations (unpacking, iteration, len), use a named tuple. If you need mutability, inheritance, or custom methods, use a dataclass. Named tuples use less memory and have zero overhead compared to a plain tuple.

Explore field access below. Click any field to see both the name-based and index-based access side by side.

NamedTuple Explorer
Pick a color
instance
c = Color(255, 99, 88)
isinstance(c, tuple) True
Click a field

Hashability

A hashable object has a stable integer identity that never changes during its lifetime. Python requires this for any object used as a dictionary key or placed in a set. Lists are not hashable because they can change. Tuples are hashable because they cannot.

Python
1# Tuples can be dict keys
2grid = {
3 (0, 0): "origin",
4 (1, 0): "right",
5 (0, 1): "up",
6}
7print(grid[(1, 0)]) # right
8
9# Lists cannot
10# bad = {[0, 0]: "origin"} # TypeError: unhashable type: 'list'
11
12# Tuples can be in sets
13visited = set()
14visited.add((3, 5))
15visited.add((1, 2))
16print((3, 5) in visited) # True

Practical: graph traversal

The most common use of tuple hashability is tracking visited positions in a grid or graph. A set of tuples gives you O(1) membership tests with readable code.

Python
1def bfs(start: tuple, grid: set) -> list:
2 from collections import deque
3
4 visited = {start}
5 queue = deque([start])
6 order = []
7
8 while queue:
9 x, y = queue.popleft()
10 order.append((x, y))
11
12 for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
13 neighbor = (x + dx, y + dy)
14 if neighbor in grid and neighbor not in visited:
15 visited.add(neighbor)
16 queue.append(neighbor)
17
18 return order
19
20cells = {(0,0), (0,1), (1,0), (1,1), (2,0)}
21print(bfs((0, 0), cells))

When tuples are not hashable

A tuple is only hashable if all of its elements are hashable. Put a list inside and the whole tuple becomes unhashable.

Python
1hash((1, 2, 3)) # works fine
2hash((1, "hello", True)) # works fine
3
4# A tuple containing a list is not hashable
5t = (1, [2, 3])
6hash(t) # TypeError: unhashable type: 'list'

If you need a hashable fixed-length sequence with mutable elements, freeze them first. For example, convert the inner list to a tuple: (1, tuple([2, 3])) is hashable.

Tuples vs lists

Both are sequences. The difference is intent. Use a list when you have a collection of items that can grow, shrink, or be reordered. Use a tuple when you have a fixed structure where each position has a specific meaning.

List

A collection of items of the same kind. The number of items can change. Position is arbitrary.

users = ['Alice', 'Bob', 'Carol']

Tuple

A record where each position has fixed meaning. The structure is part of the design.

point = (x, y)

Returning multiple values

Functions that return multiple values return a tuple. Python packs the return values automatically. The caller unpacks them. This is one of the most common tuple patterns in real code.

Python
1def min_max(nums):
2 return min(nums), max(nums) # returns a tuple
3
4lo, hi = min_max([3, 1, 4, 1, 5, 9, 2, 6])
5print(lo, hi) # 1 9
6
7# Works with any iterable
8def split_name(full_name: str) -> tuple[str, str]:
9 parts = full_name.split(" ", 1)
10 return parts[0], parts[1] if len(parts) > 1 else ""
11
12first, last = split_name("Ada Lovelace")
13print(first, last) # Ada Lovelace

Structured records

When data has a fixed schema, a tuple makes that structure explicit. A database row, a coordinate, a color channel, a key-value pair. All are natural tuples.

Python
1# Database rows
2rows = [
3 ("Alice", "Engineering", 90000),
4 ("Bob", "Design", 80000),
5 ("Carol", "Product", 95000),
6]
7
8for name, dept, salary in rows:
9 print(f"{name} ({dept}): {salary}")
10
11# Coordinates on a grid
12DIRECTIONS = {
13 "up": (0, -1),
14 "down": (0, 1),
15 "left": (-1, 0),
16 "right": (1, 0),
17}

Signal immutability to the reader

Choosing a tuple is also a signal. When a function returns a tuple, the caller knows the structure is fixed. When a constant is a tuple, it is clear that nobody should add to or modify it. The choice communicates intent even before anyone reads the code.

Python
1# Constants as tuples, clearly not meant to grow
2WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri")
3HTTP_OK = (200, 201, 202, 204)
4RGB_BLACK = (0, 0, 0)
5
6# Using a list here would mislead the reader into thinking
7# this collection is meant to be modified