Collections and Iteration
Choose between list, tuple, dict, and set; iterate in parallel and with an index; tell aliasing apart from copying; and write your first comprehension.
By the end of this lesson, you can
- Choose the right collection type for a task based on ordering, mutability, and membership-testing needs
- Predict aliasing versus copying behavior for a mutable collection
- Iterate with an index (enumerate) and in parallel over several sequences (zip)
- Rewrite an explicit accumulation loop as an equivalent comprehension
Why it matters
Almost every real program holds more than one value at a time and needs to do something with all
of them — report on them, filter them, transform them. Python gives you four built-in collection
types with genuinely different trade-offs, plus two loop helpers (enumerate, zip) that replace
error-prone manual indexing, plus a compact way to write the single most common loop shape
(comprehensions). Knowing which tool fits which job is most of what “writing idiomatic Python”
means at this stage.
Mental model
| Type | Ordered? | Mutable? | Duplicates allowed? | Typical use |
|---|---|---|---|---|
| list | Yes | Yes | Yes | A sequence you may need to change |
| tuple | Yes | No | Yes | A fixed-size, fixed record — coordinates, a row |
| dict | Yes (insertion order) | Yes | Keys: no. Values: yes. | Looking values up by key |
| set | No | Yes | No | Fast membership testing; deduplicating |
Choosing between them is mostly about two questions: does this need to change after creation (mutable), and do I look things up by position, by key, or just by “is this in here at all”?
Worked example: a small inventory report
def inventory_report(names: list[str], quantities: list[int]) -> list[str]:
lines = []
for index, (name, qty) in enumerate(zip(names, quantities, strict=True), start=1):
lines.append(f"{index}. {name}: {qty}")
return lines
def low_stock(names: list[str], quantities: list[int], threshold: int) -> list[str]:
return [name for name, qty in zip(names, quantities, strict=True) if qty < threshold]
def main() -> None:
names = ["apples", "bananas", "cherries"]
quantities = [12, 3, 40]
for line in inventory_report(names, quantities):
print(line)
print("Low stock:", low_stock(names, quantities, threshold=10))
original = [3, 1, 2]
alias = original
copy = original.copy()
alias.append(4)
print("original:", original)
print("copy:", copy)
print(
"sorted by quantity:", sorted(zip(names, quantities, strict=True), key=lambda pair: pair[1])
)
if __name__ == "__main__":
main()1. apples: 12
2. bananas: 3
3. cherries: 40
Low stock: ['bananas']
original: [3, 1, 2, 4]
copy: [3, 1, 2]
sorted by quantity: [('bananas', 3), ('apples', 12), ('cherries', 40)]inventory_report combines enumerate (for the 1., 2.,
3. numbering) with zip (to walk names and quantities
together) — and unpacks each zip-produced pair directly in the for target,
(name, qty), rather than indexing into it afterward.
low_stock is a comprehension: [name for name, qty in zip(names, quantities) if qty < threshold] builds the same list an explicit loop with .append() would, as one expression. It’s
worth writing the explicit-loop version first when a comprehension isn’t obviously clearer — a
comprehension is a rewrite of a loop you already understand, not a separate concept.
Common mistakes
- Using a
listfor data that’s never supposed to change (coordinates, an RGB triple) instead of atuple— atupledocuments that intent directly, and immutability rules out a whole class of accidental-mutation bugs. - Testing membership (
if x in collection:) against alistin a hot loop instead of aset— both give the same answer, but list membership checks scan every item; set membership is a direct lookup. - Assuming
.copy()is needed defensively “just in case” even for values that are never mutated — it costs memory and time for no benefit when nothing ever changes the original anyway.
Check your understanding
Or reveal the answer without checking
Answer:100
total = scores does not copy the dict — total and scores are two names for the same object, so modifying it through total is visible through scores too.
Or reveal the answer without checking
Answer:[('a', 1), ('b', 2)]
zip stops at the shortest input by default — numbers has only 2 items, so the pairing stops there and 'c' is silently dropped. Passing strict=True (Python 3.10+) would turn the length mismatch into a ValueError instead.
Practice: warm-up
Predict, then check: first = [1, 2, 3], second = first[:], first.append(4) — does second
now contain 4?
Stuck? Reveal one hint at a time.
Hint 1
first[:] is a full slice — check what a slice returns: the same object, or a new one?
Hint 2
Slicing a list always builds a new list object, even when the slice covers the whole thing (start and stop both omitted).
Hint 3
second is a genuine copy, made via slicing rather than .copy() — either one breaks the alias the same way. second stays [1, 2, 3]; only first becomes [1, 2, 3, 4].
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
first = [1, 2, 3]
second = first[:] # a new list — slicing always copies
first.append(4)
first -> [1, 2, 3, 4]
second -> [1, 2, 3] # unaffected — it was never an aliasfirst[:] and first.copy() do the same thing here — both produce an independent list. Only
plain assignment (second = first, no slice, no .copy()) creates an alias.
Practice: apply it
Write a frequency-table function, word_frequencies(words: list[str]) -> dict[str, int], that
counts how many times each word appears, using a plain dict (no imports). Predict its result for
["a", "b", "a", "c", "b", "a"] before running it.
Or reveal the answer without checking
Answer:3
"a" appears three times in the input list, so a correct frequency table maps "a" to 3.
Modification challenge: rewrite word_frequencies to return only words that appear more than
once, using a dict comprehension ({k: v for k, v in ... if ...}) over your first version’s
result.
Summary
list(ordered, mutable),tuple(ordered, immutable),dict(key lookup), andset(fast membership, no order) each fit a different job — pick based on whether the data changes and how you’ll look items up, not by habit.alias = originalnever copies a mutable collection;.copy()or a full slice (original[:]) does. This is the same label-not-a-box model from Names, Values, and Assignment, applied to collections.enumeratepairs each item with its index;zipwalks several iterables together and stops at the shortest one by default.- A comprehension is a compact rewrite of an explicit accumulation loop, once that loop is already understood — not a shortcut to reach for before it is.