Tuples — Immutable Ordered Sequences

Tuples are ordered, immutable sequences — once created, their contents cannot be changed. This immutability is not a limitation but a feature: it signals to readers and tools that this collection represents a fixed record whose values should not change. Python uses tuples internally everywhere — function multiple return values, enumerate() and zip() produce tuples, and SQLAlchemy row proxies behave like tuples. Understanding when to use a tuple instead of a list — and mastering tuple unpacking — will make your FastAPI and database code more readable and intentional.

Creating and Accessing Tuples

# Create with parentheses (or just commas)
point     = (10, 20)
rgb       = (255, 128, 0)
single    = (42,)          # trailing comma required for single-element tuple
also_tuple = 42,           # parentheses optional — comma makes it a tuple
empty     = ()

# No parentheses — the comma creates the tuple
x, y, z = 1, 2, 3         # multiple assignment is tuple packing/unpacking

# Indexing and slicing — same as lists
point[0]    # 10
point[-1]   # 20
rgb[0:2]    # (255, 128)

# Length and membership
len(rgb)          # 3
255 in rgb        # True

# Tuples are immutable — these all raise TypeError:
# point[0] = 5
# point.append(30)
# del point[0]

# But if a tuple contains a mutable object, that object can change:
mixed = ([1, 2], "hello")
mixed[0].append(3)   # OK! The list inside is mutable
print(mixed)         # ([1, 2, 3], "hello")
Note: A single-element tuple requires a trailing comma: (42,). Without it, (42) is just the integer 42 in parentheses — Python uses parentheses for grouping expressions, not for creating tuples. The comma is what makes a tuple, not the parentheses. This is a common Python gotcha: type((42)) is int, but type((42,)) is tuple.
Tip: Use a tuple when the collection represents a fixed record where the position has semantic meaning — a coordinate (x, y), a colour (r, g, b), a database row (id, name, email). Use a list when the collection is a sequence that will grow, shrink, or be reordered. The choice communicates intent: a tuple says “these values belong together and form a complete record”, a list says “this is a variable-length sequence of similar items”.
Warning: Tuples are hashable (when all their elements are hashable) which means they can be used as dictionary keys and set members. Lists cannot — they are mutable and therefore not hashable. This is why SQLAlchemy uses tuples for composite primary keys in its query API, and why you might see tuples as dictionary keys in caching patterns: cache[(user_id, post_id)] = result.

Tuple Unpacking

# Unpack a tuple into named variables
point = (10, 20)
x, y  = point          # x=10, y=20

rgb = (255, 128, 0)
red, green, blue = rgb

# Swap variables using tuple packing/unpacking (no temp variable!)
a, b = 1, 2
a, b = b, a            # a=2, b=1

# Extended unpacking with * (star)
first, *rest = [1, 2, 3, 4, 5]
print(first)   # 1
print(rest)    # [2, 3, 4, 5]

*most, last = [1, 2, 3, 4, 5]
print(most)    # [1, 2, 3, 4]
print(last)    # 5

first, *middle, last = [1, 2, 3, 4, 5]
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5

# Ignore values with _ (convention for unused variables)
_, y = (10, 20)         # only care about y
x, _, z = (1, 2, 3)    # ignore the middle value

# Nested unpacking
data = ((1, 2), (3, 4))
(a, b), (c, d) = data   # a=1, b=2, c=3, d=4

# Unpack in a for loop
pairs = [(1, "one"), (2, "two"), (3, "three")]
for number, word in pairs:
    print(f"{number} = {word}")

Returning Multiple Values from Functions

# Python "returns multiple values" by packing into a tuple
def get_user_stats(user_id: int):
    # ... database query ...
    return 42, 150, 3.8    # packs into a tuple (42, 150, 3.8)

# Unpack the returned tuple
post_count, view_total, avg_rating = get_user_stats(1)

# Or keep as a tuple
stats = get_user_stats(1)
print(stats[0])   # 42 — post_count

# FastAPI pattern: return metadata alongside data
def paginate_query(query, page: int, limit: int):
    total   = query.count()
    items   = query.offset((page - 1) * limit).limit(limit).all()
    return items, total, page   # tuple of (data, metadata)

posts, total, current_page = paginate_query(db.query(Post), 1, 10)

Named Tuples

from collections import namedtuple

# Create a named tuple class — fields have names, not just positions
Point  = namedtuple("Point", ["x", "y"])
Colour = namedtuple("Colour", ["red", "green", "blue"])
User   = namedtuple("User",   ["id", "name", "email", "role"])

# Create instances
origin = Point(0, 0)
red    = Colour(255, 0, 0)
alice  = User(1, "Alice", "alice@example.com", "admin")

# Access by name (readable) OR by index (tuple-compatible)
print(alice.name)    # "Alice"     — by name
print(alice[1])      # "Alice"     — by index
print(alice.email)   # "alice@example.com"

# Still immutable — alice.name = "Bob" raises AttributeError

# Unpack like a regular tuple
user_id, name, email, role = alice

# Convert to dict
alice._asdict()   # {"id": 1, "name": "Alice", ...}

# Replace a field (returns a new namedtuple)
bob = alice._replace(name="Bob", email="bob@example.com")

# Useful for database rows before Pydantic models exist
Row = namedtuple("Row", ["id", "title", "published"])
db_row = Row(1, "My Post", True)
print(db_row.title)   # "My Post"

Common Mistakes

Mistake 1 — Single-element tuple missing the comma

❌ Wrong — not a tuple, just a parenthesised integer:

t = (42)
type(t)   # <class 'int'> — not a tuple!

✅ Correct — trailing comma creates the tuple:

t = (42,)
type(t)   # <class 'tuple'> ✓

Mistake 2 — Trying to modify an immutable tuple

❌ Wrong — attempting to assign to a tuple index:

point = (1, 2)
point[0] = 10   # TypeError: 'tuple' object does not support item assignment

✅ Correct — create a new tuple with the modified value:

point = (10, point[1])   # new tuple with updated x ✓

Mistake 3 — Wrong number of variables in unpacking

❌ Wrong — mismatch between tuple length and variable count:

a, b = (1, 2, 3)   # ValueError: too many values to unpack

✅ Correct — match the count or use * to capture extras:

a, b, c = (1, 2, 3)       # exact match ✓
a, *rest = (1, 2, 3)      # a=1, rest=[2, 3] ✓

Quick Reference

Operation Code
Create tuple (1, 2, 3) or 1, 2, 3
Single element (42,) — trailing comma required
Unpack a, b = (1, 2)
Star unpack first, *rest = items
Ignore value _, y = point
Swap a, b = b, a
Named tuple class Point = namedtuple("Point", ["x", "y"])
Named tuple to dict p._asdict()
Replace field p._replace(x=5)

🧠 Test Yourself

A FastAPI route handler calls a function that returns (posts, total, page). How do you capture the total count without using tuple indexing?