Tuples
tuple in Python 3.14: creating (the comma makes it, not the parentheses), unpacking, immutability
and hashing, lexicographic comparison, named tuples, how tuples differ from lists, costs, and the
jobs DS&A solutions give them: keys, heap entries, states and sort keys. Indexing and slicing work as
in Lists; tuples as keys are also covered in
Dictionaries & sets.
Creating
| Expression | Result | Notes |
|---|---|---|
(), tuple() | () | the empty tuple |
(1,), 1, | (1,) | the comma makes a tuple |
(1) | 1 | just an int in parentheses |
1, 2, (1, 2) | (1, 2) | parentheses only group; you need them inside calls, literals and return (...) across lines |
tuple("abc"), tuple(range(3)) | ('a', 'b', 'c'), (0, 1, 2) | from any iterable |
tuple(x * x for x in xs) | a tuple | there is no tuple comprehension: (x for x in xs) is a generator |
(*a, *b), (*range(2), 9) | concatenation | unpacks any iterables |
(0,) * 3, (1, 2) + (3,) | (0, 0, 0), (1, 2, 3) | new tuples; (1, 2) + [3] is a TypeError |
divmod(7, 2), d.items(), enumerate(xs), zip(a, b) | tuples | built-ins hand you tuples all the time |
return lo, hi | (lo, hi) | "multiple return values" are one tuple |
single = (5,) # a 1-tuple
plain = (5) # just 5
pair = 3, 4 # parentheses are optional
type(single), type(plain)
# (<class 'tuple'>, <class 'int'>)
squares = tuple(n * n for n in range(4)) # (0, 1, 4, 9)
gen = (n * n for n in range(4)) # a generator, not a tupleIndexing, slicing & unpacking
t[0], t[-1], t[1:3], t[::-1] behave exactly as on lists; a slice is a new tuple. Unpacking is
where tuples earn their keep:
| Form | Example | Notes |
|---|---|---|
a, b = t | x, y = (3, 4) | counts must match: ValueError: too many values to unpack (expected 2, got 3) |
a, b = b, a | swap | the right side is built first, then assigned left to right |
first, *rest = t | rest == [2, 3] | the starred name is always a list |
def f(*args) | args | the reverse: collected positional arguments are a tuple |
(x, y), z = (1, 2), 3 | nested | any depth |
for i, (k, v) in enumerate(d.items()): | nested in a loop | |
_, value = pair | ignore a field | _ is an ordinary name by convention |
f(*t) | spread as arguments | |
match t: then case (x, 0): | sequence pattern | matches tuples and lists of that length |
point = (3, 4)
x, y = point
first, *rest = (1, 2, 3) # 1 and [2, 3]: rest is a list
(r, c), dist = (0, 1), 5 # nested unpacking
def describe(p: tuple[int, int]) -> str:
match p:
case (0, 0):
return "origin"
case (v, 0) | (0, v):
return f"on an axis at {v}"
case _:
return "elsewhere"
describe((0, 7)), describe((2, 2))
# ('on an axis at 7', 'elsewhere')Immutability & hashing
A tuple can't grow, shrink or rebind its items, so it is hashable whenever every item is. That makes it the default key for coordinates, search states and memo entries.
| Expression | Result | Notes |
|---|---|---|
t[0] = 9 | TypeError: 'tuple' object does not support item assignment | |
t += (4,) | a new tuple bound to t | O(n) copy; other names keep the old tuple |
t = ([1], 2) then t[0].append(3) | ([1, 3], 2) | the tuple is fixed, the list inside is not |
hash((1, 2)) | an int | equal tuples hash equal: (1, 2) and (1.0, 2) are one key |
{(1, [2]): 0} | TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list') | one unhashable item makes the whole tuple unhashable |
tuple(sorted(pair)) vs frozenset(pair) | an ordered key vs an unordered key | (1, 2) and (2, 1) are different tuple keys |
seen = {(0, 0), (0, 1)}
(0, 1) in seen # True: compared by value
memo: dict[tuple[int, int], int] = {}
memo[2, 3] = 10 # the parentheses are optional in [ ]
memo[(2, 3)] # 10
t = ([1], 2)
t[0].append(3) # allowed: the list itself is mutable
print(t) # ([1, 3], 2)
try:
hash(t)
except TypeError as e:
print(e) # unhashable type: 'list't[0] += [3] on that tuple raises TypeError and still extends the list; see
Copying & aliasing.
Comparing & sorting
Tuples compare item by item, left to right, and the first unequal pair decides (lexicographic
order). A tuple that is a prefix of a longer one is smaller. This one rule drives multi-key sorting,
heap entries and min/max over records.
| Expression | Result | Why |
|---|---|---|
(1, 2) < (1, 3) | True | the first items tie, the second decides |
(2,) > (1, 99) | True | the first items decide; the rest is never looked at |
(1, 2) < (1, 2, 0) | True | a prefix is smaller |
(1, "a") < (2, 0) | True | stops at 1 < 2, so "a" is never compared with 0 |
(1, "a") < (1, 0) | TypeError | the tie on 1 forces "a" < 0 |
min([(2, "b"), (1, "z"), (1, "a")]) | (1, 'a') | smallest first item, then smallest second |
sorted(pairs) | by first item, then second, ... | no key needed for "by x, then by y" |
(1, 2) == [1, 2] | False | a tuple never equals a list |
1/6
(1, 2, 5) < (1, 3, 0) compares pair by pair. The first pair is 1 and 1: a tie, so it moves on.
import heapq
from itertools import count
tasks = [(2, "write"), (1, "plan"), (2, "test")]
sorted(tasks) # [(1, 'plan'), (2, 'test'), (2, 'write')]
sorted(tasks, key=lambda t: (-t[0], t[1]))
# [(2, 'test'), (2, 'write'), (1, 'plan')]
h: list[tuple[int, int, dict[str, str]]] = []
tick = count() # a tiebreaker before the payload
for prio, name in tasks:
heapq.heappush(h, (prio, next(tick), {"name": name}))
heapq.heappop(h)[2] # {'name': 'plan'}Without the counter, two tasks with equal priority would compare their dicts and raise
TypeError: '<' not supported between instances of 'dict' and 'dict'.
Named tuples
| Tool | Gives | Notes |
|---|---|---|
collections.namedtuple("P", "x y") | a tuple subclass with named fields | untyped; defaults= fills trailing fields |
class P(NamedTuple): with x: int and y: int = 0 | the same, typed | from typing; can have methods and docstrings |
p.x, p[0], x, y = p | a field, an index, unpacking | it is a tuple |
p._replace(y=5) | a new tuple | |
p._asdict(), P._fields | {'x': 1, 'y': 2}, ('x', 'y') | |
@dataclass(frozen=True) | a hashable record | not a tuple: no indexing or unpacking, no < unless order=True |
from typing import NamedTuple
class Edge(NamedTuple):
w: int
u: str
v: str
edges = [
Edge(4, "a", "b"),
Edge(1, "b", "c"),
Edge(3, "a", "c"),
]
cheapest = min(edges) # compared by w, then u, then v
print(cheapest) # Edge(w=1, u='b', v='c')
w, u, v = cheapest # unpacks like any tuple
cheapest._replace(w=0) # Edge(w=0, u='b', v='c')
Edge(1, "b", "c") == (1, "b", "c") # True: just a tupleTuples vs lists
tuple | list | |
|---|---|---|
| Mutable | no | yes |
| Hashable | yes, when its items are | never |
| Typical use | fixed-shape records, keys, multiple return values | collections that grow or change |
| Methods | count, index | 11, including append and sort |
| Literal of constants | built once, when the code is compiled | built every time the line runs |
| Typing | per position (tuple[int, str]) or tuple[int, ...] | list[int] |
| Variance | covariant: tuple[int, ...] is a tuple[float, ...] | invariant |
| Size of 3 items (3.14) | 72 bytes | 80 to 88 bytes |
tuple(xs) and list(t) convert in O(n). To change one item, build a new tuple
(t[:i] + (v,) + t[i + 1 :]) or edit a list and convert once at the end.
Costs & memory
| Operation | Cost | Notes |
|---|---|---|
t[i], len(t) | O(1) | |
x in t, t.index(x), t.count(x) | O(n) | linear scans |
t[a:b] | O(b − a) | a new tuple |
t + u, t * k | O(len(t) + len(u)), O(len(t) · k) | always new tuples |
t += (x,) in a loop | O(n²) total | collect in a list, then tuple(xs) once |
hash(t) | O(n) the first time | cached from 3.14; 3.13 and earlier rehash on every lookup |
t == u, t < u | O(n) worst | stops at the first difference |
tuple(xs), list(t) | O(n) |
import sys
sys.getsizeof(()) # 48: the header (64-bit CPython 3.14)
sys.getsizeof((1, 2, 3)) # 72: plus 3 pointers
sys.getsizeof([1, 2, 3]) # 88: list header plus 4 slotsgetsizeof counts the pointers, not the items. For grid keys, (r, c) tuples against r * C + c
ints is weighed in Dictionaries & sets.
DS&A idioms
The tuple moves problem solutions are built from, and the trap in each. The patterns are in the DS&A sheets (linked in the last column).
| Job | Python | Watch out | More |
|---|---|---|---|
| Grid cells as keys | seen = {(r, c)}, dist[r, c] = d | a list [r, c] can't be a key | Grids as graphs |
| Grid as a set of cells | cells = {(r, c) for ... if grid[r][c] == "."} | then nxt in cells replaces the bounds check | BFS |
| Directions | DIRS = ((1, 0), (-1, 0), (0, 1), (0, -1)) | a tuple of constant tuples is built once | BFS |
| Heap entries | heappush(h, (dist, node)) | a tie compares the next field: put a counter before payloads that can't compare | Dijkstra, heapq |
| Sort by several keys | key=lambda p: (p.age, p.name); negate numbers for descending | strings can't be negated: two stable sorts | Multi-key sorts |
| Search and memo states | (r, c, keys_mask), @cache on f(i, j) | lists and sets inside a state must become tuples or frozensets | Top-down DP |
| Canonical keys | tuple(sorted(xs)), (min(u, v), max(u, v)) | an undirected edge needs one spelling | What can be a key |
| Count signature | tuple(cnt) of a 26-slot count list | Group anagrams | |
| Intervals | ivs.sort() on (start, end) tuples | sorts by start, then by end | Intervals |
| Weighted edges | (w, u, v), then edges.sort() | put the field to sort by first, or pass a key | Minimum spanning tree |
| Value and index | best, i = max((v, i) for i, v in enumerate(xs)) | ties go to the larger index; max(range(n), key=xs.__getitem__) keeps the first | |
Sorted (time, value) pairs | bisect_right(pairs, t, key=itemgetter(0)) | bisect_right(pairs, (t, math.inf)) raises TypeError when the values are strings | Lower & upper bound |
| Combinations | combinations(xs, 2), product(...), permutations(xs) | they yield tuples: list(p) before editing one | Permutations and combinations |
| Swap | xs[i], xs[j] = xs[j], xs[i] | the right side is built first | Two pointers |
from collections import deque
DIRS = ((1, 0), (-1, 0), (0, 1), (0, -1))
rows = ["..#", "...", "#.."]
cells = {
(r, c)
for r, row in enumerate(rows)
for c, ch in enumerate(row)
if ch == "."
} # open cells: membership replaces bounds checks
dist = {(0, 0): 0}
q = deque([(0, 0)])
while q:
r, c = q.popleft()
for dr, dc in DIRS:
nxt = (r + dr, c + dc)
if nxt in cells and nxt not in dist:
dist[nxt] = dist[r, c] + 1
q.append(nxt)
print(dist[2, 2]) # 4
xs = [5, 1, 5]
max((v, i) for i, v in enumerate(xs)) # (5, 2): last index
edge = (7, 3)
(min(edge), max(edge)) # (3, 7): one spelling per edgeTyping
| Annotation | Means |
|---|---|
tuple[int, str] | exactly two items: an int, then a str |
tuple[int, ...] | any length, all int |
tuple[()] | the empty tuple |
tuple[int, *tuple[str, ...]] | an int, then any number of str (3.11+) |
class P(NamedTuple): | named, typed fields |
Sequence[int] | tuples and lists alike, read-only |
from collections.abc import Sequence
type Cell = tuple[int, int]
def neighbors(cell: Cell) -> list[Cell]:
r, c = cell
return [(r + 1, c), (r, c + 1)]
def total(xs: Sequence[float]) -> float:
return sum(xs)
ints: tuple[int, ...] = (1, 2, 3)
floats: tuple[float, ...] = ints # OK: tuples are covariant
total(ints) # 6: a tuple is a Sequence
bad: tuple[int, int] = (1, 2, 3) # ❌ type error: 3 itemstuple[int, int] checks the length and the type of each position; list[int] can't express either.
Coming from JavaScript
TypeScript has tuple types over plain arrays: see Arrays and Objects.
| JS / TS | Python |
|---|---|
[x, y] used as a pair | (x, y) |
TS [number, string] | tuple[int, str] |
readonly [number, number], as const | a tuple is already immutable |
const [a, b] = pair | a, b = pair |
[a, b] = [b, a] | a, b = b, a |
const [first, ...rest] = xs | first, *rest = xs (rest is a list) |
`${r},${c}` or JSON.stringify([r, c]) as a key | (r, c): tuples hash and compare by value |
arrays as Map keys compare by identity | tuple keys compare by value |
pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1]) | pairs.sort(): tuples already sort lexicographically |
Object.freeze([1, 2]) | (1, 2) |
return [value, setValue] | return value, set_value |
JSON.stringify([1, 2]) | json.dumps((1, 2)) gives '[1, 2]', and json.loads hands back a list |
Recipes
BFS over puzzle states
When the state of a search is a whole board: Sliding Puzzle, Open the Lock.
from collections import deque
def sliding_puzzle(board: list[list[int]]) -> int:
start = tuple(x for row in board for x in row)
goal = (1, 2, 3, 4, 5, 0)
moves = ( # neighbors of each index on a 2 x 3 board
(1, 3), (0, 2, 4), (1, 5),
(0, 4), (1, 3, 5), (2, 4),
)
seen = {start}
q = deque([(start, 0)])
while q:
state, steps = q.popleft()
if state == goal:
return steps
z = state.index(0)
for j in moves[z]:
nxt = list(state) # tuples can't swap in place
nxt[z], nxt[j] = nxt[j], nxt[z]
key = tuple(nxt) # back to a hashable state
if key not in seen:
seen.add(key)
q.append((key, steps + 1))
return -1
print(sliding_puzzle([[1, 2, 3], [4, 0, 5]])) # 1
print(sliding_puzzle([[4, 1, 2], [5, 0, 3]])) # 5
print(sliding_puzzle([[1, 2, 3], [5, 4, 0]])) # -1Canonical keys
When equal things must collapse into one key: shifted strings, undirected edges.
from collections import defaultdict
def shift_key(s: str) -> tuple[int, ...]:
return tuple(
(ord(b) - ord(a)) % 26 for a, b in zip(s, s[1:])
)
words = "abc bcd acef xyz az ba a z".split()
groups: defaultdict[tuple[int, ...], list[str]]
groups = defaultdict(list)
for w in words:
groups[shift_key(w)].append(w)
print(sorted(groups.values()))
# [['a', 'z'], ['abc', 'bcd', 'xyz'], ['acef'], ['az', 'ba']]
edges = [(1, 2), (2, 1), (3, 1)]
unique = {(min(u, v), max(u, v)) for u, v in edges}
print(sorted(unique)) # [(1, 2), (1, 3)]Latest value at a time
When looking up the newest (time, value) at or before t: Time Based Key-Value Store.
import bisect
from operator import itemgetter
class TimeMap:
def __init__(self) -> None:
self.log: dict[str, list[tuple[int, str]]] = {}
def set(self, key: str, value: str, t: int) -> None:
self.log.setdefault(key, []).append((t, value))
def get(self, key: str, t: int) -> str:
pairs = self.log.get(key, []) # times increase
i = bisect.bisect_right(pairs, t, key=itemgetter(0))
return pairs[i - 1][1] if i else ""
tm = TimeMap()
tm.set("foo", "bar", 1)
tm.set("foo", "baz", 4)
print(tm.get("foo", 3), tm.get("foo", 4)) # bar baz
print(repr(tm.get("foo", 0))) # ''key= (3.10+) compares only the times. The older trick, bisect_right(pairs, (t, math.inf)),
compares math.inf with a value whenever the times tie, which raises for strings.
Merge intervals
When overlapping (start, end) ranges must collapse into disjoint ones.
type Interval = tuple[int, int]
def merge(ivs: list[Interval]) -> list[Interval]:
out: list[Interval] = []
for lo, hi in sorted(ivs): # by start, then end
if out and lo <= out[-1][1]:
out[-1] = (out[-1][0], max(out[-1][1], hi))
else:
out.append((lo, hi))
return out
print(merge([(8, 10), (1, 3), (2, 6), (15, 18)]))
# [(1, 6), (8, 10), (15, 18)]A tuple can't be stretched in place, so the last interval is replaced by a new tuple.
Records with NamedTuple
When parsed rows need names, sorting and cheap updates.
from typing import NamedTuple
class Score(NamedTuple):
name: str
points: int
time: float
rows = ["ann,90,31.5", "bo,95,40.2", "cy,90,29.8"]
scores = [
Score(n, int(p), float(t))
for n, p, t in (row.split(",") for row in rows)
]
board = sorted(scores, key=lambda s: (-s.points, s.time))
print([s.name for s in board]) # ['bo', 'cy', 'ann']
top = board[0]._replace(points=board[0].points + 5)
print(top) # Score(name='bo', points=100, time=40.2)Memoize on a canonical tuple
When the DP state is a collection: sort it into a tuple so equivalent states share one memo entry.
from functools import cache
def can_split(nums: list[int], k: int) -> bool:
total = sum(nums)
if total % k:
return False
target = total // k
items = sorted(nums, reverse=True)
@cache
def fill(i: int, buckets: tuple[int, ...]) -> bool:
if i == len(items):
return True
for j, b in enumerate(buckets):
if b + items[i] <= target:
nxt = list(buckets)
nxt[j] += items[i]
# sorted: equivalent states share an entry
if fill(i + 1, tuple(sorted(nxt))):
return True
return False
return fill(0, (0,) * k)
print(can_split([4, 3, 2, 3, 5, 2, 1], 4)) # True
print(can_split([2, 2, 2, 2, 3, 4, 5], 4)) # False(0, 3, 0) and (3, 0, 0) describe the same partial split; sorting makes them one key, which cuts
the search by up to k!.
References
- Python docs: Tuples and sequences (opens in a new tab) (tutorial): packing, unpacking, the 1-tuple comma
- Python docs: Tuples (opens in a new tab) and common sequence operations (opens in a new tab)
- Python docs: Value comparisons (opens in a new tab): the lexicographic rule for sequences
- Python docs: collections.namedtuple (opens in a new tab) and typing.NamedTuple (opens in a new tab)
- Python docs: heapq priority queue notes (opens in a new tab): why entries carry a counter
- Python docs: bisect (opens in a new tab):
key=for searching records - Python FAQ: Why are there separate tuple and list data types? (opens in a new tab)
- Typing spec: Tuples (opens in a new tab):
tuple[int, ...],tuple[()], unpacked tuples - PEP 636: Structural pattern matching tutorial (opens in a new tab): sequence patterns such as
case (x, 0):