Lesson 10 of 25

Tuples

A Fixed Group of Values

A tuple is an ordered collection, like a list, with one difference that changes everything about how it is used: it is immutable. Once built, you cannot replace an item, add one or remove one. colours[0] = "yellow" raises TypeError: 'tuple' object does not support item assignment.

Everything that only reads a sequence works the same as on a list — indexing, negative indexing, slicing, len(), in, and looping. What is missing is every method that would change it, which is why a tuple has exactly two methods, .count() and .index(), against a list's eleven. That short surface is a feature: when you see a tuple, you know nothing downstream can have altered it.

The syntax has one genuine trap. It is the comma, not the parentheses, that makes a tuple. (42) is just the number 42 with brackets around it — the same brackets you use in arithmetic. (42,) is a one-item tuple. The reverse also holds: person = "Asha", 30 is a perfectly good tuple with no parentheses at all. The brackets are usually there for clarity, and they are required when a tuple appears inside a function call so the commas are not mistaken for argument separators.

The consequence of that rule is an accidental tuple. A stray comma at the end of a line — easy to leave behind while editing — silently turns a value into a one-item tuple, and the error surfaces much later as something odd like a string that will not concatenate or a number that will not add.

Example
coordinates = (10, 20)
colours = ("red", "green", "blue")
empty = ()

print(colours[0])          # red
print(colours[-1])         # blue
print(colours[1:3])        # ('green', 'blue')
print(len(colours))        # 3
print("red" in colours)    # True
print(colours.index("blue"))   # 2

# Immutable: no assignment, no append, no remove
# colours[0] = "yellow"    # TypeError: 'tuple' object does not support
                           # item assignment

# The COMMA makes the tuple, not the brackets
print(type((42)))          # <class 'int'>    just a number in brackets
print(type((42,)))         # <class 'tuple'>  one-item tuple
print(type(42,))           # <class 'int'>    a trailing comma in a call
                           # is just an argument separator

person = "Asha", 30        # brackets are optional
print(person)              # ('Asha', 30)

# The accidental tuple
total = 100,
print(total)               # (100,)
# print(total + 5)         # TypeError: can only concatenate tuple to tuple
  • Written with commas; brackets are optional except where they would be ambiguous
  • (42,) is a one-item tuple; (42) is the number 42
  • Supports indexing, slicing, len(), in and looping — all read-only
  • Only two methods: .count(value) and .index(value)
  • tuple(items) converts a list; list(t) converts back
Notes
  • + and * work on tuples but always build a new tuple, exactly as they do on strings. t += (4,) therefore does not modify t — it rebinds the name to a new object.

Immutable, but Only One Level Deep

"Immutable" is a promise about the tuple, not about what is inside it. A tuple stores references to objects, and it guarantees that those references never change — it says nothing about whether the objects themselves can change. So a tuple containing a list is a fixed container holding a list that is still perfectly mutable.

That surprises people, and it matters practically because of hashability. Dictionary keys and set members must be hashable, which in effect means they must be immutable all the way down, so that their hash value never changes while they are stored. A tuple of numbers and strings is hashable and makes an excellent composite key — a grid coordinate, a (state, city) pair, a (year, month) bucket. A tuple containing a list is not hashable, and using one as a key raises TypeError: unhashable type: 'list'.

There is one famous oddity worth seeing once so it does not confuse you later. Writing t[0] += [4] on a tuple holding a list both raises a TypeError and extends the list. The += modifies the list in place first, then Python tries to store the result back into the tuple slot and refuses. Nothing is broken; it is the visible seam between "the tuple is fixed" and "the list inside is not". If you find yourself relying on this, use a list.

Example
t = ([1, 2], 3)

# The tuple is fixed; the list inside is not
t[0].append(99)
print(t)                # ([1, 2, 99], 3)

# t[0] = [5]            # TypeError — cannot replace the reference

# Hashable tuples make excellent composite keys
population = {
    ("Odisha", "Cuttack"): 650000,
    ("Kerala", "Kochi"): 670000,
}
print(population[("Kerala", "Kochi")])   # 670000

board = {}
board[(0, 0)] = "X"     # a grid coordinate as one key
board[(1, 2)] = "O"
print(board)            # {(0, 0): 'X', (1, 2): 'O'}

# A tuple containing a list is not hashable
print(hash((1, 2)))     # a number
# print(hash(([1], 2))) # TypeError: unhashable type: 'list'
# {[1, 2]: "x"}         # TypeError — a list can never be a key

# The oddity: this raises TypeError AND still extends the list
try:
    t[0] += [4]
except TypeError as e:
    print("error:", e)
