Python Primer·Chapter 5

Collections: Lists, Dicts, Sets and Tuples

Four ways to hold several things at once. What each is good at, what each is terrible at, and how to pick between them by what the operation costs rather than by whichever one you learned first.

Python gives you four built-in ways to hold a group of values. A list will hold any of them, and for a few hundred items the choice makes no difference at all.

At a few hundred thousand it makes an enormous difference, because the four types answer different questions at different costs. The difference between them is not style. It is speed.

flowchart TB
    Q{"What do you need?"}
    Q -->|"an ordered sequence<br/>you will change"| L["<b>list</b><br/>[1, 2, 3]"]
    Q -->|"a fixed record<br/>that must not change"| T["<b>tuple</b><br/>(1, 2, 3)"]
    Q -->|"look things up<br/>by a key"| D["<b>dict</b><br/>{'a': 1}"]
    Q -->|"membership and<br/>uniqueness only"| S["<b>set</b><br/>{1, 2, 3}"]
    style L fill:#eef1fc,stroke:#2141c8
    style D fill:#eef1fc,stroke:#2141c8

Lists

An ordered, changeable sequence. Your default when order matters.

cities = ['Delhi', 'Tokyo', 'Sydney']

cities[0]          # 'Delhi'    — first
cities[-1]         # 'Sydney'     — last
cities[1:]         # ['Tokyo', 'Sydney']
len(cities)        # 3

cities.append('Paris')     # add one to the end
cities.extend(['Lagos'])     # add several
cities.insert(0, 'Toronto')   # insert at a position — slow, see below
cities.remove('Sydney')        # remove by value
last = cities.pop()          # remove and return the last
cities.sort()                # sort in place

Slicing

Slicing takes [start:stop:step] and the stop is always excluded.

nums = [0, 1, 2, 3, 4, 5]

nums[2:5]      # [2, 3, 4]    — stop is not included
nums[:3]       # [0, 1, 2]
nums[3:]       # [3, 4, 5]
nums[::2]      # [0, 2, 4]    — every second
nums[::-1]     # [5, 4, 3, 2, 1, 0]  — reversed
nums[:]        # a shallow copy of the whole list

The excluded stop feels arbitrary at first and then becomes convenient: nums[:3] and nums[3:] together give you the whole list with no overlap and no gap.

The cost of list operations

This is the part that matters for real data.

Operation Cost Why
lst[i] Instant Direct memory offset
lst.append(x) Instant Room is usually already reserved
lst.pop() Instant Removes from the end
x in lst Grows with length Checks every element until found
lst.insert(0, x) Grows with length Every later element shifts along
lst.pop(0) Grows with length Same shifting problem

The two you must watch are membership testing and inserting at the front. Both look innocent and both turn a fast program into a slow one as data grows:

# If ids has 100,000 entries, this checks up to 100,000 items EVERY time
for record in records:
    if record.id in ids:        # slow
        ...

# Convert once to a set. Now each check is instant.
id_set = set(ids)
for record in records:
    if record.id in id_set:     # fast
        ...

That change alone has rescued more slow scripts than any other single fix.

To picture the difference, think about what happens when Amazon checks whether you already own something before recommending it. Against a list of a few hundred million products, “is this in here” asked as a list scan would be hopeless. Asked of a set, it is instant, and it is instant whether the catalogue holds a hundred items or a hundred million. Same question, same one-line change, completely different business.

Dictionaries

Keys mapped to values. The most useful data structure in Python and the one you will reach for most.

population = {'Delhi': 33.8, 'Tokyo': 37.1, 'Sydney': 5.3}

population['Delhi']              # 33.8
population['Lagos']              # KeyError — the key does not exist
population.get('Lagos')          # None — safe
population.get('Lagos', 0)       # 0 — safe with a default

population['Lagos'] = 16.5        # add or overwrite
del population['Sydney']           # remove
'Delhi' in population            # True — checks KEYS, and is instant

Prefer .get() when a missing key is a normal possibility. Use [] when a missing key means something has gone wrong and you want the error.

Looping over a dictionary gives you the keys unless you ask otherwise:

for city in population:                    # keys
    ...
for city, pop in population.items():       # both — usually what you want
    print(f"{city}: {pop}m")
for pop in population.values():            # values only
    ...

Dictionaries keep insertion order, which has been guaranteed since Python 3.7. You can rely on it.

Two patterns worth memorising

