BeginnerCollections and iteration~35 minPython

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

The four built-in collection types
TypeOrdered?Mutable?Duplicates allowed?Typical use
listYesYesYesA sequence you may need to change
tupleYesNoYesA fixed-size, fixed record — coordinates, a row
dictYes (insertion order)YesKeys: no. Values: yes.Looking values up by key
setNoYesNoFast 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

Pythoncollections_and_iteration.py
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()
Output
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 list for data that’s never supposed to change (coordinates, an RGB triple) instead of a tuple — a tuple documents that intent directly, and immutability rules out a whole class of accidental-mutation bugs.
  • Testing membership (if x in collection:) against a list in a hot loop instead of a set — 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

scores = {"ada": 91, "grace": 88} total = scores total["ada"] = 100 print(scores["ada"])
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.

names = ["a", "b", "c"] numbers = [1, 2] print(list(zip(names, numbers))) What prints?
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.

  1. Hint 1

    first[:] is a full slice — check what a slice returns: the same object, or a new one?

  2. Hint 2

    Slicing a list always builds a new list object, even when the slice covers the whole thing (start and stop both omitted).

  3. 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 alias

first[:] 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.

word_frequencies(["a", "b", "a", "c", "b", "a"]) — how many times does "a" map to?
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), and set (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 = original never 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.
  • enumerate pairs each item with its index; zip walks 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.

Related reference