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] — unchanged

What 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

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.

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.

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.

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.

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

Back to Lists, tuples, dicts and sets: work through the checklist