Python — Tuples
A tuple is an ordered, immutable sequence of elements. Think of it as a list that cannot change — no appends, no removals, no item reassignment. Tuples are defined with parentheses () and can hold elements of any type, mixed freely.
Despite their simplicity, tuples are one of Python's most versatile data structures. They serve as lightweight records, dictionary keys, function return values, and the backbone of multiple assignment. Understanding when to reach for a tuple over a list is a hallmark of idiomatic Python.
Tuples can be created with literals, the tuple() constructor, or via comprehension-like generator expressions. The most common way is the parenthesized literal.
| 1 | # Tuple literals — parentheses optional in many contexts |
| 2 | empty = () # empty tuple |
| 3 | point = (3, 4) # two-element tuple |
| 4 | rgb = (255, 128, 0) # three elements |
| 5 | mixed = (1, "hello", 3.14) # heterogeneous elements |
| 6 | nested = ((1, 2), (3, 4)) # nested tuples |
| 7 | |
| 8 | # Single-element tuple — the trailing comma is REQUIRED |
| 9 | single = (42,) # this is a tuple |
| 10 | not_a_tuple = (42) # this is just the int 42 |
| 11 | print(type(single)) # <class 'tuple'> |
| 12 | print(type(not_a_tuple)) # <class 'int'> |
| 13 | |
| 14 | # tuple() constructor — from any iterable |
| 15 | from_list = tuple([1, 2, 3]) # → (1, 2, 3) |
| 16 | from_string = tuple("abc") # → ('a', 'b', 'c') |
| 17 | from_range = tuple(range(5)) # → (0, 1, 2, 3, 4) |
| 18 | from_generator = tuple(x**2 for x in range(4)) # → (0, 1, 4, 9) |
| 19 | |
| 20 | # Parentheses can be omitted in assignments (tuple packing) |
| 21 | packed = 1, 2, 3 # → (1, 2, 3) — implicit tuple |
| 22 | print(type(packed)) # <class 'tuple'> |
| 23 | |
| 24 | # Single-element packing also needs comma |
| 25 | packed_single = 42, |
| 26 | print(packed_single) # → (42,) |
| 27 | |
| 28 | # Empty tuple — no ambiguity here |
| 29 | empty2 = tuple() # → () |
warning
Once created, a tuple's structure cannot change: you cannot add, remove, or replace elements. However, if a tuple contains a mutable object (like a list), that object itself can be modified. This is called shallow immutability.
| 1 | # Immutable structure — these all fail |
| 2 | t = (1, 2, 3) |
| 3 | # t[0] = 99 # TypeError: 'tuple' object does not support item assignment |
| 4 | # t.append(4) # AttributeError: 'tuple' object has no attribute 'append' |
| 5 | # t.pop() # AttributeError |
| 6 | # del t[0] # TypeError |
| 7 | |
| 8 | # But mutable contents CAN be modified (shallow immutability) |
| 9 | t = (1, [10, 20], 3) |
| 10 | t[1].append(30) # OK — we're modifying the list, not the tuple |
| 11 | print(t) # → (1, [10, 20, 30], 3) |
| 12 | t[1][0] = 99 # OK — same reasoning |
| 13 | print(t) # → (1, [99, 20, 30], 3) |
| 14 | |
| 15 | # Why this matters — hashability |
| 16 | # Tuples are hashable ONLY if all their elements are hashable |
| 17 | hashable = (1, 2, 3) # OK — all ints are hashable |
| 18 | print(hash(hashable)) # → some integer |
| 19 | |
| 20 | # unhashable = (1, [2, 3], 4) # TypeError: unhashable type: 'list' |
| 21 | # because lists are mutable and therefore unhashable |
| 22 | |
| 23 | # Immutability enables tuples to be used as dictionary keys |
| 24 | d = {(1, 2): "point-a"} # OK |
| 25 | # d = {[1, 2]: "point-a"} # TypeError — lists aren't hashable |
| 26 | |
| 27 | # Tuples are also used internally — function *args are a tuple |
| 28 | def show_args(*args): |
| 29 | print(type(args)) # <class 'tuple'> |
| 30 | print(args) # immutable positional arguments |
| 31 | |
| 32 | show_args(1, 2, 3) # → (1, 2, 3) |
info
Tuples support the same indexing and slicing operations as lists. Since they are sequences, everything that reads from a list also works on a tuple.
| 1 | t = (10, 20, 30, 40, 50) |
| 2 | |
| 3 | # Indexing — zero-based, negative wraps around |
| 4 | print(t[0]) # → 10 |
| 5 | print(t[-1]) # → 50 (last element) |
| 6 | print(t[-2]) # → 40 (second-to-last) |
| 7 | |
| 8 | # Slicing — returns a NEW tuple (always a copy) |
| 9 | print(t[1:4]) # → (20, 30, 40) indices 1..3 |
| 10 | print(t[:3]) # → (10, 20, 30) start to index 2 |
| 11 | print(t[2:]) # → (30, 40, 50) index 2 to end |
| 12 | print(t[::2]) # → (10, 30, 50) every other element |
| 13 | print(t[::-1]) # → (50, 40, 30, 20, 10) reversed copy |
| 14 | |
| 15 | # Membership testing — O(n) |
| 16 | print(30 in t) # → True |
| 17 | print(99 in t) # → False |
| 18 | print(99 not in t) # → True |
| 19 | |
| 20 | # Length |
| 21 | print(len(t)) # → 5 |
| 22 | |
| 23 | # Concatenation and repetition — new tuples |
| 24 | print(t + (60, 70)) # → (10, 20, 30, 40, 50, 60, 70) |
| 25 | print(t * 2) # → (10, 20, 30, 40, 50, 10, 20, 30, 40, 50) |
| 26 | print((0,) * 5) # → (0, 0, 0, 0, 0) |
| 27 | |
| 28 | # Comparison — lexicographic, element by element |
| 29 | print((1, 2, 3) < (1, 2, 4)) # → True |
| 30 | print((1, 2) < (1, 2, 3)) # → True (shorter is "less") |
| 31 | print((1, 3) < (1, 2, 4)) # → False (3 > 2 at index 1) |
Tuple unpacking lets you assign each element of a tuple to a separate variable in one line. This is one of Python's most elegant features and extends far beyond tuples — it works with any iterable.
| 1 | # Basic unpacking |
| 2 | point = (3, 4) |
| 3 | x, y = point |
| 4 | print(x, y) # → 3 4 |
| 5 | |
| 6 | # Swapping variables — the Pythonic way |
| 7 | a, b = 10, 20 |
| 8 | a, b = b, a |
| 9 | print(a, b) # → 20 10 |
| 10 | |
| 11 | # Unpacking any iterable — not just tuples |
| 12 | first, second, third = [1, 2, 3] |
| 13 | print(first, second, third) # → 1 2 3 |
| 14 | |
| 15 | # Star (*) unpacking — Python 3+ |
| 16 | head, *tail = (1, 2, 3, 4, 5) |
| 17 | print(head) # → 1 |
| 18 | print(tail) # → [2, 3, 4, 5] (always a list!) |
| 19 | |
| 20 | *beginning, last = (1, 2, 3, 4, 5) |
| 21 | print(beginning) # → [1, 2, 3, 4] |
| 22 | print(last) # → 5 |
| 23 | |
| 24 | first, *middle, last = (1, 2, 3, 4, 5) |
| 25 | print(first, middle, last) # → 1 [2, 3, 4] 5 |
| 26 | |
| 27 | # Star unpacking in function calls — spread operator |
| 28 | def add(a, b, c): |
| 29 | return a + b + c |
| 30 | |
| 31 | nums = (10, 20, 30) |
| 32 | print(add(*nums)) # → 60 — same as add(10, 20, 30) |
| 33 | |
| 34 | # Nested unpacking |
| 35 | data = (1, (2, 3), 4) |
| 36 | a, (b, c), d = data |
| 37 | print(a, b, c, d) # → 1 2 3 4 |
| 38 | |
| 39 | # Ignoring values with underscore |
| 40 | _, important, *_ = (1, 2, 3, 4, 5) |
| 41 | print(important) # → 2 |
| 42 | |
| 43 | # Multiple return values — functions "return" tuples |
| 44 | def min_max(items): |
| 45 | return min(items), max(items) |
| 46 | |
| 47 | low, high = min_max([3, 1, 7, 2, 9]) |
| 48 | print(low, high) # → 1 9 |
| 49 | |
| 50 | # For loop unpacking — iterate over pairs |
| 51 | pairs = [(1, "a"), (2, "b"), (3, "c")] |
| 52 | for num, letter in pairs: |
| 53 | print(f"{letter}: {num}") |
| 54 | # → a: 1 |
| 55 | # → b: 2 |
| 56 | # → c: 3 |
| 57 | |
| 58 | # Enumerate returns tuples — already unpacking |
| 59 | for idx, val in enumerate(["x", "y", "z"]): |
| 60 | print(idx, val) |
best practice
A namedtuple is a factory function that creates tuple subclasses with named fields. You get the immutability and memory efficiency of tuples with the readability of named attributes — perfect for lightweight data records.
| 1 | from collections import namedtuple |
| 2 | |
| 3 | # Defining a named tuple type |
| 4 | Point = namedtuple("Point", ["x", "y"]) |
| 5 | # ^ class name ^ type name ^ field names |
| 6 | |
| 7 | # Creating instances |
| 8 | p1 = Point(3, 4) |
| 9 | p2 = Point(x=10, y=20) |
| 10 | |
| 11 | # Access by index (tuple behavior) |
| 12 | print(p1[0]) # → 3 |
| 13 | print(p1[1]) # → 4 |
| 14 | |
| 15 | # Access by attribute (named behavior) |
| 16 | print(p1.x) # → 3 |
| 17 | print(p1.y) # → 4 |
| 18 | |
| 19 | # Unpacking works |
| 20 | x, y = p1 |
| 21 | print(x, y) # → 3 4 |
| 22 | |
| 23 | # Immutable — cannot set attributes |
| 24 | # p1.x = 99 # AttributeError: can't set attribute |
| 25 | |
| 26 | # Useful for return values |
| 27 | def divide(a, b): |
| 28 | Result = namedtuple("Result", ["quotient", "remainder"]) |
| 29 | return Result(a // b, a % b) |
| 30 | |
| 31 | res = divide(10, 3) |
| 32 | print(res.quotient) # → 3 |
| 33 | print(res.remainder) # → 1 |
| 34 | q, r = divide(10, 3) # unpacking still works |
| 35 | |
| 36 | # Named tuple fields and properties |
| 37 | print(Point._fields) # → ('x', 'y') — tuple of field names |
| 38 | print(p1._asdict()) # → {'x': 3, 'y': 4} |
| 39 | |
| 40 | # _replace — create a NEW tuple with some fields changed |
| 41 | p3 = p1._replace(x=99) |
| 42 | print(p1) # → Point(x=3, y=4) — original unchanged |
| 43 | print(p3) # → Point(x=99, y=4) — new tuple |
| 44 | |
| 45 | # _make — create from iterable |
| 46 | data = [5, 6] |
| 47 | p4 = Point._make(data) |
| 48 | print(p4) # → Point(x=5, y=6) |
| 49 | |
| 50 | # Field defaults (Python 3.7+) |
| 51 | Node = namedtuple("Node", ["value", "left", "right"], defaults=(None, None)) |
| 52 | root = Node(42) |
| 53 | print(root) # → Node(value=42, left=None, right=None) |
| 54 | |
| 55 | # Docstring and module info |
| 56 | print(Point.__doc__) # → Point(x, y) |
| 57 | print(Point.x.__doc__) # → Alias for field number 0 |
| 58 | |
| 59 | # Renaming invalid field names automatically |
| 60 | # NamedTuple = namedtuple("Bad", ["class", "for", "def"], rename=True) |
| 61 | # → Bad(class_=1, for_=2, def_=3) |
| 62 | |
| 63 | # Type hints with NamedTuple (Python 3.6+) |
| 64 | from typing import NamedTuple |
| 65 | |
| 66 | class Employee(NamedTuple): |
| 67 | name: str |
| 68 | id: int |
| 69 | active: bool = True |
| 70 | |
| 71 | e = Employee("Alice", 1001) |
| 72 | print(e.name) # → Alice |
| 73 | print(e.active) # → True |
info
Tuples have only two built-in methods — count and index. Since tuples are immutable, there are no modifying methods like append, extend, or sort.
| 1 | t = (1, 2, 3, 2, 4, 2, 5) |
| 2 | |
| 3 | # count — how many times a value appears |
| 4 | print(t.count(2)) # → 3 |
| 5 | print(t.count(1)) # → 1 |
| 6 | print(t.count(99)) # → 0 |
| 7 | |
| 8 | # index — first position of a value (raises ValueError if not found) |
| 9 | print(t.index(3)) # → 2 |
| 10 | print(t.index(2)) # → 1 (first occurrence) |
| 11 | print(t.index(2, 2)) # → 3 (start searching at index 2) |
| 12 | print(t.index(2, 4)) # → 5 (start searching at index 4) |
| 13 | # t.index(99) # ValueError: tuple.index(x): x not in tuple |
| 14 | |
| 15 | # Helper pattern — safe index with default |
| 16 | def safe_index(tup, value, default=-1): |
| 17 | try: |
| 18 | return tup.index(value) |
| 19 | except ValueError: |
| 20 | return default |
| 21 | |
| 22 | print(safe_index(t, 2)) # → 1 |
| 23 | print(safe_index(t, 99, -1)) # → -1 |
| 24 | |
| 25 | # That's it — no other methods. Compare to lists which have: |
| 26 | # append, extend, insert, remove, pop, clear, sort, reverse |
| 27 | # Tuples don't need them — they can't change! |
note
Tuples shine in specific scenarios where immutability, hashability, or memory efficiency matter. Here are the most common use cases in idiomatic Python.
| 1 | # 1. Fixed records / lightweight structs |
| 2 | person = ("Alice", 30, "Engineer") |
| 3 | # vs: person = {"name": "Alice", "age": 30, "job": "Engineer"} |
| 4 | # Tuple is lighter, but you lose named access |
| 5 | |
| 6 | # 2. Multiple return values from functions |
| 7 | def divide_with_remainder(a, b): |
| 8 | return a // b, a % b # returns a tuple |
| 9 | |
| 10 | quotient, remainder = divide_with_remainder(10, 3) |
| 11 | |
| 12 | # 3. Dictionary keys — tuples are hashable |
| 13 | locations = { |
| 14 | (40.7128, -74.0060): "New York", |
| 15 | (34.0522, -118.2437): "Los Angeles", |
| 16 | (51.5074, -0.1278): "London", |
| 17 | } |
| 18 | # Lists cannot be dict keys — tuples enable multi-key lookups |
| 19 | |
| 20 | # 4. Function arguments (*args is a tuple) |
| 21 | def log(message, *tags): |
| 22 | # tags is a tuple — immutable, always fixed during call |
| 23 | for tag in tags: |
| 24 | print(f"[{tag}] {message}") |
| 25 | |
| 26 | log("server started", "info", "system") |
| 27 | |
| 28 | # 5. Immutable sequences as configuration constants |
| 29 | DEFAULT_CONFIG = ("localhost", 8080, False) |
| 30 | |
| 31 | def connect(host, port, use_tls): |
| 32 | pass |
| 33 | |
| 34 | connect(*DEFAULT_CONFIG) # unpack as arguments |
| 35 | |
| 36 | # 6. Database rows — fetched as tuples |
| 37 | # cursor.fetchall() returns list of tuples |
| 38 | # Each row: (id, name, email) |
| 39 | |
| 40 | # 7. Grouping related values without a class |
| 41 | from datetime import date |
| 42 | |
| 43 | def get_date_parts(): |
| 44 | today = date.today() |
| 45 | return today.year, today.month, today.day |
| 46 | |
| 47 | y, m, d = get_date_parts() |
| 48 | |
| 49 | # 8. Format strings — safe positional arguments |
| 50 | print("Year: %d, Month: %d, Day: %d" % (y, m, d)) |
| 51 | |
| 52 | # 9. Storing heterogeneous but fixed-length data |
| 53 | # CSV row, GPS coordinate, RGB color, bounding box |
| 54 | rgb = (255, 128, 0) # always 3 values |
| 55 | bbox = (10, 20, 100, 200) # always 4 values |
best practice
Choosing between tuple and list affects performance, semantics, and API design. Here's how they compare across key dimensions.
| Criterion | Tuple | List |
|---|---|---|
| Mutability | Immutable | Mutable |
| Hashable | Yes (if elements are) | No |
| Dict Key | Yes | No |
| Memory | ~16-40% smaller | Larger (over-allocation) |
| Creation Speed | Faster | Slower |
| Methods | count, index (2) | append, extend, insert, remove, pop, clear, sort, reverse, count, index (10+) |
| Use Case | Fixed structure, records, keys | Variable-length collections |
| Iteration | Comparable speed | Comparable speed |
| Syntax | (1, 2, 3) | [1, 2, 3] |
| 1 | # Memory efficiency — tuples don't over-allocate |
| 2 | import sys |
| 3 | |
| 4 | lst = [1, 2, 3, 4, 5] |
| 5 | tup = (1, 2, 3, 4, 5) |
| 6 | |
| 7 | print(sys.getsizeof(lst)) # → 120 (varies by Python version) |
| 8 | print(sys.getsizeof(tup)) # → 80 (no over-allocation overhead) |
| 9 | |
| 10 | # Lists pre-allocate extra capacity for future appends |
| 11 | # Tuples allocate exactly what they need — no more, no less |
| 12 | |
| 13 | # Creation speed |
| 14 | import timeit |
| 15 | |
| 16 | t_list = timeit.timeit("[1, 2, 3, 4, 5]", number=10_000_000) |
| 17 | t_tuple = timeit.timeit("(1, 2, 3, 4, 5)", number=10_000_000) |
| 18 | |
| 19 | print(f"list creation: {t_list:.3f}s") |
| 20 | print(f"tuple creation: {t_tuple:.3f}s") |
| 21 | # Tuples are typically 1.5-2x faster to create |
| 22 | |
| 23 | # Semantics matter more than performance |
| 24 | # Use tuple to signal: "this data should not change" |
| 25 | DEFAULT_PORT = (8080, "http") |
| 26 | |
| 27 | # Use list to signal: "this data may grow or shrink" |
| 28 | active_connections = [8080, 3000, 443] |
info