In the previous lesson you saw that list mutability is a double-edged sword: powerful for managing Papyrus's ever-changing stock, dangerous when an alias modifies data you thought was safe. Python offers the exact counterpart: the tuple, an ordered, immutable sequence. Besides protecting data that must not change, the tuple is the mechanism through which Python returns several values from a function — the promise we noted down in 03-02 and settle today — and the first serious step towards making each Papyrus book a single piece of information instead of a row scattered across three lists.

Contents

  1. Creating tuples (including the one-element tuple)
  2. Immutability: what it means and what it is for
  3. Unpacking, including * for "the rest"
  4. Multiple return values from functions
  5. Tuples as lightweight records: the book as a tuple
  6. A taste of things to come: namedtuple
  7. Tuples vs lists: how to choose

Creating tuples

A tuple is written with parentheses... although what actually creates it is the comma:

book = ("The Odyssey", 12.50, 4)     # 3-element tuple: a book's record
point = 3, 5                          # also a tuple! the parentheses are optional
empty = ()                            # empty tuple (here you do need the parentheses)
also_empty = tuple()
from_list = tuple([4, 6, 8])          # tuple() converts any iterable

The classic trap is the single-element tuple: without a trailing comma there is no tuple.

not_a_tuple = ("Faust")     # this is just a str in parentheses
is_a_tuple = ("Faust",)     # the trailing comma turns it into a tuple
also = "Faust",             # even without parentheses

print(type(not_a_tuple))    # <class 'str'>
print(type(is_a_tuple))     # <class 'tuple'>

Being sequences, tuples share almost all the mechanics from the previous lesson: positive and negative indexing, slicing, len(), in/not in, count() and index():

book = ("The Odyssey", 12.50, 4)
print(book[0])       # The Odyssey
print(book[-1])      # 4
print(book[:2])      # ('The Odyssey', 12.5)  → the slice of a tuple is another tuple
print(12.50 in book) # True

Immutability: what it means and what it is for

Once created, a tuple accepts no changes: no assigning by index, no append, no remove, no sort — those methods simply do not exist.

book = ("The Odyssey", 12.50, 4)
book[2] = 3          # TypeError: 'tuple' object does not support item assignment

Why would Ana want something that "can't be touched"? For exactly that reason:

  • Protection against mistakes: the shop's fixed data (name, coordinates, the discount/VAT pair) cannot be corrupted by accident. If any code tries to modify them, Python flags it instantly.
  • Communicating intent: whoever reads ("The Odyssey", 12.50, 4) understands it is a closed record, not a collection that grows.
  • Dictionary keys and set elements: being immutable (and therefore hashable), tuples can be used where lists cannot — you will take advantage of this in 04-03 and 04-04.
  • Lightness: Python can optimize them better than lists.

An honest nuance: immutability is shallow. If a tuple contains a list, the tuple cannot change which objects it contains, but the inner list is still mutable:

config = ("Papyrus", [4, 6, 8])   # tuple with a list inside
config[1].append(0)               # legal: we mutate the list, not the tuple
print(config)                     # ('Papyrus', [4, 6, 8, 0])

Unpacking, including *

Unpacking assigns the elements of a tuple to several variables in one go. You already used it without naming it with enumerate() and zip(); now we treat it as a first-class citizen:

book = ("The Odyssey", 12.50, 4)
title, price, stock = book             # three variables in one line
print(f"{title}: {price:.2f} EUR ({stock} units)")

# Swapping variables without a helper: Python's most famous idiom
a, b = 10, 20
a, b = b, a                            # (b, a) is a tuple that gets unpacked

The number of variables must match the number of elements... unless you use * to capture "the rest" in a list:

weekly_sales = (3, 1, 0, 2, 5, 7, 4)   # units sold from Monday to Sunday
monday, *midweek, sunday = weekly_sales
print(monday)         # 3
print(midweek)        # [1, 0, 2, 5, 7]  ← always a LIST, even if it comes from a tuple
print(sunday)         # 4

first, *rest = ("The Odyssey", "Hamlet", "Don Quixote", "Faust")
print(first)          # The Odyssey
print(rest)           # ['Hamlet', 'Don Quixote', 'Faust']

