Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 18 30 MIN

Data and control flow

Lists, Tuples and Dictionaries

Two names for one list, a dictionary key that is not there, and a set that reorders your data. The three containers and the traps inside them.

Data that arrives from anywhere real arrives in groups: request records, configuration entries, rows from a query, the words in a sentence. Python gives you three containers for those groups and they differ in ways that show up as bugs rather than as type errors, which is what makes them worth an hour of attention.

The first bug in this lesson is the one people hit within a week of learning the language. You assign a list to a second name, change it through the second name, and the first name changes too. Nothing is broken. The variable holds a reference to the list, not the list itself, and the assignment copied the reference. Once you see that clearly, an entire class of "how did that get modified" problems stops happening.

Assigning a list copies the reference, not the contents

Two names can point at one object, and mutation through either name is visible from both:

a = [1, 2, 3]
b = a
b.append(4)
print(a)
print(a is b)

c = a[:]
c.append(5)
print(a, c)
print(a is c)
[1, 2, 3, 4]
True
[1, 2, 3, 4] [1, 2, 3, 4, 5]
False

is asks whether two names point at the same object; == asks whether two objects hold equal values. For a copy you need one of three things: slicing with a[:], the list(a) constructor, or a.copy(). All three produce the same result for a flat list.

That copy is shallow. It duplicates the outer list and keeps references to whatever was inside it, so a list of lists still shares its inner lists:

teams = [["ana", "bo"], ["cy"]]
copy = teams[:]
copy.append(["di"])
copy[0].append("zed")
print(teams)
print(copy)
[['ana', 'bo', 'zed'], ['cy']]
[['ana', 'bo', 'zed'], ['cy'], ['di']]

The appended ["di"] only exists in the copy. The appended "zed" exists in both, because copy[0] and teams[0] are one list. When you genuinely need independent nested structures, copy.deepcopy from the standard library is the tool, and it is slow enough that reaching for it on a hot path is a decision, not a default.

List methods mutate, and the ones you want return None

list.sort() sorts in place and returns None. So does list.append. This trips people up in one specific way: they assign the result and then work with None.

a = [3, 1, 2]
print(a.sort())
print(a)
print(sorted(a, reverse=True))
print(a.append(9))
a.append(9)
print(a)
None
[1, 2, 3]
[3, 2, 1]
None
[1, 2, 3, 9, 9]

Three of those lines returned None from a method that changed the list anyway. sorted is the builtin that returns a new list and leaves the original alone, and the naming convention is worth trusting: the -ed form is the copy, the bare verb is the mutation. a.append(9) twice appended twice, which is why there are two nines. A list also allows duplicates and preserves the order you inserted in, which is the difference between it and a set.

Membership costs. x in list walks the list until it finds a match or runs out, so a membership check inside a loop over a large list is quadratic. If you need to ask "is this here" many times, build a set from the list once and ask that.

Slicing reads a sublist, and a negative step reverses it

A slice takes a start, a stop and a step, all optional, and it returns a new list. The stop index is excluded and out-of-range indexes are clamped rather than raising:

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[2:5])
print(nums[-3:])
print(nums[::2])
print(nums[::-1])
print(nums[5:2])
print(nums[2:100])
[2, 3, 4]
[7, 8, 9]
[0, 2, 4, 6, 8]
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
[]
[2, 3, 4, 5, 6, 7, 8, 9]

nums[5:2] is empty because the default step is 1 and there is no way to get from 5 to 2 by adding one. When the step is negative the two endpoints swap roles, so nums[::-1] starts at the end and walks backwards, and nums[2:5:-1] is the surprising one: it is empty as well, because the start is left of the stop while the step moves left. Get in the habit of reading a slice as "start here, move by this step, stop before there" rather than as two indexes.

Slicing never raises IndexError. Indexing does, and only when the index is out of range, so nums[10] fails while nums[10:] returns an empty list.

Tuples are immutable, and that is the point

A tuple is an ordered sequence like a list with no methods that mutate it. Attempting assignment raises rather than silently doing something else:

point = (3, 4)
x, y = point
print(x, y)

try:
    point[0] = 5
except TypeError as exc:
    print(type(exc).__name__, exc)

print(type(("ana")).__name__)
print(type(("ana",)).__name__, len(("ana",)))
3 4
TypeError 'tuple' object does not support item assignment
str
tuple 1

The parentheses do not make the tuple; the comma does. ("ana") is a string in parentheses, and forgetting the trailing comma in a one-element tuple is a mistake that survives review because the value prints without complaint.

Reach for a tuple when the position is part of the meaning and the set of values is fixed: a coordinate, a row from a database, a (key, value) pair from dict.items(). A tuple can be a dictionary key or a member of a set because it is hashable, which a list is not. A list is the right answer when the collection grows, shrinks or gets reordered, which covers most collections of records you will keep.

A dictionary lookup for a missing key raises KeyError

d[key] has one behavior: it returns the value or it raises. It does not return None, and it does not create the key.

config = {"host": "db.internal", "port": 5432}

try:
    print(config["user"])
except KeyError as exc:
    print(type(exc).__name__, exc)

print(config.get("user"))
print(config.get("user", "postgres"))
print("user" in config)
print(list(config))
KeyError 'user'
None
postgres
False
['host', 'port']

Which one you write is a statement about the data. config["user"] says the key must be there and its absence is a failure you want to hear about. config.get("user", "postgres") says a missing key is normal and there is a sensible default. The version that returns None with no default is the one to avoid in the middle of a calculation, because None travels: it becomes a TypeError three functions later, and the traceback points at the arithmetic rather than at the missing key.

For counters there is a shorter spelling. dict.get with a default works, and collections.defaultdict removes the branch entirely by creating the default on demand:

from collections import defaultdict

