Tuples and Sets
Immutable sequences and unordered unique collections — tuple packing/unpacking, named tuples, every set operation, and when sets beat lists for membership testing.
Tuples — Ordered, Immutable Sequences
A tuple is a lot like a list — ordered, indexable, allows duplicates and mixed types — with exactly one fundamental difference: once created, a tuple can never be changed. No .append(), no item reassignment, no .sort() in place. This single restriction, which sounds like a limitation, turns out to be exactly what makes tuples useful for a specific, common category of data.
point = (3, 4)
colors = ("red", "green", "blue")
single = (42,) # a ONE-item tuple needs a trailing comma — (42) is just the int 42
empty = ()
print(point[0]) # 3 — indexing works exactly like lists
print(colors[-1]) # blue — negative indexing too
print(colors[0:2]) # ('red', 'green') — slicing returns a tuple, not a list(42) is just the integer 42 wrapped in ordinary parentheses — it is (42,), with the comma, that actually creates a tuple. This catches nearly every beginner at least once, and is worth testing with type() the first time you try it to see for yourself.point = (3, 4)
point[0] = 5
# TypeError: 'tuple' object does not support item assignment
# Every list method that would MODIFY the tuple simply doesn't exist on it —
# no .append(), no .remove(), no .sort(). Read-only operations like .index()
# and .count() DO exist, since they don't change anything.Technically, parentheses are not what makes a tuple — the comma is. 1, 2, 3 without any parentheses at all is already a tuple; the parentheses are just a readability convention, almost always used, and required in a few specific syntactic positions (like an empty tuple, or a tuple passed directly as one function argument among several).
Tuple Packing and Unpacking — Including the * Operator
You have already used tuple unpacking, without necessarily naming it, in the Functions module — lowest, highest = get_min_max(numbers) is tuple unpacking. Packing is the reverse direction: combining several values into a single tuple by separating them with commas.
# Packing — multiple values combined into one tuple
point = 3, 4, 5 # packed into (3, 4, 5) — parentheses are optional here
# Unpacking — one tuple split back into multiple names
x, y, z = point
print(x, y, z) # 3 4 5
# The classic use: swapping two variables without a temp variable
a, b = 1, 2
a, b = b, a # the right side is packed into a tuple, then unpacked
print(a, b) # 2 1The number of names on the left must match the number of values on the right — unpacking a, b = (1, 2, 3) raises ValueError: too many values to unpack. This is where extended unpacking with a single * becomes genuinely useful — it lets one name absorb "everything else" as a list, while the rest match exactly one value each.
first, *middle, last = [1, 2, 3, 4, 5]
print(first) # 1
print(middle) # [2, 3, 4] — note: a LIST, not a tuple, even though the source was one
print(last) # 5
first, *rest = (10, 20, 30, 40)
print(first) # 10
print(rest) # [20, 30, 40]
*rest, last = (10, 20, 30, 40)
print(rest) # [10, 20, 30]
print(last) # 40When to Choose a Tuple Over a List
Given that a tuple is essentially "a list that cannot change," the natural question is when that restriction is actually a benefit rather than an inconvenience. There are three genuinely distinct reasons, and a working engineer should be able to name all three, not just recite "tuples are immutable."
Reason 1 — semantic meaning: a fixed-shape record, not a growable collection
A tuple communicates, just by its type, that this value is a fixed-size, fixed-meaning record — not a collection you would ever loop over expecting a variable number of items. A GPS coordinate (latitude, longitude) is always exactly two values, in a fixed order, each with a specific meaning by position. Representing it as a list quietly implies "this could have any number of items" even though it never should.
Reason 2 — hashability: tuples can be dictionary keys and set members
This is the most practically important reason. A dict key or a set member must be hashable — a requirement you will meet formally in the Dictionaries module — and hashability requires immutability. Lists are unhashable and can never be used as dict keys; tuples (containing only hashable items themselves) can.
visited_coordinates = {
(40.7128, -74.0060): "New York",
(34.0522, -118.2437): "Los Angeles",
}
print(visited_coordinates[(40.7128, -74.0060)]) # New York
# The list equivalent fails immediately:
# bad_dict = { [40.7128, -74.0060]: "New York" }
# TypeError: unhashable type: 'list'Reason 3 — a small, genuine performance edge
Tuples are slightly faster to create and slightly more memory-efficient than lists holding the same values, because Python does not need to reserve extra room for future growth the way it does for a list, which is designed to be appended to. For most everyday code this difference is not something you will notice — but at genuinely large scale, or inside a tight loop creating many small fixed records, it is a real and measurable advantage of choosing the type that actually matches your intent.
.append(), .remove(), or reorder the collection, it should be a list. If the collection has a fixed number of items with fixed meaning by position — a coordinate, an RGB color, a database row — a tuple says that intent directly, and unlocks hashability as a side benefit.namedtuple and typing.NamedTuple — Tuples With Field Names
A plain tuple's biggest real weakness is readability — point[0] and point[1] tell you nothing about which value is which without checking how the tuple was built. A named tuple solves this directly: it behaves exactly like a regular tuple — immutable, indexable, unpackable — but its fields can also be accessed by name.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p[0]) # 3 — still works like a regular tuple by index
print(p.x) # 3 — but also readable by name
print(p.y) # 4
x, y = p # unpacking still works exactly like a regular tuple
print(f"({x}, {y})") # (3, 4)The more modern equivalent, typing.NamedTuple, does the same thing with a class-based syntax that also lets you attach type hints per field — you will use type hints properly in the dedicated Type Hints module later in this track, but the pattern is genuinely common enough in real code to introduce now.
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p) # Point(x=3, y=4) — a genuinely readable repr, for freeget_min_max() example from the Functions module, which could return a named tuple instead of a plain one for extra clarity), for rows read back from a database query, and for configuration values, wherever the full weight of defining a class would be overkill but a plain, unlabelled tuple would be too opaque to read comfortably.Sets — Unordered Collections of Unique Values
A set is an unordered collection with exactly one defining rule: every item in it is unique. Adding a value that already exists in the set has no effect at all — the set simply does not change. This makes sets the natural tool any time "does this contain duplicates" or "give me only the distinct values" is the actual problem you are solving.
unique_ids = {101, 102, 103, 101, 102}
print(unique_ids) # {101, 102, 103} — duplicates are simply gone
# set() can build a set from any iterable — a very common dedup trick
names = ["Maria", "Jordan", "Maria", "Priya", "Jordan"]
unique_names = set(names)
print(unique_names) # {'Maria', 'Jordan', 'Priya'} — order not guaranteed
empty_set = set() # NOTE: {} creates an empty DICT, not an empty set — use set(){1, 2, 3} with items inside really is a set. An empty set must be created with the explicit set() call — there is no literal syntax for it.fruits = {"apple", "banana"}
fruits.add("cherry") # add ONE item
fruits.update(["date", "fig"]) # add MULTIPLE items from another iterable — like extend() for lists
fruits.discard("banana") # removes if present, does nothing if not — never raises an error
fruits.remove("apple") # removes if present, raises KeyError if NOT present
print("cherry" in fruits) # True — membership check, covered in depth in Part 07Because a set is unordered, it has no indexing at all — my_set[0] raises a TypeError immediately. There is no concept of "the first item," since a set makes no promise about the order its items are stored or iterated in.
Union, Intersection, Difference, and Symmetric Difference
Sets support the core mathematical set operations directly, and they map exactly onto a Venn diagram — genuinely useful for real problems like "which customers exist in both systems" or "which permissions does this role have that the other one doesn't."
python_devs = {"Maria", "Jordan", "Priya", "Sam"}
js_devs = {"Priya", "Sam", "Alex", "Chen"}python_devs | js_devs
# or: python_devs.union(js_devs)
# {'Maria', 'Jordan', 'Priya', 'Sam', 'Alex', 'Chen'}
# Every name from both sets, each appearing only once, even Priya and Sam who are in both.python_devs & js_devs
# or: python_devs.intersection(js_devs)
# {'Priya', 'Sam'}
# Only the developers who know BOTH languages.python_devs - js_devs
# or: python_devs.difference(js_devs)
# {'Maria', 'Jordan'}
# Python developers who do NOT also know JavaScript.
# Note: this is NOT symmetric — js_devs - python_devs gives a different result: {'Alex', 'Chen'}python_devs ^ js_devs
# or: python_devs.symmetric_difference(js_devs)
# {'Maria', 'Jordan', 'Alex', 'Chen'}
# Everyone who knows exactly one of the two languages — Priya and Sam,
# who know both, are excluded entirely.set(system_a_ids) - set(system_b_ids) instantly answers "which records exist in A but are missing from B" — a question that would otherwise require a nested loop (exactly the O(n × m) problem from the Loops module) to answer manually.Why in Is Fast for a Set and Slow for a Large List
This ties directly back to the algorithmic-complexity idea first introduced in the Operators module, and it is one of the most consequential performance decisions you can make in everyday Python: checking whether a value exists in a list requires Python to scan items one by one, in the worst case checking every single item — this is O(n), meaning the time it takes grows in direct proportion to the list's size. Checking membership in a set uses hashing to jump almost directly to where the value would be, without scanning — this is O(1), meaning it takes roughly the same time regardless of how large the set is.
blocked_ids_list = [ ... 100_000 ids ... ]
blocked_ids_set = set(blocked_ids_list)
# Both give the same correct answer:
user_id in blocked_ids_list # O(n) — may check all 100,000 items, one by one
user_id in blocked_ids_set # O(1) — roughly constant time, regardless of sizeFor a small list, the difference is genuinely irrelevant — checking 10 items one by one takes effectively no time either way. It becomes a real, measurable problem exactly where the Loops module's Denver example landed: a lookup performed repeatedly, inside another loop, against a collection that has grown large. Converting the collection being checked against from a list into a set is one of the single highest-leverage, lowest-effort performance fixes in everyday Python.
frozenset — An Immutable Set
A frozenset is exactly what its name suggests: a set that cannot be modified after creation — no .add(), no .remove(), no .update(). It exists for precisely the same reason tuples exist alongside lists: immutability is what makes hashability possible, and a regular, mutable set cannot be used as a dict key or placed inside another set for the same reason a list cannot.
permissions_a = frozenset(["read", "write"])
permissions_b = frozenset(["read", "write", "delete"])
# A frozenset can be a dict key — a regular set cannot
access_levels = {
permissions_a: "editor",
permissions_b: "admin",
}
print(access_levels[frozenset(["read", "write"])]) # editor
permissions_a.add("delete")
# AttributeError: 'frozenset' object has no attribute 'add'Union, intersection, difference, and symmetric difference all work identically on frozensets — only the mutating operations are removed. In practice, you will reach for a regular set far more often; frozenset earns its place specifically when a set-like collection needs to be hashable — as a dict key, or as a member of another set.
An Atlanta HR Platform's Nightly Access Audit Times Out Every Night
An Atlanta HR platform runs a nightly compliance audit that flags any employee whose account has access permissions they should no longer have — comparing each employee's current granted permissions against the permissions their current role is actually allowed. When the company had a few hundred employees, the job finished in seconds. Past around fifteen thousand employees, the job started missing its overnight window entirely, sometimes still running when the next business day started.
What the investigation finds
The audit checks each employee's permissions against a master list of currently authorized permission codes — stored and searched as a plain list, checked with in inside a loop over every employee. Exactly the O(n) membership check from Part 07, run once per employee, against a list that itself has grown alongside the company.
authorized_codes = get_authorized_permission_codes() # a list, ~4,000 codes
flagged = []
for employee in get_all_employees(): # ~15,000 employees
for code in employee.granted_permissions:
if code not in authorized_codes: # O(n) scan, EVERY time
flagged.append((employee, code))With roughly 15,000 employees each holding a handful of permission codes, and each in check scanning up to 4,000 items in the authorized list, the job was performing tens of millions of individual comparisons — the same nested-loop-shaped cost problem from the Loops module's Denver example, just expressed through a slow membership check instead of a literal nested for.
The fix
authorized_codes = set(get_authorized_permission_codes()) # a set, not a list
flagged = []
for employee in get_all_employees():
for code in employee.granted_permissions:
if code not in authorized_codes: # O(1) — fast, regardless of size
flagged.append((employee, code))One line changed — list to set — and the nightly job drops from regularly missing its window to finishing in a few seconds. As with the Denver logistics example in the Loops module, the underlying lesson generalises well beyond this one job: any time code repeatedly asks "does this value exist in that collection," and the collection is not trivially small, a set is very often the correct default, not a list.
Four Misconceptions About Tuples and Sets
5 Interview Questions — With Complete Answers
Tuple and Set Mistakes Beginners Make Constantly
Errors You Will Hit With Tuples and Sets — And Exactly Why
🎯 Key Takeaways
- ✓Tuples are ordered and indexable like lists, but immutable — no append, remove, or item reassignment. A one-item tuple requires a trailing comma: (42,).
- ✓Unpacking splits a tuple into named variables; extended unpacking with * lets one name absorb "everything else" as a list, e.g. first, *rest = my_tuple.
- ✓Choose a tuple over a list for fixed-shape records, when hashability (dict keys, set members) is needed, or for a small performance edge — choose a list when the collection needs to grow or be reordered.
- ✓Named tuples (collections.namedtuple, typing.NamedTuple) add field-name access on top of regular tuple behaviour — genuinely common for lightweight records.
- ✓Sets are unordered, unique-valued collections. {} creates an empty dict, not an empty set — use set() explicitly.
- ✓Union (|), intersection (&), difference (-), and symmetric difference (^) map directly onto Venn-diagram set operations.
- ✓Membership testing (in) is O(1) for a set versus O(n) for a list — converting a large, frequently-checked list into a set is one of the highest-leverage performance fixes in everyday Python.
- ✓frozenset is an immutable set, needed specifically when a set-like collection must itself be hashable — as a dict key or a member of another set.
What comes next
Module 10 wraps up the fundamentals of input and output — input() mechanics, print()'s lesser-known keyword arguments, and print-based debugging — before Phase 2 begins with dictionaries.
Module 10 → Input/Output & f-string FormattingDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.