There can only be one * per unpacking. You will recognize the idea from *args (03-02): it is the same mechanism, now on the assignment side.

Multiple return values from functions

Here we settle the debt from 03-02. When a function "returns several values", it actually returns one tuple that gets unpacked afterwards. Let's add a receipt function to our Papyrus toolkit:

BOOK_VAT = 0.04          # the course's fictional VAT
MEMBER_DISCOUNT = 0.05

def sale_breakdown(base, member=False):
    """Returns (base, discount, vat, total) for a sale, in euros."""
    discount = round(base * MEMBER_DISCOUNT, 2) if member else 0.0
    subtotal = base - discount
    vat = round(subtotal * BOOK_VAT, 2)
    total = round(base * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT), 2) if member \
            else round(base * (1 + BOOK_VAT), 2)
    return base, discount, vat, total      # no parentheses: the comma builds the tuple

# Luis (a member) buys "Don Quixote" (15.90 EUR base price)
result = sale_breakdown(15.90, member=True)
print(result)                 # (15.9, 0.8, 0.63, 15.71)  → one single tuple
print(type(result))           # <class 'tuple'>

base, disc, vat, total = sale_breakdown(15.90, member=True)   # direct unpacking
print(f"Total with a member card: {total:.2f} EUR")           # 15.71 EUR, as always

Notice the two sides: the caller can keep the whole tuple (result) or unpack it on the fly. If you only care about part of it, the underscore is the convention for "I'm ignoring this":

*_, total = sale_breakdown(12.50, member=True)
print(total)    # 12.35 → the member price of "The Odyssey", verified in module 1

Tuples as lightweight records: the book as a tuple

And here comes the module's strategic move. The three parallel lists can become a single list of tuples, where each tuple is a book's complete record:

# Before: three lists to keep in sync by hand
# catalog = ["The Odyssey", "Hamlet", "Don Quixote"]; prices = [...]; stocks = [...]

# Now: one list of (title, price, stock) records
catalog = [
    ("The Odyssey", 12.50, 4),
    ("Hamlet", 9.95, 6),
    ("Don Quixote", 15.90, 8),
    ("Faust", 21.00, 0),
]

for title, price, stock in catalog:             # unpacking in the for: goodbye zip()
    status = "SOLD OUT" if stock == 0 else f"{stock} units"
    print(f"{title:<12} {price:>6.2f} EUR  {status}")

Compare with module 3's zip(catalog, prices, stocks): the result is the same, but there are no longer three structures that can fall out of sync — adding "Faust" is a single catalog.append(("Faust", 21.00, 0)). The outer list is mutable (the catalog grows and shrinks); each record is immutable (a book's data travels together and protected). And if the stock goes down? Being immutable, the tuple is not modified: it is replaced by another one:

catalog[2] = ("Don Quixote", 15.90, 7)   # Luis buys a copy

That wholesale replacement is awkward, and book[1] is still a mute index: nothing screams "I am the price". Two improvements remain ahead of us.

A taste of things to come: namedtuple

The first improvement already exists in the standard library (the collections module, which we mentioned in 03-05): tuples whose fields have names.

from collections import namedtuple

Book = namedtuple("Book", ["title", "price", "stock"])
odyssey = Book("The Odyssey", 12.50, 4)

print(odyssey.price)    # 12.5  → by name, not by mute index
print(odyssey[1])       # 12.5  → still a regular tuple
title, price, stock = odyssey   # and it unpacks the same way

Hold on to the idea; we will develop it fully in 04-06. The second improvement — finding a book by title without scanning anything — is lesson 04-03.

Tuples vs lists: how to choose

Criterion List Tuple
Mutability Mutable: grows, shrinks, reorders Immutable: created in one piece
Typical use Homogeneous collection of variable size (titles, order queue) Heterogeneous record with a fixed structure (title + price + stock)
Methods Many (append, sort, remove...) Only count() and index()
Dict key / set element? No (not hashable) Yes
Multiple return values Possible but unusual The standard idiom
Syntax [1, 2, 3] (1, 2, 3) or 1, 2, 3