counts = {}
for word in "a b a".split():
    counts[word] = counts.get(word, 0) + 1
print(counts)

tally = defaultdict(int)
for word in "a b a".split():
    tally[word] += 1
print(dict(tally))
{'a': 2, 'b': 1}
{'a': 2, 'b': 1}

defaultdict has one sharp edge: reading a missing key inserts it. Ask a defaultdict(list) for a key that never existed and the dictionary now contains that key mapped to an empty list, which changes len() and changes what you get back when you iterate. That is convenient when counting and wrong when the question is "which keys are present".

Dictionary order is part of the language

Dictionaries iterate in insertion order. That is a guarantee of the language, not an implementation accident, and it holds in every Python 3 you will meet in production. Order is not sorted, and re-assigning an existing key does not move it:

config = {"host": "db.internal", "port": 5432}
config["user"] = "postgres"
config["host"] = "replica.internal"
print(list(config))
print(config)
print(sorted(config))
['host', 'port', 'user']
{'host': 'replica.internal', 'port': 5432, 'user': 'postgres'}
['host', 'port', 'user']

The practical consequence is that configuration dictionaries, JSON payloads and dict-based records keep the shape you wrote them in, and building an API response by inserting keys in the order you want is legitimate rather than fragile. It is not a sort: if you need alphabetical output, call sorted, and if you need a stable order for a report, sort by the value that defines the order.

Sets are the container for the opposite question. They drop duplicates and ignore order, membership tests are constant time, and the price is that you cannot index one:

methods = ["GET", "POST", "GET", "GET", "DELETE", "POST"]
print(sorted(set(methods)))
print(len(methods), len(set(methods)))
print(list(dict.fromkeys(methods)))
['DELETE', 'GET', 'POST']
6 3
['GET', 'POST', 'DELETE']

set answered "which distinct methods are here" and did not care about the order it found them in, so the result is sorted for printing. dict.fromkeys deduplicates while keeping first-seen order, which is the tool when the order carries meaning. Note that a set requires its members to be hashable, so a set of dictionaries or a set of lists is not available.

A dictionary becomes a record when the keys are fixed

Rows from a database and objects from JSON are dictionaries because the keys are data. The moment the same set of keys appears in code that does not come from the data, the dictionary is pretending to be a record, and mistakes get quieter:

route = {"method": "GET", "path": "/users", "status": 200}
print(route["path"])
print(route["headers"] if "headers" in route else "no headers key")
/users
no headers key

A misspelled route["methd"] raises KeyError: 'methd', which is at least loud. What is quiet is a field that is optional in one code path and required in another, with no single place that says which keys exist. That is the point where a class or a dataclass earns its place, because the field list becomes the shape of the thing and the checker can see it. A dictionary of unknown shape is the right tool for data you did not design; a dictionary of known shape is a record waiting to be written down.

My working rule: lists for things that grow, tuples for fixed groups, sets for membership and duplicate removal, dictionaries for lookup by key and for data whose shape arrives at runtime.

Lab 25 minutes

Write totals.py with a function that takes a list of transaction dictionaries and returns one dictionary mapping each day to the sum of its amounts.

transactions = [
    {"day": "mon", "amount": 4},
    {"day": "tue", "amount": 7},
    {"day": "mon", "amount": 2},
    {"day": "wed", "amount": 5},
]

Print the totals, then print the days sorted from the largest total down. Then answer three questions with evidence from a run of your own code: what happens when you ask the result for a day that had no transactions, what order the keys come back in, and what changes if you build the totals with defaultdict and read a day that was never inserted.

Show the solution and the reasoning

Both versions below are correct, and the difference between them is what happens to keys you only read.

from collections import defaultdict

transactions = [
    {"day": "mon", "amount": 4},
    {"day": "tue", "amount": 7},
    {"day": "mon", "amount": 2},
    {"day": "wed", "amount": 5},
]


def totals_manual(rows):
    out = {}
    for row in rows:
        out[row["day"]] = out.get(row["day"], 0) + row["amount"]
    return out


def totals_defaultdict(rows):
    out = defaultdict(int)
    for row in rows:
        out[row["day"]] += row["amount"]
    return dict(out)


manual = totals_manual(transactions)
print(manual)
print(list(manual))
print(sorted(manual, key=manual.get, reverse=True))

try:
    print(manual["thu"])
except KeyError as exc:
    print(type(exc).__name__, exc)

print(manual.get("thu", 0))

accumulated = defaultdict(int)
_ = accumulated["thu"]
print(dict(accumulated))
{'mon': 6, 'tue': 7, 'wed': 5}
['mon', 'tue', 'wed']
['tue', 'mon', 'wed']
KeyError 'thu'
0
{'thu': 0}

The answers follow from the container rules above. Asking for "thu" with square brackets raises KeyError: 'thu', because the key is not in the dictionary and indexing does not invent one. The keys come back in the order the loop inserted them, which is mon, tue, wed: not alphabetical and not sorted by total; it is first-seen order. The sorted print is a separate decision, and key=manual.get is what makes it sort by the values rather than by the day names.

The defaultdict answer is the interesting one. Reading accumulated["thu"] did not raise, and it also did not leave the dictionary alone: the key now exists with the value 0. In a counting loop that is exactly what you want. In a function that returns a defaultdict to its caller it is a trap, because len() and iteration change as a side effect of looking. I return dict(out) from totals_defaultdict for that reason: the caller gets a plain dictionary whose keys are only the days that had transactions. The common advice, "wrap it in dict() before returning", is not superstition.

The manual version with get has no such surprise and one extra branch. Pick based on where the value is read: inside the loop, use defaultdict or a Counter; at a boundary that leaves your function, use a plain dictionary.

Progress

Mark this lesson done and it stays marked in this browser.