../

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

ExpressionResultNotes
(), tuple()()the empty tuple
(1,), 1,(1,)the comma makes a tuple
(1)1just 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 tuplethere is no tuple comprehension: (x for x in xs) is a generator
(*a, *b), (*range(2), 9)concatenationunpacks 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)tuplesbuilt-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 tuple

Indexing, 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:

FormExampleNotes
a, b = tx, y = (3, 4)counts must match: ValueError: too many values to unpack (expected 2, got 3)
a, b = b, aswapthe right side is built first, then assigned left to right
first, *rest = trest == [2, 3]the starred name is always a list
def f(*args)argsthe reverse: collected positional arguments are a tuple
(x, y), z = (1, 2), 3nestedany depth
for i, (k, v) in enumerate(d.items()):nested in a loop
_, value = pairignore a field_ is an ordinary name by convention
f(*t)spread as arguments
match t: then case (x, 0):sequence patternmatches tuples and lists of that length
first, *rest = (1, 2, 3) 1 2 3 1 2 3 first = 1 rest = [2, 3]: a new list def f(*args): ... f(1, 2, 3) 1, 2, 3 positional arguments 1 2 3 args = (1, 2, 3): a tuple
A starred target collects a list; *args collects a tuple
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.

ExpressionResultNotes
t[0] = 9TypeError: 'tuple' object does not support item assignment
t += (4,)a new tuple bound to tO(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 intequal 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.

ExpressionResultWhy
(1, 2) < (1, 3)Truethe first items tie, the second decides
(2,) > (1, 99)Truethe first items decide; the rest is never looked at
(1, 2) < (1, 2, 0)Truea prefix is smaller
(1, "a") < (2, 0)Truestops at 1 < 2, so "a" is never compared with 0
(1, "a") < (1, 0)TypeErrorthe 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]Falsea tuple never equals a list
(1, 2, 5) (1, 3, 0) 1 2 5 1 3 0 == 1 == 1: a tie, so move to the next pair (1, 2, 5) (1, 3, 0) 1 2 5 1 3 0 == < True: 2 < 3 decides; 5 and 0 are never compared (2,) (1, 99) 2 1 99 > True: 2 > 1 decides at once; 99 is never looked at (1, 2) (1, 2, 0) 1 2 1 2 0 == == none True: every pair ties and the left runs out first (1, 'a') (1, 0) 1 'a' 1 0 == < TypeError: '<' not supported between instances of 'str' and 'int' (1, 'a') (2, 0) 1 'a' 2 0 < True: 1 < 2 decides before 'a' meets 0 [0] [1] [2] tie pair being compared raises never compared

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

ToolGivesNotes
collections.namedtuple("P", "x y")a tuple subclass with named fieldsuntyped; defaults= fills trailing fields
class P(NamedTuple): with x: int and y: int = 0the same, typedfrom typing; can have methods and docstrings
p.x, p[0], x, y = pa field, an index, unpackingit 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 recordnot 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 tuple

Tuples vs lists

tuplelist
Mutablenoyes
Hashableyes, when its items arenever
Typical usefixed-shape records, keys, multiple return valuescollections that grow or change
Methodscount, index11, including append and sort
Literal of constantsbuilt once, when the code is compiledbuilt every time the line runs
Typingper position (tuple[int, str]) or tuple[int, ...]list[int]
Variancecovariant: tuple[int, ...] is a tuple[float, ...]invariant
Size of 3 items (3.14)72 bytes80 to 88 bytes
t = (1, 2, 3) getsizeof(t) == 72 = 48 + 3 × 8 header one block: the pointers sit inside the tuple xs = [1, 2, 3] getsizeof(xs) == 88 = 56 + 4 × 8 header spare a second block, with room to grow Both point to the same int objects, which getsizeof counts in neither.
getsizeof: 72 bytes for the tuple, 88 for the list

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

OperationCostNotes
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 * kO(len(t) + len(u)), O(len(t) · k)always new tuples
t += (x,) in a loopO(n²) totalcollect in a list, then tuple(xs) once
hash(t)O(n) the first timecached from 3.14; 3.13 and earlier rehash on every lookup
t == u, t < uO(n) worststops 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 slots

getsizeof 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).

JobPythonWatch outMore
Grid cells as keysseen = {(r, c)}, dist[r, c] = da list [r, c] can't be a keyGrids as graphs
Grid as a set of cellscells = {(r, c) for ... if grid[r][c] == "."}then nxt in cells replaces the bounds checkBFS
DirectionsDIRS = ((1, 0), (-1, 0), (0, 1), (0, -1))a tuple of constant tuples is built onceBFS
Heap entriesheappush(h, (dist, node))a tie compares the next field: put a counter before payloads that can't compareDijkstra, heapq
Sort by several keyskey=lambda p: (p.age, p.name); negate numbers for descendingstrings can't be negated: two stable sortsMulti-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 frozensetsTop-down DP
Canonical keystuple(sorted(xs)), (min(u, v), max(u, v))an undirected edge needs one spellingWhat can be a key
Count signaturetuple(cnt) of a 26-slot count listGroup anagrams
Intervalsivs.sort() on (start, end) tuplessorts by start, then by endIntervals
Weighted edges(w, u, v), then edges.sort()put the field to sort by first, or pass a keyMinimum spanning tree
Value and indexbest, 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) pairsbisect_right(pairs, t, key=itemgetter(0))bisect_right(pairs, (t, math.inf)) raises TypeError when the values are stringsLower & upper bound
Combinationscombinations(xs, 2), product(...), permutations(xs)they yield tuples: list(p) before editing onePermutations and combinations
Swapxs[i], xs[j] = xs[j], xs[i]the right side is built firstTwo 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 edge

Typing

AnnotationMeans
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 items

tuple[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 / TSPython
[x, y] used as a pair(x, y)
TS [number, string]tuple[int, str]
readonly [number, number], as consta tuple is already immutable
const [a, b] = paira, b = pair
[a, b] = [b, a]a, b = b, a
const [first, ...rest] = xsfirst, *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 identitytuple 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]]))  # -1

Canonical 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