Mnemonic rule: list = "many things of the same kind"; tuple = "one thing with several parts". The catalog (many books) is a list; each book (title, price, stock) is a tuple.

Common Mistakes and Tips

  • Forgetting the comma in the single-element tuple: ("Faust") is a str. If a function expects a tuple and you hand it that, the errors show up far from the source. Write ("Faust",).
  • Trying to mutate a tuple (book[2] -= 1) raises a TypeError. That is not a flaw: it is the tuple doing its job. If you need to change values often, that piece was asking to be a different structure (spoiler: a dictionary).
  • Mismatched unpacking: a, b = (1, 2, 3) raises ValueError: too many values to unpack. Count the variables, or use *rest if the length varies.
  • Believing that *rest yields a tuple: it always produces a list, wherever it comes from.
  • An "immutable" tuple with lists inside: immutability does not propagate inward. If you need a truly frozen record, avoid putting lists in it.
  • Tip: when a function of yours starts returning 4-5 values, consider whether it is asking for a namedtuple (04-06) or a dictionary (04-03) — unpacking 5 positions from memory is fragile.

Exercises

  1. Receipt with multiple return values. Write receipt_line(title, base, member=False) that returns the tuple (title, total, savings) where total is the final price (Papyrus convention: base * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT) for a member, base * (1 + BOOK_VAT) otherwise, rounded to 2 decimals) and savings is the difference between not being a member and being one. Unpack the result for "Hamlet" (9.95) as a member and print a receipt line with an f-string.
  2. Migration to a list of tuples. Start from Papyrus's classic three parallel lists and build the list of tuples catalog_records with zip(). Then add "Faust" (21.00, 0) with a single append and print the catalog with unpacking in the for, flagging the sold-out ones.
  3. The most and least expensive. On catalog_records from exercise 2, use max() and min() with key=lambda book: book[1] (a 03-03 refresher) to obtain the record of the most expensive book and the cheapest one, and unpack them to print only their titles.

Solutions

# Exercise 1
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05

def receipt_line(title, base, member=False):
    """Returns (title, total, savings) for a sale line."""
    regular_total = round(base * (1 + BOOK_VAT), 2)
    member_total = round(base * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT), 2)
    total = member_total if member else regular_total
    savings = round(regular_total - member_total, 2)
    return title, total, savings

title, total, savings = receipt_line("Hamlet", 9.95, member=True)
print(f"{title}: {total:.2f} EUR (you save {savings:.2f} EUR as a member)")
# Hamlet: 9.83 EUR (you save 0.52 EUR as a member)
# Exercise 2
catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]

catalog_records = list(zip(catalog, prices, stocks))   # zip joins; list materializes
catalog_records.append(("Faust", 21.00, 0))            # a single add, impossible to desync

for title, price, stock in catalog_records:
    status = "SOLD OUT" if stock == 0 else f"{stock} units"
    print(f"{title:<12} {price:>6.2f} EUR  {status}")

Tip: zip() returns tuples all by itself — migrating from parallel lists to a list of tuples is literally a list(zip(...)). It is the last time we will need zip() for the catalog.

# Exercise 3
most_expensive = max(catalog_records, key=lambda book: book[1])
cheapest = min(catalog_records, key=lambda book: book[1])

expensive_title, *_ = most_expensive
cheap_title, *_ = cheapest
print(f"Most expensive: {expensive_title} | Cheapest: {cheap_title}")
# Most expensive: Faust | Cheapest: Hamlet

A common mistake here: writing key=book[1] without lambdabook does not exist outside the lambda; key needs a function, not a value.

Conclusion

Tuples complete lists with the piece they were missing: immutability. You know how to create them (remembering the single-element comma), unpack them — *rest included — and you have seen that Python's "multiple return values" were always a tuple in disguise, settling the promise from 03-02. Above all, the Papyrus catalog has taken its first big step: from three parallel lists to one list of tuples where each book travels as a unit, with namedtuple noted down as an improvement for 04-06. But two annoyances remain: fields are identified by mute indices, and finding a book means scanning the whole list. The next lesson introduces Python's crown-jewel structure, the dictionary, and with it we will carry out the great refactor that module 3 announced: every book directly reachable by its title.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved