2.2 Lists, tuples, dicts and sets
Checked against the Python 3 documentation, August 2026
What this is and why it exists
Lists, tuples, dicts and sets are the containers you will reach for hundreds of times a week, and each has a distinct purpose and a distinct cost. Choosing the right one is often the difference between a script that answers instantly and one you abandon — and the choice is not a matter of taste, because the costs are properties of how each is built.
The vocabulary
- list — an ordered, mutable sequence; the documentation describes lists as "mutable sequences, typically used to store collections of homogeneous items".
- tuple — an ordered, immutable sequence; usable as a dict key, unlike a list.
- dict — a mapping from keys to values, with fast lookup by key.
- set — an unordered collection of unique values, with fast membership testing.
- Mutable — can be changed in place after creation.
- Aliasing — two names referring to the same object, so a change through one is visible through the other.
- Shallow copy — a new container holding the same inner objects.
- Hashable — usable as a dict key or set member; roughly, immutable.
The mental model
Choose by the question you are asking. Order matters and things change: list. Order matters and nothing changes, or you need it as a key: tuple. Looking things up by a name: dict. Asking "have I seen this before": set.
That last one deserves emphasis because it is the most common cheap win in beginner code. Asking whether a value is in a list means walking the list, so checking a million values against a list of a million takes a very long time; a set answers by hashing, so the same work finishes while you are still watching. If you are about to write if x in big_list: inside a loop, make big_list a set first. Almost nothing else in this module makes such a difference for such a small edit.
Then aliasing, which is this topic's trap and produces bugs that feel supernatural. b = a does not copy anything. It gives the same list a second name, so appending through b changes what a sees, because there is only one list. This is not a quirk of lists — it is how all assignment works in Python — but it only becomes visible with mutable objects. When you want a copy, ask for one: b = a.copy() or b = list(a). And know that this is a shallow copy: the new list holds the same inner objects, so a list of lists copied this way still shares its inner lists.
Slicing is the other thing worth internalising, because it is everywhere. a[start:stop] includes the start and excludes the stop, which is why a[:3] and a[3:] together are the whole list with nothing repeated and nothing missed. a[::-1] reverses. And a slice of a list is a new list, which makes a[:] a quick shallow copy.
Sorting takes a key. sorted(items, key=len) sorts by length, key=lambda r: r["score"] by a field, and reverse=True inverts it. The key is called once per item and its result is what gets compared, which is both faster and more readable than writing a comparison.
Two dict helpers pay for themselves immediately. defaultdict takes a factory that the documentation says "is called without arguments to provide a default value for the given key, this value is inserted in the dictionary for the key, and returned" — so grouping needs no "if the key is missing, create an empty list" line. And Counter is "a dict subclass for counting hashable objects", which turns counting into one line and gives you most_common for free.
In code
Checked against the built-in types and collections references.
from collections import defaultdict, Counter
rows = [
{"name": "asha", "subject": "maths", "score": 88},
{"name": "ravi", "subject": "maths", "score": 71},
{"name": "asha", "subject": "physics", "score": 94},
]
seen = {r["name"] for r in rows}
if "asha" in seen:
print("asha appears")
by_subject = defaultdict(list)
for r in rows:
by_subject[r["subject"]].append(r["name"])
counts = Counter(r["name"] for r in rows)
print(counts.most_common(1))
top = sorted(rows, key=lambda r: r["score"], reverse=True)
print(top[0]["name"], top[:2])And the trap, shown deliberately:
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] — one list, two names
c = a.copy()
c.append(5)
print(a) # [1, 2, 3, 4] — unchangedWhat you should now be able to explain or do
Pick the right container for four described jobs and defend each. Say what happens to lookup cost when a list becomes a set, and spot the loop where that matters. Explain what b = a actually does, and produce a real copy. Read a[:3], a[3:] and a[::-1] aloud. Sort a list of dicts by a field. Group with defaultdict and count with Counter without writing a missing-key check.
Check yourself
You check membership against a list of a hundred thousand names, inside a loop. What is the one-line fix?
Build a set of the names first and check against that. A list is searched by walking it; a set answers by hashing, and the difference at that size is minutes against instants.
b = a then b.append(4). What is in a?
The four items. Assignment binds a second name to the same list — nothing was copied. Use a.copy() or list(a) when you want a separate one.
What does a shallow copy not protect you from?
Shared inner objects. Copying a list of lists gives you a new outer list holding the same inner lists, so mutating one of those is still visible through both.
Why does a[:3] plus a[3:] cover the whole list exactly once?
Because a slice includes the start and excludes the stop. The same index ends one slice and begins the next, with nothing repeated and nothing missed.
What does defaultdict(list) save you writing?
The missing-key check. On a key that is not there, the factory is called, the value is inserted and returned — so grouping is one append with no preamble.
Go deeper
We haven't checked most of these for screen reader use yet.
Back to Lists, tuples, dicts and sets: work through the checklist