Tuples
📦 Tuples
A tuple is an immutable, ordered sequence — like a list that can never be changed after creation. That constraint turns out to be surprisingly useful: it communicates intent, enables features lists can't have, and is the foundation of Python's elegant multiple-assignment syntax.
⚡ Coming from Java / JavaScript
Neither Java nor JavaScript has a built-in tuple type. In Java you'd reach for a small class, a
record (Java 16+), or an array. In JavaScript you'd use an array and just... try not to mutate it. Python's tuple is a first-class citizen — lighter than a class, safer than a list, and with unpacking syntax that makes it genuinely powerful.
🚀 FastAPI relevance — MEDIUM
Tuples appear frequently in Python web code as lightweight records. When FastAPI reads route parameters or query strings, it often works with named tuples internally.
NamedTuple is also a clean, lightweight alternative to full Pydantic models for simple structured data.
🏗️ Creating Tuples
Standard syntax
( item, item, ... )
Parentheses with comma-separated values. The parentheses are actually optional — it's the commas that make a tuple, not the brackets. But use parentheses for clarity.
# With parentheses (recommended)
expiry = (2025, 12, 31)
ingredient = ("milk", 2, "litres")
# Without parentheses — still a tuple!
expiry = 2025, 12, 31
print(type(expiry)) # <class 'tuple'>
Single-element tuple
( item, ) ← trailing comma!
This is the single biggest gotcha with tuples. A single value in parentheses is not a tuple — you must add a trailing comma. This catches everyone the first time.
# NOT a tuple — just parentheses
not_a_tuple = ("milk")
print(type(not_a_tuple)) # <class 'str'>
# IS a tuple — trailing comma
is_a_tuple = ("milk",)
print(type(is_a_tuple)) # <class 'tuple'>
Empty tuple & conversion
() and tuple()
tuple() converts any iterable to a tuple — lists, strings, ranges. The empty tuple is simply ().empty = ()
# Convert from list
ingredients_list = ["eggs", "milk"]
frozen = tuple(ingredients_list)
# Convert string to chars
chars = tuple("abc")
# → ('a', 'b', 'c')
Indexing & slicing
Same as lists
Tuples support all the same indexing and slicing as lists — positive, negative, and
start:stop:step. They just can't be modified through those operations.date = (2025, 8, 15)
date[0] # → 2025 (year)
date[-1] # → 15 (day)
date[1:] # → (8, 15)
# This raises TypeError:
# date[0] = 2026 ← immutable!
🔒 Immutability — What It Means in Practice
What You Can and Cannot Do
You cannot change, add, or remove items in a tuple. But if a tuple contains a mutable object (like a list), that inner object can still be changed — the tuple just can't point to a different object.
ingredient = ("pasta", 500, "grams")
# ✗ All of these raise TypeError
# ingredient[0] = "rice"
# ingredient.append("organic")
# del ingredient[0]
# ✓ Reading is fine
name = ingredient[0] # "pasta"
if "pasta" in ingredient: # membership test
print("Found it")
# ✓ Concatenation creates a NEW tuple
more = ingredient + ("organic",)
# → ("pasta", 500, "grams", "organic")
# ingredient itself is unchanged
# ⚠ The "mutable inside immutable" edge case
mixed = (["eggs", "milk"], 500)
mixed[0].append("butter") # ✓ modifying the list inside works
# mixed[0] is still the same list object — tuple didn't change
# mixed[0] = [] ← ✗ this still raises TypeError
Tuples as Dictionary Keys — a Superpower Lists Don't Have
Because tuples are immutable and hashable, they can be used as dictionary keys. Lists cannot. This makes tuples ideal for composite keys — like a (year, month, day) date.
# Use a (year, month, day) tuple as a dict key
meal_plan = {
(2025, 8, 11): "Spaghetti Bolognese",
(2025, 8, 12): "Chicken Tikka",
(2025, 8, 13): "Vegetable Curry",
}
# Look up by date tuple
todays_meal = meal_plan[(2025, 8, 12)]
print(todays_meal) # → "Chicken Tikka"
# This would raise TypeError — lists are not hashable:
# bad = {[2025, 8, 11]: "Pasta"} ← ✗
📬 Tuple Unpacking — Python's Party Trick
Unpacking is arguably the most useful thing about tuples. It lets you assign multiple variables in one clean line and is used constantly in real Python code.
Basic Unpacking
The number of variables on the left must match the number of values in the tuple (unless you use
*). The variable names can be anything — Python assigns by position.# Unpack into named variables
expiry = (2025, 12, 31)
year, month, day = expiry
print(f"Expires: {day}/{month}/{year}") # 31/12/2025
# Unpack an ingredient record
ingredient = ("milk", 2, "litres")
name, qty, unit = ingredient
print(f"{qty} {unit} of {name}") # 2 litres of milk
# Swap two variables — no temp variable (works because of tuple packing)
a, b = 10, 20
a, b = b, a
print(a, b) # 20 10
Starred Unpacking — Grab the Rest
Use
* to capture multiple remaining values into a list. The starred variable can be in any position.ingredients = ("flour", "eggs", "milk", "butter", "sugar")
# Grab first, last, and everything in between
first, *middle, last = ingredients
print(first) # "flour"
print(middle) # ["eggs", "milk", "butter"] ← a list, not a tuple
print(last) # "sugar"
# Grab just the first, ignore the rest
head, *_ = ingredients
print(head) # "flour"
# Grab just the last two
*_, penultimate, final = ingredients
print(penultimate, final) # "butter" "sugar"
Ignore Values with _ (Underscore)
By convention,
_ is used for values you don't need. It's a real variable, but it signals to other developers (and yourself) that you intentionally don't care about that value.date_record = (2025, 8, 15, "Thursday")
# Only want the month and day
_, month, day, _ = date_record
print(f"{day}/{month}") # 15/8
# Unpack inside a for loop — very common pattern
inventory = [
("milk", 2, 3), # (name, qty, days_left)
("eggs", 6, 10),
("yogurt", 1, 1),
]
for name, qty, days_left in inventory:
if days_left <= 3:
print(f"⚠ {name} expires in {days_left} day(s)!")
🏷️ Named Tuples — Tuples with Labels
A named tuple is a tuple subclass where each position has a name. It gives you the immutability and memory efficiency of a tuple with the readability of a class — without needing to write a full class definition.
typing.NamedTuple — The Modern Approach
There are two ways to create named tuples. The modern way using
typing.NamedTuple supports type hints and is more readable — it's what you'll see in FastAPI-adjacent code.from typing import NamedTuple
# Define a named tuple (looks like a class)
class Ingredient(NamedTuple):
name: str
quantity: int
unit: str
days_left: int = 0 # default value
# Create instances
milk = Ingredient("milk", 2, "litres", 3)
eggs = Ingredient("eggs", 6, "units", 10)
butter = Ingredient(name="butter", quantity=250, unit="grams", days_left=7)
# Access by NAME — much clearer than index
print(milk.name) # "milk"
print(milk.days_left) # 3
# Still works as a tuple too
print(milk[0]) # "milk"
name, qty, unit, days = milk # unpacking still works
# Expiry alert using named access
pantry = [milk, eggs, butter]
for item in pantry:
if item.days_left <= 3:
print(f"⚠ {item.name}: only {item.days_left} day(s) left!")
collections.namedtuple — The Classic Approach
The older
collections.namedtuple factory function does the same thing with a different syntax. You'll see this in older codebases.from collections import namedtuple
# First arg: the type name. Second: field names as a list (or space-separated string)
Recipe = namedtuple("Recipe", ["name", "servings", "prep_minutes"])
pasta = Recipe("Pasta", 4, 30)
print(pasta.name) # "Pasta"
print(pasta.servings) # 4
# Convert to dict
print(pasta._asdict())
# → {"name": "Pasta", "servings": 4, "prep_minutes": 30}
# "Update" a field — returns a NEW named tuple (immutable!)
bigger = pasta._replace(servings=8)
print(bigger.servings) # 8
print(pasta.servings) # 4 — original unchanged
⚖️ Tuple vs List — When to Use Which
📦 Use a Tuple when...
- The data is fixed and shouldn't change
- You're grouping related but different things (name, qty, unit)
- You need to use it as a dictionary key
- You want to signal to other developers: "don't modify this"
- You need slight memory and speed advantages over a list
- Returning multiple values (covered in the functions lesson)
📋 Use a List when...
- The data will grow or shrink over time
- You're collecting similar things (a list of ingredients)
- You need to sort, append, remove, or reorder
- The length isn't known in advance
- You're building up results in a loop
- You want list comprehensions (comprehensions return lists)
A Practical Rule of Thumb
Think of a list as a collection of the same kind of thing. Think of a tuple as a record with fixed fields.
# List — many things of the same kind
shopping = ["milk", "eggs", "bread"] # will be added to / removed from
# Tuple — one thing with multiple attributes
milk = ("milk", 2, "litres", 3) # fixed record: name, qty, unit, days
# List of tuples — very common pattern
inventory = [
("milk", 2, "litres", 3),
("eggs", 6, "units", 10),
("butter", 250, "grams", 7),
]
# Sort by days_left (index 3) — tuples sort by first element by default
inventory.sort(key=lambda item: item[3])
# → milk (3), butter (7), eggs (10)
📋 Quick Reference
| Operation | Syntax | Notes |
|---|---|---|
| Create | (1, 2, 3) | Parentheses optional — commas make the tuple |
| Single item | (1,) | Trailing comma is required — (1) is just 1 |
| Empty | () | Or tuple() |
| From iterable | tuple([1, 2, 3]) | Converts list, string, range, etc. |
| Index | t[0] / t[-1] | Same as lists — read only |
| Slice | t[1:3] | Returns a new tuple |
| Length | len(t) | |
| Membership | x in t | Returns True / False |
| Concatenate | t1 + t2 | Creates a new tuple |
| Repeat | t * 3 | e.g. (0,) * 7 → seven zeros |
| Count | t.count(x) | How many times x appears |
| Find | t.index(x) | Index of first x — raises ValueError if missing |
| Unpack | a, b, c = t | Must match length exactly |
| Starred unpack | first, *rest = t | rest becomes a list |
| Ignore value | _, b, _ = t | Convention for "don't care" |
| As dict key | {(1, 2): "val"} | Works because tuples are hashable |
| Named tuple | class T(NamedTuple): ... | Access by name: t.name |
| To list | list(t) | When you need to modify it |
⚠ Gotchas for Java / JavaScript Programmers:
The single-element trap —
Tuples are not "read-only lists" — they are a different type with a different purpose. A tuple says "this is a record with a fixed structure." A list says "this is a collection of similar things." Choosing the right one communicates intent to anyone reading your code.
Immutability is shallow — if a tuple contains a list, that list can still be mutated. The tuple's references are frozen, not the objects those references point to. This is the same as Java's
Starred unpacking gives a list, not a tuple —
The single-element trap —
("milk") is a string, not a tuple. ("milk",) is a tuple. The trailing comma is the only difference and it will bite you at least once.Tuples are not "read-only lists" — they are a different type with a different purpose. A tuple says "this is a record with a fixed structure." A list says "this is a collection of similar things." Choosing the right one communicates intent to anyone reading your code.
Immutability is shallow — if a tuple contains a list, that list can still be mutated. The tuple's references are frozen, not the objects those references point to. This is the same as Java's
final for object references — final List<String> list prevents reassignment but not list.add().Starred unpacking gives a list, not a tuple —
first, *rest = (1, 2, 3, 4) gives rest = [2, 3, 4] as a list, even though the source was a tuple. If you need a tuple, wrap it: rest = tuple(rest).