print(t)                # ([1, 2, 99, 4], 3)  — the append happened anyway
Notes
  • The practical test for hashability is simply whether the value can change. Numbers, strings, booleans, None, frozensets and tuples of those are all fine as keys. Lists, dictionaries and sets are not.

Packing and Unpacking

Putting several values on the right of an assignment packs them into a tuple. Putting several names on the left unpacks it again, one value per name. This is the feature that makes tuples worth having, and it is used far more often than tuples are consciously created.

The count must match. Three values into two names raises ValueError: too many values to unpack; two values into three raises ValueError: not enough values to unpack. Those messages are precise and worth reading carefully — they tell you how many Python expected and how many it got, which usually identifies the offending line straight away.

The whole right-hand side is evaluated before any name is assigned, which is why a, b = b, a swaps two variables with no temporary. Python builds the tuple (b, a) from the old values first, then unpacks it. The same rule makes a, b = b, a + b — one step of a Fibonacci sequence — do the right thing rather than using the already-updated a.

A starred name absorbs whatever is left over, as a list, even when the source was a tuple. You may have at most one starred name, and it may sit at either end or in the middle. Where you do not need a value, the conventional throwaway name is a single underscore — it is an ordinary variable, just one that signals "deliberately ignored".

Example
person = "Asha", 30, "Engineer"     # packing
name, age, job = person             # unpacking
print(name, age, job)               # Asha 30 Engineer

# The counts must match
# a, b = 1, 2, 3     # ValueError: too many values to unpack (expected 2)
# a, b, c = 1, 2     # ValueError: not enough values to unpack (expected 3, got 2)

# Swap: the right side is built first, from the old values
a, b = 10, 20
a, b = b, a
print(a, b)                         # 20 10

# One Fibonacci step, for the same reason
x, y = 0, 1
for _ in range(5):
    x, y = y, x + y
print(x, y)                         # 5 8

# A starred name collects the rest — always as a list
first, *middle, last = (1, 2, 3, 4, 5)
print(first, middle, last)          # 1 [2, 3, 4] 5

# Ignore what you do not need
_, marks, _ = ("Asha", 87, "Engineer")
print(marks)                        # 87

# Unpacking inside a for loop is where you meet it most
for position, colour in enumerate(["red", "green"], start=1):
    print(position, colour)

scores = {"Asha": 87, "Ravi": 92}
for student, mark in scores.items():
    print(student, mark)
  • a, b = 1, 2 — pack on the right, unpack on the left
  • Counts must match, or you get a ValueError naming both numbers
  • a, b = b, a — swap, because the right side is evaluated first
  • first, *rest = items — the starred name collects the remainder as a list
  • _ — the conventional name for a value you are deliberately ignoring
Notes
  • Unpacking works on any iterable, not just tuples. a, b, c = "xyz" and a, b = [1, 2] are both fine, which is why the feature shows up in loops over lists and dictionaries as naturally as it does with tuples.

Returning Several Values

Python functions return one object, which sounds restrictive until you notice that the object can be a tuple. Writing return name, age packs the two values, and the caller unpacks them with name, age = get_user(). It reads as though the function returned two things, and it is the standard way to do so — there is no separate "out parameter" mechanism to learn.

You will meet built-ins that already work this way. divmod(17, 5) returns (3, 2), the quotient and remainder together. str.partition() returns three pieces. Each is a tuple you can unpack on the spot.

The weakness of returning a bare tuple is that positions carry the meaning. result[2] tells the reader nothing, and swapping two fields during a rewrite produces a bug that no error message will catch. Once a tuple has more than three fields, or once it travels far from where it was created, give the fields names with collections.namedtuple. It is still a tuple — immutable, unpackable, usable as a dictionary key — but you can also write point.x, and printing it shows the field names, which makes debugging noticeably easier.

Example
def get_user():
    return "Asha", 30          # packs into a tuple

name, age = get_user()         # unpacks at the call site
print(f"{name} is {age}")

result = get_user()            # or keep it as one value
print(result)                  # ('Asha', 30)
print(type(result))            # <class 'tuple'>

# Built-ins that return tuples
quotient, remainder = divmod(17, 5)
print(quotient, remainder)     # 3 2

before, sep, after = "roll=101".partition("=")
print(before, after)           # roll 101

# Positions are hard to read once there are several
def summarise(marks):
    return min(marks), max(marks), sum(marks) / len(marks)

low, high, avg = summarise([78, 84, 91])
print(low, high, round(avg, 2))    # 78 91 84.33

# Named fields, still a tuple
from collections import namedtuple

Student = namedtuple("Student", ["name", "marks", "branch"])
s = Student("Asha", 87, "CSE")

