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
- Creating tuples (including the one-element tuple)
- Immutability: what it means and what it is for
- Unpacking, including
*for "the rest" - Multiple return values from functions
- Tuples as lightweight records: the book as a tuple
- A taste of things to come:
namedtuple - 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 iterableThe 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) # TrueImmutability: 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 assignmentWhy 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 unpackedThe 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 alwaysNotice 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 1Tuples 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:
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 wayHold 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 astr. 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 aTypeError. 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)raisesValueError: too many values to unpack. Count the variables, or use*restif the length varies. - Believing that
*restyields 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
- Receipt with multiple return values. Write
receipt_line(title, base, member=False)that returns the tuple(title, total, savings)wheretotalis the final price (Papyrus convention:base * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT)for a member,base * (1 + BOOK_VAT)otherwise, rounded to 2 decimals) andsavingsis 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. - Migration to a list of tuples. Start from Papyrus's classic three parallel lists and build the list of tuples
catalog_recordswithzip(). Then add "Faust" (21.00, 0) with a singleappendand print the catalog with unpacking in thefor, flagging the sold-out ones. - The most and least expensive. On
catalog_recordsfrom exercise 2, usemax()andmin()withkey=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: HamletA common mistake here: writing key=book[1] without lambda — book 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