Counting things:

from collections import Counter

cities = ['Delhi', 'Sydney', 'Delhi', 'Tokyo', 'Delhi']
counts = Counter(cities)
counts                    # Counter({'Delhi': 3, 'Sydney': 1, 'Tokyo': 1})
counts.most_common(2)     # [('Delhi', 3), ('Sydney', 1)]

Grouping things:

from collections import defaultdict

by_tier = defaultdict(list)         # missing keys create an empty list
for city, tier in [('Delhi', 1), ('Sydney', 2), ('Tokyo', 1)]:
    by_tier[tier].append(city)

dict(by_tier)     # {1: ['Delhi', 'Tokyo'], 2: ['Sydney']}

Without defaultdict that loop needs an if key not in d check every time. With it, the check disappears.

Sets

Unordered, no duplicates, and membership testing is instant regardless of size.

tiers = {'gold', 'silver', 'gold', 'bronze'}
tiers                        # {'gold', 'silver', 'bronze'} — dupes gone

'gold' in tiers              # instant, even with a million entries
tiers.add('platinum')
tiers.discard('bronze')      # remove if present, no error if absent

The set operations replace a great deal of loop-writing:

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

a | b        # {1,2,3,4,5,6}  union — in either
a & b        # {3, 4}         intersection — in both
a - b        # {1, 2}         difference — in a but not b
a ^ b        # {1,2,5,6}      symmetric difference — in exactly one

These come up constantly in data work:

expected = set(reference_ids)
actual = set(df.customer_id)

missing = expected - actual          # in reference, absent from data
unexpected = actual - expected       # in data, not in reference
print(f"{len(missing)} missing, {len(unexpected)} unexpected")

Two lines that would otherwise be a nested loop.

Note that {} creates an empty dictionary, not a set. For an empty set you need set().

Tuples

Like a list, but cannot be changed after creation.

point = (10, 20)
point[0]           # 10
point[0] = 5       # TypeError — tuples are immutable

Three reasons they exist and matter.

They signal intent. A tuple says “this is a fixed record, not a growing collection”. A coordinate, a date pair, a row of results.

They can be dictionary keys. Lists cannot, because a key must not change underneath the dictionary.

distances = {('Delhi', 'Tokyo'): 1400, ('Delhi', 'Sydney'): 1450}
distances[('Delhi', 'Sydney')]      # 1450

Functions return them. Every time a function appears to return several values, it is returning one tuple.

def min_max(values):
    return min(values), max(values)      # this is a tuple

lo, hi = min_max([3, 1, 4])              # unpacked into two names

Named tuples, when a tuple gets confusing

Once a tuple has more than about three fields, positional access becomes unreadable. record[4] tells the reader nothing.

from collections import namedtuple

City = namedtuple('City', ['name', 'population', 'tier'])
delhi = City('Delhi', 33.8, 1)

delhi.population       # 33.8 — readable
delhi[1]               # 33.8 — still works

Choosing, in Practice

You need to Use
Keep things in order and add to them list
Look something up by a name or ID dict
Check “have I seen this before” set
Remove duplicates set
Return several values from a function tuple
Use a compound value as a dictionary key tuple
Count occurrences Counter
Group items under keys defaultdict(list)

The single most valuable habit: if you find yourself writing if x in some_list inside a loop, make it a set first. That one change is the difference between a script that finishes and one you kill after twenty minutes.

Nesting

Real data is nested, and the combination you will meet most often is a list of dictionaries, which is exactly what an API returns.

records = [
    {'city': 'Delhi', 'pop': 33.8, 'tier': 1},
    {'city': 'Sydney',  'pop': 5.3,  'tier': 2},
]

for r in records:
    print(r['city'], r['pop'])

# And this is precisely what pandas expects
import pandas as pd
df = pd.DataFrame(records)

That last line is worth remembering. A list of dictionaries with consistent keys converts straight into a DataFrame, which makes it the natural shape to collect results in.

What Carries Forward

Membership in a list is slow; in a set or dict it is instant. This is the most common cause of unexpectedly slow beginner code.

Use .get() when a missing key is normal, and [] when it is a bug you want to hear about.

Counter and defaultdict remove a great deal of boilerplate. Learn those two and a lot of loops get shorter.

A list of dictionaries is the natural shape for collected results, and converts directly into a DataFrame.

Next: control flow, and the comprehensions that replace most of the loops you would otherwise write.