print(s.name, s.marks)         # Asha 87
print(s[0])                    # Asha — indexing still works
name, marks, branch = s        # unpacking still works
print(s)                       # Student(name='Asha', marks=87, branch='CSE')
Notes
  • A namedtuple is immutable like any tuple. To produce a changed version, use s._replace(marks=90), which returns a new one. If you need something you can edit freely, a small class or a dictionary is the better fit.

Tuple or List? Choosing on Meaning

The usual advice is "use a tuple when the data should not change", which is true and not very helpful, because you can always choose not to change a list. The more useful distinction is about what the collection is.

A list is a variable number of things of the same kind, where position is just order: a list of student names, a list of marks, a list of lines from a file. Adding one more makes sense, and every item is used the same way.

A tuple is a fixed number of things of different kinds, where each position means something specific: a coordinate (x, y), an RGB colour, a database row, a (name, marks) pair. Adding one more would not make sense, because there is no such thing as a third half of a coordinate. This is the reason a database row comes back as a tuple and a set of rows comes back as a list.

There are two hard technical reasons on top of that. Only a tuple can be a dictionary key or a set member. And a tuple is a defensive choice for anything you return from a function or store as a constant, because a caller cannot accidentally mutate what you handed them. The performance difference is real but small, and it is a poor reason on its own to choose one over the other.

Example
# List: many things of the same kind, may grow
students = ["Asha", "Ravi", "Meera"]
students.append("Karan")

# Tuple: a fixed record where each position has its own meaning
record = ("Asha", 87, "CSE")     # name, marks, branch

# A list of tuples is the common shape for tabular data
rows = [
    ("Asha", 87, "CSE"),
    ("Ravi", 92, "ECE"),
    ("Meera", 78, "CSE"),
]

for name, marks, branch in rows:
    print(f"{name:<8}{branch:<5}{marks:>4}")

# sorted() on tuples compares position by position, left to right
print(sorted(rows, key=lambda r: r[1], reverse=True)[0])   # ('Ravi', 92, 'ECE')

# Convert freely when you need the other behaviour
as_list = list(record)
as_list[1] = 90
record = tuple(as_list)
print(record)                    # ('Asha', 90, 'CSE')
  • Same kind of thing, variable count, order only — use a list
  • Different kinds of thing, fixed count, position carries meaning — use a tuple
  • Needs to be a dictionary key or set member — it must be a tuple
  • Returned from a function or used as a constant — a tuple is the safer default
  • list(t) and tuple(items) convert between them whenever you need to

Tuples You Are Already Using

Once you know the shape, you start seeing tuples everywhere in code you have already written. dict.items() yields a (key, value) tuple per entry. enumerate() yields (index, item). zip() yields one tuple per position across its inputs. In each case the for loop's two loop variables are unpacking a tuple, whether or not you thought of it that way.

Two more places make the point. When you write a function with *args — covered in Lesson 13 — Python collects the extra positional arguments into a tuple, not a list, precisely because that group is fixed once the call has been made. And catching several exception types at once requires a tuple: except (ValueError, TypeError):. The brackets there are not optional grouping; they are building a tuple of exception classes.

Comparison is the last piece. Tuples compare position by position, left to right, stopping at the first difference — exactly like alphabetical ordering on words. That is why sorting a list of (marks, name) tuples sorts by marks and then uses the name to break ties, with no extra code. It also means comparing tuples of different types can raise a TypeError partway through, once it reaches a pair it cannot order.

Example
scores = {"Asha": 87, "Ravi": 92}

print(list(scores.items()))        # [('Asha', 87), ('Ravi', 92)]
print(list(enumerate(["a", "b"]))) # [(0, 'a'), (1, 'b')]
print(list(zip([1, 2], "ab")))     # [(1, 'a'), (2, 'b')]

# Catching several exception types needs a tuple
try:
    value = int("abc")
except (ValueError, TypeError) as e:
    print("bad input:", e)

# Tuples compare left to right, first difference wins
print((1, 2) < (1, 3))             # True
print((2, 0) < (1, 99))            # False — the first item decides
print((1, 2) < (1, 2, 0))          # True  — a prefix sorts first

# So sorting by a tuple gives you a tie-breaker for free
results = [(87, "Meera"), (92, "Ravi"), (87, "Asha")]
print(sorted(results))
# [(87, 'Asha'), (87, 'Meera'), (92, 'Ravi')]  — marks, then name
Notes
  • sorted() always returns a list, even when you give it a tuple. If you need a sorted tuple back, wrap the result: tuple(sorted(t)).
Ask AI