The core data types all 6 written
Numbers, text, and the four containers almost every Python program is built out of.
4 Numbers, and the one thing floats cannot do
Python has two number types you will use constantly and two more
worth knowing about for the day the first two let you down. The important content of this
lesson is not the arithmetic; it is that one of those types is an approximation, and that
comparing approximations with == is the most common way correct code appears
broken.
Integers do not overflow
An int in Python has no maximum. If a calculation needs three hundred digits
it gets three hundred digits, and the only limit is memory. If you have come from C, Java or
JavaScript this is a genuine difference rather than a detail — there is no long,
no wraparound, and no need to think about 64-bit boundaries.
big = 2 ** 1000
print("2 ** 1000 has", len(str(big)), "digits")
print("and the last ten of them are", str(big)[-10:])
# Integer division, and the remainder. Both are exact.
print()
print("17 // 5 =", 17 // 5, " (floor division: whole part only)")
print("17 % 5 =", 17 % 5, " (remainder)")
print("divmod(17, 5) =", divmod(17, 5), " (both at once)")
test("integers are unbounded", 2 ** 1000 > 10 ** 300, True)
test("floor division discards the remainder", 17 // 5, 3)
test("divmod returns quotient and remainder together", divmod(17, 5), (3, 2))
What it printed, when the build ran it
2 ** 1000 has 302 digits and the last ten of them are 5668069376 17 // 5 = 3 (floor division: whole part only) 17 % 5 = 2 (remainder) divmod(17, 5) = (3, 2) (both at once) ok integers are unbounded ok floor division discards the remainder ok divmod returns quotient and remainder together
Floats are approximations, and this is not a Python problem
A float is a binary fraction with fixed precision — the IEEE-754 double your
CPU implements. Most decimal fractions, including 0.1, have no exact binary form,
exactly as one third has no exact decimal form. So 0.1 is stored as the closest
representable value, and the small error is real and compounds.
print("0.1 + 0.2 =", 0.1 + 0.2)
print("is that == 0.3? =", 0.1 + 0.2 == 0.3)
# What 0.1 actually is, printed to twenty decimal places.
print()
print("0.1 to 20 places:", f"{0.1:.20f}")
# The error compounds: add a tenth ten times and you do not land on one.
running = 0.0
for _ in range(10):
running += 0.1
print()
print("a tenth, ten times =", repr(running))
print("short of 1.0 by =", abs(running - 1.0))
test("a tenth plus two tenths is not three tenths", 0.1 + 0.2 == 0.3, False)
test("but it is close enough for any real purpose", close(0.1 + 0.2, 0.3), True)
test("ten tenths does not reach one either", running == 1.0, False)
test("close() forgives that too", close(running, 1.0), True)
What it printed, when the build ran it
0.1 + 0.2 = 0.30000000000000004 is that == 0.3? = False 0.1 to 20 places: 0.10000000000000000555 a tenth, ten times = 0.9999999999999999 short of 1.0 by = 1.1102230246251565e-16 ok a tenth plus two tenths is not three tenths ok but it is close enough for any real purpose ok ten tenths does not reach one either ok close() forgives that too
The rule for everything that follows. Never compare computed floats with
==. Compare them with a tolerance — this course uses a helper called
close(a, b), and the standard library ships the same idea as
math.isclose. And never test a computed float for being exactly zero; test that
its absolute value is below a threshold you chose deliberately.
round() does not do what you were taught at school
Two separate surprises live in one function, and they have different causes. The first is deliberate: Python rounds a value sitting exactly halfway to the nearest even number rather than always upwards. Over many values that keeps the average unbiased, which is why accountants and statisticians want it.
print("round(0.5) =", round(0.5), " <- not 1")
print("round(1.5) =", round(1.5), " <- 2")
print("round(2.5) =", round(2.5), " <- not 3")
print("round(3.5) =", round(3.5))
print()
print("Each ties to the nearest EVEN number, so the bias cancels out over many values.")
test("halves round to even, not always up", (round(0.5), round(1.5), round(2.5), round(3.5)), (0, 2, 2, 4))
What it printed, when the build ran it
round(0.5) = 0 <- not 1 round(1.5) = 2 <- 2 round(2.5) = 2 <- not 3 round(3.5) = 4 Each ties to the nearest EVEN number, so the bias cancels out over many values. ok halves round to even, not always up
The second surprise has nothing to do with that rule. round(2.675, 2) gives
2.67, and people reach for banker's rounding to explain it. That is the wrong
explanation: the real reason is that 2.675 was never stored as 2.675. It is
stored as a hair less, so the nearest two-decimal value genuinely is 2.67.
print("round(2.675, 2) =", round(2.675, 2), " <- looks like a bug")
# It is not a rounding rule. It is what 2.675 actually is.
print("2.675 to 20 places:", f"{2.675:.20f}")
print()
print("Stored slightly BELOW 2.675, so 2.67 really is the nearer value.")
# Decimal, in contrast, stores what you wrote.
from decimal import Decimal
print()
print("Decimal('2.675') rounded to 2 places:", round(Decimal("2.675"), 2))
# The clinching evidence: ask Decimal to show the float exactly.
print()
print("what the float 2.675 really is:", Decimal(2.675))
print("what Decimal('2.675') is: ", Decimal("2.675"))
test("the stored float is not the value typed", Decimal(2.675) == Decimal("2.675"), False)
test("and it is specifically a little below it", Decimal(2.675) < Decimal("2.675"), True)
test("Decimal, given a string, rounds the way school taught", str(round(Decimal("2.675"), 2)), "2.68")
What it printed, when the build ran it
round(2.675, 2) = 2.67 <- looks like a bug
2.675 to 20 places: 2.67499999999999982236
Stored slightly BELOW 2.675, so 2.67 really is the nearer value.
Decimal('2.675') rounded to 2 places: 2.68
what the float 2.675 really is: 2.67499999999999982236431605997495353221893310546875
what Decimal('2.675') is: 2.675
ok the stored float is not the value typed
ok and it is specifically a little below it
ok Decimal, given a string, rounds the way school taught
When approximation is unacceptable: Decimal and Fraction
Money is the standard example. An error of 10⁻¹⁷ per operation is irrelevant in a physics simulation and unacceptable in a ledger that must balance. The standard library ships two exact alternatives, and the cost of both is speed.
from decimal import Decimal
from fractions import Fraction
print("float :", 0.1 + 0.2)
print("Decimal :", Decimal("0.1") + Decimal("0.2"))
print("Fraction:", Fraction(1, 10) + Fraction(2, 10))
# Fraction is exact for any rational, including thirds, which Decimal cannot do.
print()
print("a third, three times, as a Fraction:", Fraction(1, 3) * 3)
print("a third, three times, as a float: ", (1 / 3) * 3)
test("Decimal is exact for decimal fractions", Decimal("0.1") + Decimal("0.2") == Decimal("0.3"), True)
test("Fraction is exact for rationals", Fraction(1, 3) * 3 == 1, True)
test("the float version of a tenth plus two tenths still is not", 0.1 + 0.2 == 0.3, False)
test("but the Fraction version is", Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10), True)
What it printed, when the build ran it
float : 0.30000000000000004 Decimal : 0.3 Fraction: 3/10 a third, three times, as a Fraction: 1 a third, three times, as a float: 1.0 ok Decimal is exact for decimal fractions ok Fraction is exact for rationals ok the float version of a tenth plus two tenths still is not ok but the Fraction version is
Do not go looking for float errors in the obvious places. Several
arithmetic sequences that "should" break happen to land back on an exact value —
(1/3) * 3 really is exactly 1.0, and so is
1/3 + 1/3 + 1/3. The error is real but it is small and it sometimes cancels.
This is precisely why you cannot reason about it by intuition and should compare with a
tolerance instead of hunting for the cases that fail.
A detail that rewards using the built-in
Adding a list of floats with a loop and adding it with sum() can give different
answers, because sum() compensates for the accumulated error and a plain loop does
not. This is a real difference in modern CPython rather than a rounding coincidence.
import math
values = [0.1] * 10
# Accumulating by hand: each += discards a little precision.
running = 0.0
for v in values:
running += v
print("a manual += loop :", repr(running))
print("the built-in sum():", repr(sum(values)))
print("math.fsum() :", repr(math.fsum(values)))
print()
print("Same ten numbers. sum() tracks and corrects the error the loop throws away.")
test("the manual loop misses by a fraction", running == 1.0, False)
test("sum() lands exactly on one", sum(values), 1.0)
test("and fsum(), which guarantees it, agrees", math.fsum(values), 1.0)
What it printed, when the build ran it
a manual += loop : 0.9999999999999999 the built-in sum(): 1.0 math.fsum() : 1.0 Same ten numbers. sum() tracks and corrects the error the loop throws away. ok the manual loop misses by a fraction ok sum() lands exactly on one ok and fsum(), which guarantees it, agrees
One trap in using Decimal at all. Build it from a
string, not a float. Decimal(0.1) takes the already-wrong float and
preserves its error faithfully; Decimal("0.1") is the exact tenth you meant.
Passing a float into Decimal discards the entire benefit of using it.
Negative numbers divide in a way that surprises C programmers
Python's // rounds toward negative infinity rather than toward zero, and
% always takes the sign of the divisor. This is internally consistent and
differs from C, Java and JavaScript, so it is worth seeing once deliberately.
print(" 7 // 2 =", 7 // 2, " 7 % 3 =", 7 % 3)
print("-7 // 2 =", -7 // 2, " -7 % 3 =", -7 % 3, " <- positive remainder")
print()
print("divmod(-7, 3) =", divmod(-7, 3))
# The invariant that makes it consistent: (a // b) * b + (a % b) == a
a, b = -7, 3
print()
print(f"({a} // {b}) * {b} + ({a} % {b}) =", (a // b) * b + (a % b))
# math.fmod follows C instead, which is why the two disagree.
import math
print("math.fmod(-7, 3) =", math.fmod(-7, 3), " <- C's answer, negative")
test("floor division rounds toward negative infinity", -7 // 2, -4)
test("the remainder takes the divisor's sign", -7 % 3, 2)
test("the division invariant holds", (a // b) * b + (a % b), a)
test("math.fmod disagrees on purpose", math.fmod(-7, 3), -1.0)
What it printed, when the build ran it
7 // 2 = 3 7 % 3 = 1 -7 // 2 = -4 -7 % 3 = 2 <- positive remainder divmod(-7, 3) = (-3, 2) (-7 // 3) * 3 + (-7 % 3) = -7 math.fmod(-7, 3) = -1.0 <- C's answer, negative ok floor division rounds toward negative infinity ok the remainder takes the divisor's sign ok the division invariant holds ok math.fmod disagrees on purpose
The positive-remainder behaviour is usually what you want. index % length
gives a valid index for negative input, and day % 7 gives a valid weekday — both
of which would be wrong with C semantics.
What to take from this lesson
- Integers are exact and unbounded. Floats are approximations with about 15–17 significant decimal digits.
- Never compare computed floats with
==. Use a tolerance. round()ties to even, and separately, the value you typed may not be the value stored.- Use
Decimal(from a string) for money andFractionfor exact rationals. //floors toward negative infinity and%follows the divisor's sign.
5 Strings: text, slicing and f-strings
A string is an immutable sequence of characters. Both halves of that matter: sequence gives you indexing, slicing and iteration for free, and immutable means every operation that looks like editing is really building something new.
Strings cannot be edited, only rebuilt
greeting = "hello"
greeting[0] = "H"
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
greeting[0] = "H"
~~~~~~~~^^^
TypeError: 'str' object does not support item assignment
The fix is to build a new string, which every string method already does — they all return a new value and leave the original untouched.
greeting = "hello"
# Every one of these returns a NEW string.
print("upper(): ", greeting.upper())
print("replace(): ", greeting.replace("l", "L"))
print("capitalize():", greeting.capitalize())
# And the original is unchanged, because it could not be otherwise.
print()
print("the original is still:", greeting)
# So the return value has to be used or it is simply discarded.
greeting.upper() # does nothing useful
shouted = greeting.upper() # this is the version that matters
test("methods do not mutate the original", greeting, "hello")
test("they return the new value instead", shouted, "HELLO")
What it printed, when the build ran it
upper(): HELLO replace(): heLLo capitalize(): Hello the original is still: hello ok methods do not mutate the original ok they return the new value instead
Indexing and slicing
Indices start at zero. Negative indices count from the end, which removes almost every
len(x) - 1 you would otherwise write. A slice [start:stop] includes
the start and excludes the stop — the same half-open convention as range, and the
reason a[:n] and a[n:] fit together exactly.
word = "Python"
# 012345
# -654321
print("word[0] =", word[0])
print("word[-1] =", word[-1], " <- last character, no len() needed")
print("word[0:2] =", word[0:2], " <- start included, stop excluded")
print("word[:2] =", word[:2], " <- from the beginning")
print("word[2:] =", word[2:], "<- to the end")
print("word[::2] =", word[::2], "<- every second character")
print("word[::-1]=", word[::-1], "<- reversed")
# The halves of a split slice join back exactly, with no overlap or gap.
print()
print("word[:3] + word[3:] ==", word[:3] + word[3:])
# A slice out of range is clipped rather than an error -- unlike an index.
print("word[0:99] =", word[0:99], "<- clipped, not an error")
test("negative indexing reaches the end", word[-1], "n")
test("a half-open slice excludes its stop", word[0:2], "Py")
test("split slices rejoin exactly", word[:3] + word[3:], word)
test("an oversized slice is clipped", word[0:99], "Python")
What it printed, when the build ran it
word[0] = P word[-1] = n <- last character, no len() needed word[0:2] = Py <- start included, stop excluded word[:2] = Py <- from the beginning word[2:] = thon <- to the end word[::2] = Pto <- every second character word[::-1]= nohtyP <- reversed word[:3] + word[3:] == Python word[0:99] = Python <- clipped, not an error ok negative indexing reaches the end ok a half-open slice excludes its stop ok split slices rejoin exactly ok an oversized slice is clipped
Note the asymmetry in that last line: a slice past the end is clipped silently, but an index past the end raises. That difference is deliberate and worth knowing, because it means a slice will quietly give you fewer items than you asked for.
word = "Python"
print(word[99])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
print(word[99])
~~~~^^^^
IndexError: string index out of range
f-strings, including the parts most people never find
An f before the quotes lets you put expressions inside braces. It is the
current best way to build a string and has been since 3.6 — the older % and
.format() styles still work and you will meet them in existing code, but there is
no reason to write new code with them.
name = "Ada"
age = 36
print(f"{name} is {age}")
# Any expression, not just a name.
print(f"next year she is {age + 1}")
print(f"her name has {len(name)} letters, upper-cased {name.upper()}")
# !r shows the repr -- the quoted, unambiguous form. Invaluable in logs.
value = "7"
print(f"plain: {value} repr: {value!r} <- now you can see it is a string")
# Format specs after a colon: width, alignment, precision, thousands separators.
print()
print(f"pi to 3 places: {3.14159:.3f}")
print(f"padded to 8, right: '{name:>8}'")
print(f"padded to 8, left: '{name:<8}'")
print(f"centred in 8: '{name:^8}'")
print(f"big number: {1234567:,}")
print(f"as a percentage: {0.847:.1%}")
print(f"in hex: {255:#x}")
test("an f-string evaluates expressions", f"{age + 1}", "37")
test("!r gives the quoted form", f"{value!r}", "'7'")
test("a format spec controls precision", f"{3.14159:.3f}", "3.142")
test("and thousands separators", f"{1234567:,}", "1,234,567")
What it printed, when the build ran it
Ada is 36 next year she is 37 her name has 3 letters, upper-cased ADA plain: 7 repr: '7' <- now you can see it is a string pi to 3 places: 3.142 padded to 8, right: ' Ada' padded to 8, left: 'Ada ' centred in 8: ' Ada ' big number: 1,234,567 as a percentage: 84.7% in hex: 0xff ok an f-string evaluates expressions ok !r gives the quoted form ok a format spec controls precision ok and thousands separators
The debugging shortcut worth memorising. Put = at the end of
the expression and the f-string prints the expression itself alongside its value. It turns
every print("total:", total) into something shorter and harder to get out of
sync with the code.
total = 42
items = ["a", "b"]
# The = specifier prints the expression text AND the value.
print(f"{total=}")
print(f"{len(items)=}")
print(f"{total * 2=}")
# It even keeps whitespace, so you can format it.
print(f"{total = }")
test("the = specifier includes the expression text", f"{total=}", "total=42")
test("it works on any expression", f"{len(items)=}", "len(items)=2")
What it printed, when the build ran it
total=42 len(items)=2 total * 2=84 total = 42 ok the = specifier includes the expression text ok it works on any expression
Building a string in a loop: use join
Because strings are immutable, result += piece inside a loop creates a new
string every iteration and copies everything accumulated so far. For a handful of pieces that
does not matter. For thousands it is the difference between linear and quadratic work.
import timeit
words = [str(n) for n in range(2000)]
def with_plus():
result = ""
for w in words:
result += w
return result
def with_join():
return "".join(words)
# Same answer, different amount of work.
print("identical result:", with_plus() == with_join())
plus_time = timeit.timeit(with_plus, number=200)
join_time = timeit.timeit(with_join, number=200)
print(f"200 runs building with += : {plus_time:.4f}s")
print(f"200 runs building with join: {join_time:.4f}s")
print(f"join is about {plus_time / join_time:.0f}x faster at this size")
test("both approaches produce the same string", with_plus(), with_join())
test("join is the faster one", join_time < plus_time, True)
What it printed, when the build ran it
identical result: True 200 runs building with += : 0.0307s 200 runs building with join: 0.0028s join is about 11x faster at this size ok both approaches produce the same string ok join is the faster one
join reads backwards the first few times: the separator goes first, and it is
the thing you call the method on. ", ".join(names), not
names.join(", "). It also only accepts strings, which is the one error it
reliably produces.
parts = ["id", 42]
print("-".join(parts))
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
print("-".join(parts))
~~~~~~~~^^^^^^^
TypeError: sequence item 1: expected str instance, int found
The methods that do the most work
raw = " Ada Lovelace, Analyst \n"
print(f"{raw!r}")
print(f"{raw.strip()!r} <- whitespace off both ends")
print(f"{raw.strip().split(',')!r} <- split on a separator")
# Splitting with no argument splits on any run of whitespace, which is
# almost always what you want for human-entered text.
print()
print("split(): ", "a b \t c".split())
print("split(' '): ", "a b \t c".split(" "), "<- note the empty strings")
line = "name=value"
key, _, val = line.partition("=")
print()
print("partition keeps the separator out but never fails:", (key, val))
# Tests that read like questions.
print()
print("startswith:", "report.csv".startswith("report"))
print("endswith: ", "report.csv".endswith(".csv"))
print("in: ", "csv" in "report.csv")
test("strip removes surrounding whitespace only", " hi ".strip(), "hi")
test("split with no argument collapses runs of whitespace", "a b".split(), ["a", "b"])
test("split with an explicit separator does not", "a b".split(" "), ["a", "", "", "b"])
test("partition always returns three parts", "novalue".partition("="), ("novalue", "", ""))
What it printed, when the build ran it
' Ada Lovelace, Analyst \n'
'Ada Lovelace, Analyst' <- whitespace off both ends
['Ada Lovelace', ' Analyst'] <- split on a separator
split(): ['a', 'b', 'c']
split(' '): ['a', '', '', 'b', '\t', 'c'] <- note the empty strings
partition keeps the separator out but never fails: ('name', 'value')
startswith: True
endswith: True
in: True
ok strip removes surrounding whitespace only
ok split with no argument collapses runs of whitespace
ok split with an explicit separator does not
ok partition always returns three parts
A string is a sequence of characters, and that has a sharp edge. Because
iterating a string yields single characters, passing one where a list was expected often
"works" and produces nonsense rather than an error — list("abc") is
['a', 'b', 'c'], and a function expecting a list of names given the single name
"Ada" will happily process three letters. When a function takes a list of
strings, a single string is the input most likely to fail silently.
def initials(names):
return "".join(n[0] for n in names)
# Intended use.
print("correct:", initials(["Ada", "Grace", "Alan"]))
# The mistake: one string instead of a list of strings. No error at all.
print("silently wrong:", initials("Ada"))
test("given a list, it does the right thing", initials(["Ada", "Grace"]), "AG")
test("given one string, it iterates characters instead", initials("Ada"), "Ada")
What it printed, when the build ran it
correct: AGA silently wrong: Ada ok given a list, it does the right thing ok given one string, it iterates characters instead
What to take from this lesson
- Strings are immutable. Every method returns a new one, so the return value must be used.
- Slices are half-open and clipped at the ends; indices are not forgiving.
- Use f-strings, and learn
!r, format specs, and the=debugging specifier. - Build strings with
"".join(pieces)rather than+=in a loop. - A single string passed where a list of strings was expected iterates its characters instead of raising.
6 Lists, and the aliasing that catches everyone
A list is an ordered, mutable sequence — the default container in Python and the one you will reach for most. Its mutability is what makes it useful and also what makes it the source of the most puzzling bugs in the language.
The operations worth knowing by heart
stack = [3, 1, 2]
stack.append(4) # one item onto the end
stack.extend([5, 6]) # several items onto the end
stack.insert(0, 0) # at a position (costly: shifts everything after it)
print("after building:", stack)
last = stack.pop() # remove and return the last
first = stack.pop(0) # remove and return by index
print("popped", last, "and", first, "leaving", stack)
stack.remove(1) # remove the first occurrence BY VALUE
print("after remove(1):", stack)
print()
print("len: ", len(stack))
print("2 in it: ", 2 in stack)
print("index of 2:", stack.index(2))
print("count of 2:", stack.count(2))
test("pop() took the last item", last, 6)
test("pop(0) took the first", first, 0)
test("the list ended up as expected", stack, [3, 2, 4, 5])
test("membership works on values, not positions", 2 in stack, True)
What it printed, when the build ran it
after building: [0, 3, 1, 2, 4, 5, 6] popped 6 and 0 leaving [3, 1, 2, 4, 5] after remove(1): [3, 2, 4, 5] len: 4 2 in it: True index of 2: 1 count of 2: 1 ok pop() took the last item ok pop(0) took the first ok the list ended up as expected ok membership works on values, not positions
Two of those deserve a warning. remove takes a value, not an index,
and raises if the value is absent. And pop(0) is not free — removing from the
front shifts every remaining element, so a loop doing it repeatedly is quadratic. Lesson 35
covers collections.deque, which is the right tool for a queue.
colours = ["red", "green"]
colours.remove("blue")
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
colours.remove("blue")
~~~~~~~~~~~~~~^^^^^^^^
ValueError: list.remove(x): x not in list
Copying, and the two depths of it
From lesson 2: b = a makes another name, not another list. To get a genuinely
separate list you have to ask, and there are two different things you might be asking for.
import copy
original = [1, 2, 3]
alias = original # NOT a copy
shallow = original[:] # a copy (also: list(original), original.copy())
shallow.append(4)
print("original after appending to the copy:", original)
print("the copy: ", shallow)
test("the alias is the same object", alias is original, True)
test("the slice is a different object", shallow is original, False)
test("so the original was untouched", original, [1, 2, 3])
# Now the part that catches people: a shallow copy copies the OUTER list only.
grid = [[1, 2], [3, 4]]
outer_copy = grid[:]
outer_copy[0].append(99) # reaching through to a SHARED inner list
print()
print("after appending to outer_copy[0][…]:")
print(" grid: ", grid, "<- changed too")
print(" outer_copy:", outer_copy)
# deepcopy copies all the way down.
deep = copy.deepcopy(grid)
deep[0].append(1000)
print()
print("after appending to a deepcopy:")
print(" grid:", grid, "<- this time untouched")
test("a shallow copy shares the inner lists", grid[0], [1, 2, 99])
test("the inner lists really are the same object", grid[0] is outer_copy[0], True)
test("deepcopy does not share them", grid[0] is deep[0], False)
What it printed, when the build ran it
original after appending to the copy: [1, 2, 3] the copy: [1, 2, 3, 4] ok the alias is the same object ok the slice is a different object ok so the original was untouched after appending to outer_copy[0][…]: grid: [[1, 2, 99], [3, 4]] <- changed too outer_copy: [[1, 2, 99], [3, 4]] after appending to a deepcopy: grid: [[1, 2, 99], [3, 4]] <- this time untouched ok a shallow copy shares the inner lists ok the inner lists really are the same object ok deepcopy does not share them
The [[0]] * 3 trap
Multiplying a list repeats references, not contents. With numbers inside you never notice. With lists inside, you have made one inner list and pointed at it three times — and building a grid this way is common enough that it is worth seeing fail.
# The wrong way to build a grid.
grid = [[0]] * 3
print("looks right:", grid)
grid[0][0] = 9
print("after setting only grid[0][0]:", grid, "<- all three changed")
print("because all three are one object:", grid[0] is grid[1] is grid[2])
# The right way: a comprehension, which evaluates [0] afresh each time.
good = [[0] for _ in range(3)]
good[0][0] = 9
print()
print("with a comprehension:", good)
print("three distinct objects:", good[0] is good[1])
test("multiplication shared a single inner list", grid, [[9], [9], [9]])
test("the comprehension built three separate ones", good, [[9], [0], [0]])
What it printed, when the build ran it
looks right: [[0], [0], [0]] after setting only grid[0][0]: [[9], [9], [9]] <- all three changed because all three are one object: True with a comprehension: [[9], [0], [0]] three distinct objects: False ok multiplication shared a single inner list ok the comprehension built three separate ones
When * is fine. [0] * 5 is perfectly safe,
because integers are immutable — nothing can reach into one and change it, so sharing is
undetectable. The rule is: repeating immutable values is fine, repeating a mutable container
is almost never what you want.
sort() and sorted() are not the same call
list.sort() sorts in place and returns None.
sorted(iterable) leaves its input alone and returns a new list. Assigning the
result of the first is the mistake, and it produces None rather than an error.
numbers = [3, 1, 2]
# sorted() returns a new list; the original is untouched.
ascending = sorted(numbers)
print("sorted() gave:", ascending, " original still:", numbers)
# sort() rearranges in place and returns None.
returned = numbers.sort()
print("sort() returned:", returned, " and the list is now:", numbers)
# key= controls what to sort by; reverse= flips the order.
words = ["banana", "fig", "cherry"]
print()
print("by length: ", sorted(words, key=len))
print("reverse alphabet: ", sorted(words, reverse=True))
# The sort is stable: equal keys keep their original relative order.
people = [("Ada", 36), ("Alan", 41), ("Grace", 36)]
print("stable by age: ", sorted(people, key=lambda p: p[1]))
test("sort() returns None, not the list", returned, None)
test("sorted() does not modify its input", sorted([3, 1]), [1, 3])
test("key= sorts by a computed value", sorted(words, key=len), ["fig", "banana", "cherry"])
test("the sort is stable, so Ada stays before Grace",
[p[0] for p in sorted(people, key=lambda p: p[1])], ["Ada", "Grace", "Alan"])
What it printed, when the build ran it
sorted() gave: [1, 2, 3] original still: [3, 1, 2]
sort() returned: None and the list is now: [1, 2, 3]
by length: ['fig', 'banana', 'cherry']
reverse alphabet: ['fig', 'cherry', 'banana']
stable by age: [('Ada', 36), ('Grace', 36), ('Alan', 41)]
ok sort() returns None, not the list
ok sorted() does not modify its input
ok key= sorts by a computed value
ok the sort is stable, so Ada stays before Grace
The consequence of sort() returning None is a bug that shows up
one line later, which is why it is worth recognising on sight.
numbers = [3, 1, 2]
numbers = numbers.sort() # the mistake: assigning the return value
print(numbers[0])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 3, in <module>
print(numbers[0])
~~~~~~~^^^
TypeError: 'NoneType' object is not subscriptable
Do not change the length of a list you are iterating
The loop keeps an internal position. Removing items as you go moves the remaining items underneath it, so the loop skips some. It does not raise — it silently produces the wrong answer, which is worse.
# The bug: removing while iterating.
numbers = [1, 2, 2, 3, 2, 4]
for n in numbers:
if n == 2:
numbers.remove(n)
buggy = numbers
print("after removing 2s in place:", buggy, "<- a 2 survived")
# Fix one: build a new list, which is clearer as well as correct.
numbers = [1, 2, 2, 3, 2, 4]
kept = [n for n in numbers if n != 2]
print("with a comprehension: ", kept)
# Fix two: iterate over a copy if you must mutate the original.
numbers = [1, 2, 2, 3, 2, 4]
for n in numbers[:]:
if n == 2:
numbers.remove(n)
print("iterating over a copy: ", numbers)
test("mutating while iterating left a 2 behind", buggy, [1, 3, 2, 4])
test("the comprehension removes every 2", kept, [1, 3, 4])
test("iterating over a copy also removes every 2", numbers, [1, 3, 4])
What it printed, when the build ran it
after removing 2s in place: [1, 3, 2, 4] <- a 2 survived with a comprehension: [1, 3, 4] iterating over a copy: [1, 3, 4] ok mutating while iterating left a 2 behind ok the comprehension removes every 2 ok iterating over a copy also removes every 2
Unpacking a list
first, second, third = [1, 2, 3]
print("exact:", first, second, third)
# A starred name absorbs however many are left over, as a list.
head, *rest = [1, 2, 3, 4]
print("head:", head, " rest:", rest)
*most, last = [1, 2, 3, 4]
print("most:", most, " last:", last)
first, *middle, last = [1, 2, 3, 4, 5]
print("middle:", middle)
# Underscore is a normal name, used by convention for "I am ignoring this".
_, wanted, _ = [1, 2, 3]
print("ignoring two of three:", wanted)
test("a starred name collects the remainder as a list", rest, [2, 3, 4])
test("it can sit anywhere in the pattern", middle, [2, 3, 4])
What it printed, when the build ran it
exact: 1 2 3 head: 1 rest: [2, 3, 4] most: [1, 2, 3] last: 4 middle: [2, 3, 4] ignoring two of three: 2 ok a starred name collects the remainder as a list ok it can sit anywhere in the pattern
Unpacking insists the shape matches, and says so clearly when it does not.
a, b = [1, 2, 3]
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 1, in <module>
a, b = [1, 2, 3]
^^^^
ValueError: too many values to unpack (expected 2, got 3)
What to take from this lesson
b = ais not a copy. Usea[:]orlist(a)for a shallow copy andcopy.deepcopywhen the contents are mutable too.[[0]] * 3repeats one inner list. Use a comprehension.sort()mutates and returnsNone;sorted()returns a new list.- Never add or remove items from a list while iterating it — build a new one instead.
removetakes a value and raises if it is absent.
7 Tuples, unpacking, and why immutability is useful
A tuple is an immutable sequence. Described as "a list you cannot change" it sounds like a worse list, which is why beginners ignore it. The useful framing is the other way round: because it cannot change, it can be hashed — and that unlocks the one thing a list can never do.
The syntax, and the comma that actually matters
It is the commas that make a tuple, not the parentheses. The parentheses are usually there for clarity and are occasionally required for grouping, but a bare comma-separated sequence is already a tuple — which is why a stray trailing comma can silently turn a value into a one-element tuple.
point = (3, 4)
also_a_tuple = 3, 4 # no parentheses needed
empty = ()
single = (5,) # the comma is what makes this a tuple
not_a_tuple = (5) # just 5 in brackets
print("point: ", point, type(point).__name__)
print("also_a_tuple:", also_a_tuple, type(also_a_tuple).__name__)
print("single: ", single, type(single).__name__)
print("not_a_tuple: ", not_a_tuple, type(not_a_tuple).__name__)
# The accident this enables: a trailing comma changes the type.
value = 42,
print()
print("value = 42, gives:", value, "of type", type(value).__name__)
test("commas make the tuple, not the brackets", also_a_tuple, (3, 4))
test("one element needs the trailing comma", single, (5,))
test("without it, the brackets just group", not_a_tuple, 5)
test("a trailing comma silently creates a tuple", value, (42,))
What it printed, when the build ran it
point: (3, 4) tuple also_a_tuple: (3, 4) tuple single: (5,) tuple not_a_tuple: 5 int value = 42, gives: (42,) of type tuple ok commas make the tuple, not the brackets ok one element needs the trailing comma ok without it, the brackets just group ok a trailing comma silently creates a tuple
Immutable means the tuple, not necessarily its contents
You cannot add, remove or replace an element of a tuple. That is a guarantee about the tuple's own structure. It says nothing about whether the objects inside it can change, and that distinction is where the usual one-line summary breaks down.
point = (3, 4)
point[0] = 99
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
point[0] = 99
~~~~~^^^
TypeError: 'tuple' object does not support item assignment
# A tuple whose contents include a mutable object.
record = ("Ada", [36, 37])
# The tuple's structure is fixed...
try:
record[0] = "Grace"
except TypeError as exc:
print("replacing an element:", type(exc).__name__)
# ...but the list inside it is still a list.
record[1].append(38)
print("after appending to the inner list:", record)
test("the tuple still has two elements", len(record), 2)
test("but the list inside it changed", record[1], [36, 37, 38])
What it printed, when the build ran it
replacing an element: TypeError
after appending to the inner list: ('Ada', [36, 37, 38])
ok the tuple still has two elements
ok but the list inside it changed
The real reason tuples exist: they can be hashed
A dict key and a set member must be hashable, and hashability requires that the value cannot change — otherwise the container could never find it again after a change. Lists are mutable, so they are unhashable by design. Tuples of immutable things are hashable, which makes them the standard way to use a compound value as a key.
# A coordinate as a dict key -- impossible with a list.
visits = {}
visits[(0, 0)] = "start"
visits[(3, 4)] = "treasure"
print("looked up by coordinate:", visits[(3, 4)])
print("the keys:", list(visits))
# The same thing with a list as the key does not work.
try:
visits[[0, 0]] = "nope"
except TypeError as exc:
print()
print("a list as a key:", exc)
# Tuples in a set, deduplicating pairs.
seen = {(0, 0), (3, 4), (0, 0)}
print()
print("a set of pairs deduplicates:", len(seen), "unique of 3 given")
test("a tuple works as a dict key", visits[(3, 4)], "treasure")
test("a tuple is hashable", isinstance(hash((3, 4)), int), True)
test("a set of tuples deduplicates", len(seen), 2)
What it printed, when the build ran it
looked up by coordinate: treasure the keys: [(0, 0), (3, 4)] a list as a key: cannot use 'list' as a dict key (unhashable type: 'list') a set of pairs deduplicates: 2 unique of 3 given ok a tuple works as a dict key ok a tuple is hashable ok a set of tuples deduplicates
And the rule made precise: a tuple is hashable only if everything inside it is. A tuple containing a list is not hashable, because its contents could change.
key = (1, [2, 3])
hash(key)
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
hash(key)
~~~~^^^^^
TypeError: unhashable type: 'list'
Unpacking is where tuples are used most
Most of the tuples you handle are never named. They appear as the way a function returns more than one value and are unpacked immediately at the call site.
def min_max(numbers):
return min(numbers), max(numbers) # a tuple, without brackets
low, high = min_max([3, 1, 4, 1, 5])
print("low:", low, " high:", high)
# Swapping, without a temporary variable. The right side becomes a tuple first.
a, b = 1, 2
a, b = b, a
print("swapped:", a, b)
# Unpacking in a for loop, which is the single most common use.
pairs = [("Ada", 1815), ("Alan", 1912), ("Grace", 1906)]
print()
for name, year in pairs:
print(f" {name} was born in {year}")
# enumerate hands you a tuple each time round, so it unpacks the same way.
print()
for index, (name, year) in enumerate(pairs, start=1):
print(f" {index}. {name} ({year})")
test("a function can return two values as a tuple", min_max([3, 1, 4]), (1, 4))
test("swapping works without a temporary", (a, b), (2, 1))
test("unpacking in a loop reaches both halves",
[n for n, _ in pairs], ["Ada", "Alan", "Grace"])
What it printed, when the build ran it
low: 1 high: 5 swapped: 2 1 Ada was born in 1815 Alan was born in 1912 Grace was born in 1906 1. Ada (1815) 2. Alan (1912) 3. Grace (1906) ok a function can return two values as a tuple ok swapping works without a temporary ok unpacking in a loop reaches both halves
When to use which
A practical rule that holds up. Use a list for a
variable number of similar things you will add to, remove from or sort — a list of users, a
list of lines. Use a tuple for a fixed number of related but different
things that together form one value — a coordinate, an RGB colour, a database row. If you
find yourself writing record[2] and having to remember what index two means, the
answer is neither: you want a NamedTuple or a dataclass, which
lesson 32 covers.
from collections import namedtuple
# A tuple with names for its positions. Still a tuple: immutable, hashable,
# unpackable -- but readable.
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print("by name: ", p.x, p.y)
print("by position:", p[0], p[1])
print("it prints usefully:", p)
# It is still a real tuple, so everything tuples can do still works.
x, y = p
print("still unpacks:", x, y)
print("still hashable, so usable as a key:", {p: "here"}[Point(3, 4)])
test("a namedtuple is a tuple", isinstance(p, tuple), True)
test("fields are reachable by name", (p.x, p.y), (3, 4))
test("equal by value, like any tuple", p == (3, 4), True)
What it printed, when the build ran it
by name: 3 4 by position: 3 4 it prints usefully: Point(x=3, y=4) still unpacks: 3 4 still hashable, so usable as a key: here ok a namedtuple is a tuple ok fields are reachable by name ok equal by value, like any tuple
What to take from this lesson
- The comma makes the tuple. A single-element tuple needs its trailing comma, and a stray one creates a tuple by accident.
- Immutability is shallow: the tuple's structure is fixed, but mutable objects inside it are not.
- Tuples are hashable when their contents are, which is what makes compound dict keys and set members possible.
- Most tuples are unpacked rather than named — multiple return values, swaps, and loops over pairs.
- Reaching for
record[2]is the signal to use anamedtupleor a dataclass instead.
8 Dicts: the data structure Python is built on
A dict maps keys to values with fast lookup by key. It is also the structure Python uses for itself — a module's globals, an object's attributes and a function's keyword arguments are all dicts underneath — so learning it well pays off twice.
The basics, and the four ways to read a key
user = {"name": "Ada", "role": "analyst"}
user["team"] = "research" # add
user["role"] = "lead analyst" # update, same syntax
print("built:", user)
print()
print("len: ", len(user))
print("has 'name':", "name" in user)
print("keys: ", list(user.keys()))
print("values: ", list(user.values()))
print("items: ", list(user.items()))
del user["team"]
print()
print("after del:", user)
test("assignment adds or replaces", user["role"], "lead analyst")
test("membership tests keys, not values", "Ada" in user, False)
test("del removes a key", "team" in user, False)
What it printed, when the build ran it
built: {'name': 'Ada', 'role': 'lead analyst', 'team': 'research'}
len: 3
has 'name': True
keys: ['name', 'role', 'team']
values: ['Ada', 'lead analyst', 'research']
items: [('name', 'Ada'), ('role', 'lead analyst'), ('team', 'research')]
after del: {'name': 'Ada', 'role': 'lead analyst'}
ok assignment adds or replaces
ok membership tests keys, not values
ok del removes a key
Reading a key that is not there is the most common dict error, and there are four different right answers depending on what you actually mean.
config = {"host": "localhost"}
# 1. Brackets: "this key must exist". Raises if it does not.
print("host:", config["host"])
# 2. get(): "it might not exist, and None is fine"
print("port via get(): ", config.get("port"))
# 3. get() with a default: "it might not exist, use this instead"
print("port via get(default): ", config.get("port", 8080))
# 4. setdefault(): "it might not exist; if so, store this and give it back"
stored = config.setdefault("port", 8080)
print("port via setdefault(): ", stored, " and the dict is now:", config)
test("get returns None for a missing key", {"a": 1}.get("b"), None)
test("get can supply a default instead", {"a": 1}.get("b", 0), 0)
test("get does NOT add the key", "b" in {"a": 1}, False)
test("setdefault does add it", config["port"], 8080)
What it printed, when the build ran it
host: localhost
port via get(): None
port via get(default): 8080
port via setdefault(): 8080 and the dict is now: {'host': 'localhost', 'port': 8080}
ok get returns None for a missing key
ok get can supply a default instead
ok get does NOT add the key
ok setdefault does add it
user = {"name": "Ada"}
print(user["email"])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
print(user["email"])
~~~~^^^^^^^^^
KeyError: 'email'
Choose by intent, not by which one avoids the error. Brackets are correct
when a missing key is genuinely a bug — you want the KeyError, because
silently continuing with a default would hide it. Reach for get when absence is
a normal case. Wrapping everything in get to avoid ever seeing a
KeyError converts loud failures into quiet wrong answers.
Iteration gives you keys, which is a common surprise
scores = {"ada": 90, "alan": 85}
# Iterating a dict yields its KEYS.
for key in scores:
print("key:", key)
# Values and pairs have to be asked for.
print()
for name, score in scores.items():
print(f" {name} scored {score}")
# A common bug: expecting values from a bare loop.
total_wrong = 0
for x in scores:
total_wrong += 1 # counting keys, not summing scores
total_right = sum(scores.values())
print()
print("count of keys:", total_wrong, " sum of values:", total_right)
test("iterating yields keys", sorted(scores), ["ada", "alan"])
test("values() is needed for the values", sum(scores.values()), 175)
test("items() gives pairs", sorted(scores.items())[0], ("ada", 90))
What it printed, when the build ran it
key: ada key: alan ada scored 90 alan scored 85 count of keys: 2 sum of values: 175 ok iterating yields keys ok values() is needed for the values ok items() gives pairs
Order is insertion order, and that is now a guarantee
Since Python 3.7 a dict preserves the order keys were first inserted. This is part of the language specification rather than an implementation accident, so you can rely on it. Note that updating an existing key does not move it.
d = {}
d["b"] = 1
d["a"] = 2
d["c"] = 3
print("insertion order:", list(d))
d["b"] = 99 # updating does not reorder
print("after updating b:", list(d))
# Sorting is a separate request, and gives you a list.
print()
print("sorted by key: ", dict(sorted(d.items())))
print("sorted by value:", dict(sorted(d.items(), key=lambda kv: kv[1])))
test("keys stay in insertion order", list(d), ["b", "a", "c"])
test("updating a value does not move the key", list(d)[0], "b")
What it printed, when the build ran it
insertion order: ['b', 'a', 'c']
after updating b: ['b', 'a', 'c']
sorted by key: {'a': 2, 'b': 99, 'c': 3}
sorted by value: {'a': 2, 'c': 3, 'b': 99}
ok keys stay in insertion order
ok updating a value does not move the key
Keys must be hashable
The same rule as lesson 7: a key has to be something that cannot change, so a list cannot be one. Strings, numbers, and tuples of immutable things are the usual choices.
index = {}
index[["a", "b"]] = 1
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
index[["a", "b"]] = 1
~~~~~^^^^^^^^^^^^
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
# What is allowed as a key.
mixed = {
"a string": 1,
42: 2,
3.5: 3,
(0, 0): 4,
True: 5,
None: 6,
frozenset([1, 2]): 7,
}
print("all of these are valid keys:", len(mixed))
for k in mixed:
print(f" {type(k).__name__:>9} {k!r}")
test("tuples, numbers, strings, None and frozensets all work as keys", len(mixed), 7)
What it printed, when the build ran it
all of these are valid keys: 7
str 'a string'
int 42
float 3.5
tuple (0, 0)
bool True
NoneType None
frozenset frozenset({1, 2})
ok tuples, numbers, strings, None and frozensets all work as keys
One sharp edge in that list. True == 1 and
hash(True) == hash(1), so True and 1 are the
same key. A dict cannot hold both. The same applies to 1 and
1.0. It is rarely what you want and can be genuinely baffling when it
happens.
collision = {}
collision[1] = "integer one"
collision[True] = "boolean true"
collision[1.0] = "float one"
print("three assignments, and the dict holds:", collision)
print("because 1 == True == 1.0 and their hashes match")
test("they collapse into one entry", len(collision), 1)
test("the last assignment won", collision[1], "float one")
test("the original key object is kept", list(collision)[0], 1)
What it printed, when the build ran it
three assignments, and the dict holds: {1: 'float one'}
because 1 == True == 1.0 and their hashes match
ok they collapse into one entry
ok the last assignment won
ok the original key object is kept
Merging, comprehensions, and counting
defaults = {"host": "localhost", "port": 80}
overrides = {"port": 8080}
# | merges, with the RIGHT side winning on conflicts (3.9+).
merged = defaults | overrides
print("merged: ", merged)
print("original:", defaults, "<- untouched")
# |= updates in place.
running = dict(defaults)
running |= overrides
print("after |=:", running)
# A dict comprehension.
squares = {n: n * n for n in range(5)}
print()
print("comprehension:", squares)
# Inverting a dict, which is just a comprehension with the pair swapped.
codes = {"ok": 200, "missing": 404}
by_code = {v: k for k, v in codes.items()}
print("inverted: ", by_code)
test("the right side wins a merge", merged["port"], 8080)
test("merging does not modify either input", defaults["port"], 80)
test("a dict comprehension builds pairs", squares[4], 16)
test("inverting swaps keys and values", by_code[404], "missing")
What it printed, when the build ran it
merged: {'host': 'localhost', 'port': 8080}
original: {'host': 'localhost', 'port': 80} <- untouched
after |=: {'host': 'localhost', 'port': 8080}
comprehension: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
inverted: {200: 'ok', 404: 'missing'}
ok the right side wins a merge
ok merging does not modify either input
ok a dict comprehension builds pairs
ok inverting swaps keys and values
Counting things is common enough that the standard library has it covered. Writing it by hand once is worth doing, and then never doing again.
from collections import Counter, defaultdict
words = ["fig", "apple", "fig", "cherry", "fig"]
# By hand, with get()
by_hand = {}
for w in words:
by_hand[w] = by_hand.get(w, 0) + 1
# With defaultdict, which creates the default on first access
dd = defaultdict(int)
for w in words:
dd[w] += 1
# With Counter, which exists for exactly this
counted = Counter(words)
print("by hand: ", by_hand)
print("defaultdict:", dict(dd))
print("Counter: ", dict(counted))
print()
print("and Counter can rank them:", counted.most_common(2))
test("all three agree", by_hand == dict(dd) == dict(counted), True)
test("Counter ranks by frequency", counted.most_common(1), [("fig", 3)])
What it printed, when the build ran it
by hand: {'fig': 3, 'apple': 1, 'cherry': 1}
defaultdict: {'fig': 3, 'apple': 1, 'cherry': 1}
Counter: {'fig': 3, 'apple': 1, 'cherry': 1}
and Counter can rank them: [('fig', 3), ('apple', 1)]
ok all three agree
ok Counter ranks by frequency
The one defaultdict surprise. Merely reading a
missing key creates it, because that is how the default gets supplied. So
if dd["nope"] == 0 silently adds "nope" to the dict. Use
in to test membership on a defaultdict, never a read.
from collections import defaultdict
dd = defaultdict(int)
dd["real"] = 1
print("before checking:", dict(dd))
_ = dd["ghost"] # just reading it
print("after reading dd['ghost']:", dict(dd), "<- it exists now")
# 'in' does not create the key.
safe = defaultdict(int)
safe["real"] = 1
print()
print("'phantom' in safe:", "phantom" in safe, " and the dict is still", dict(safe))
test("reading a missing key created it", "ghost" in dd, True)
test("using in does not", "phantom" in safe, False)
What it printed, when the build ran it
before checking: {'real': 1}
after reading dd['ghost']: {'real': 1, 'ghost': 0} <- it exists now
'phantom' in safe: False and the dict is still {'real': 1}
ok reading a missing key created it
ok using in does not
What to take from this lesson
- Brackets when a missing key is a bug;
getwhen absence is normal;setdefaultwhen you want to store the default too. - Iterating a dict gives keys. Use
.values()or.items()for the rest. - Insertion order is guaranteed since 3.7.
- Keys must be hashable, and
1,Trueand1.0are all the same key. Counterfor counting;defaultdictfor accumulating — but remember that reading a missing key creates it.
9 Sets, membership, and cheap de-duplication
A set is an unordered collection of unique, hashable values. Two things make it worth reaching for: it removes duplicates for free, and it answers "is this in here?" in roughly constant time no matter how large it gets.
Making one, and the empty-set trap
# From a literal...
primes = {2, 3, 5, 7}
# ...or from any iterable, which is how you deduplicate.
words = ["fig", "apple", "fig", "cherry", "fig"]
unique = set(words)
print("primes:", primes)
print("unique words:", unique, " (order is not meaningful)")
print("counts: 5 given,", len(unique), "distinct")
# The trap: {} is an empty DICT. The empty set has no literal.
empty_dict = {}
empty_set = set()
print()
print("type of {} :", type(empty_dict).__name__, "<- not a set")
print("type of set() :", type(empty_set).__name__)
test("a set drops duplicates", len(unique), 3)
test("{} is a dict", type({}).__name__, "dict")
test("the empty set needs set()", len(set()), 0)
What it printed, when the build ran it
primes: {2, 3, 5, 7}
unique words: {'fig', 'apple', 'cherry'} (order is not meaningful)
counts: 5 given, 3 distinct
type of {} : dict <- not a set
type of set() : set
ok a set drops duplicates
ok {} is a dict
ok the empty set needs set()
Membership is the reason to use one
Checking x in a_list compares against each element in turn, so the work grows
with the length of the list. Checking x in a_set jumps straight to where the value
would be. Both are one line of Python and they are not remotely the same amount of work.
import timeit
size = 20_000
as_list = list(range(size))
as_set = set(as_list)
target = size - 1 # worst case for the list: the very last item
list_time = timeit.timeit(lambda: target in as_list, number=200)
set_time = timeit.timeit(lambda: target in as_set, number=200)
print(f"200 membership tests against {size:,} items")
print(f" list: {list_time:.5f}s")
print(f" set: {set_time:.7f}s")
print(f" the set is about {list_time / set_time:,.0f}x faster here")
print()
print("Both were one line of Python. Only the container changed.")
test("the set answers the same question", (target in as_list) == (target in as_set), True)
test("and is measurably faster", set_time < list_time, True)
What it printed, when the build ran it
200 membership tests against 20,000 items list: 0.05572s set: 0.0000119s the set is about 4,687x faster here Both were one line of Python. Only the container changed. ok the set answers the same question ok and is measurably faster
The practical version of that. Any time you build a list purely to ask
in against it repeatedly — allowed usernames, seen IDs, stop words — make it a
set instead. It is a one-word change with a large effect, and it is one of the few
optimisations that costs nothing in readability.
Set algebra reads like the mathematics
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print("a | b union: ", a | b)
print("a & b intersection: ", a & b)
print("a - b difference: ", a - b)
print("b - a the other difference:", b - a)
print("a ^ b symmetric difference:", a ^ b, " (in one or the other, not both)")
print()
print("{1,2} <= a subset? ", {1, 2} <= a)
print("a >= {1,2} superset?", a >= {1, 2})
print("a.isdisjoint({9}) ", a.isdisjoint({9}))
test("union collects everything", a | b, {1, 2, 3, 4, 5, 6})
test("intersection keeps the overlap", a & b, {3, 4})
test("difference removes the other side", a - b, {1, 2})
test("symmetric difference excludes the overlap", a ^ b, {1, 2, 5, 6})
test("subset comparison works", {1, 2} <= a, True)
What it printed, when the build ran it
a | b union: {1, 2, 3, 4, 5, 6}
a & b intersection: {3, 4}
a - b difference: {1, 2}
b - a the other difference: {5, 6}
a ^ b symmetric difference: {1, 2, 5, 6} (in one or the other, not both)
{1,2} <= a subset? True
a >= {1,2} superset? True
a.isdisjoint({9}) True
ok union collects everything
ok intersection keeps the overlap
ok difference removes the other side
ok symmetric difference excludes the overlap
ok subset comparison works
These operations are the point at which set code becomes shorter than the loop it replaces.
"Which users are in both groups" is group_a & group_b, and "which permissions
is this role missing" is required - granted.
required = {"read", "write", "deploy"}
granted = {"read", "write"}
missing = required - granted
extra = granted - required
print("missing:", missing)
print("extra: ", extra or "none")
print("is the role sufficient?", required <= granted)
test("the difference names what is missing", missing, {"deploy"})
test("and the subset test answers the real question", required <= granted, False)
What it printed, when the build ran it
missing: {'deploy'}
extra: none
is the role sufficient? False
ok the difference names what is missing
ok and the subset test answers the real question
Sets are mutable; their members are not
A set can be added to and removed from, but every member must be hashable — the same rule as dict keys, for the same reason. So a set of lists is impossible, and a set of tuples is fine.
s = {1, 2}
s.add(3)
s.update([4, 5])
s.discard(1) # no error if absent
s.remove(2) # raises if absent
print("after editing:", s)
# Members must be hashable, so tuples are in and lists are out.
pairs = {(0, 0), (1, 1)}
print("a set of tuples is fine:", pairs)
test("add and update grow the set", s, {3, 4, 5})
test("discard tolerates a missing value", s.discard(99), None)
test("tuples can be members", (0, 0) in pairs, True)
What it printed, when the build ran it
after editing: {3, 4, 5}
a set of tuples is fine: {(1, 1), (0, 0)}
ok add and update grow the set
ok discard tolerates a missing value
ok tuples can be members
seen = set()
seen.add([1, 2])
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
seen.add([1, 2])
~~~~~~~~^^^^^^^^
TypeError: cannot use 'list' as a set element (unhashable type: 'list')
And because a set is itself mutable, it cannot be a member of another set. When you need
that — a set of sets, or a set as a dict key — the immutable version is
frozenset.
# A frozenset is hashable, so it can be a member or a key.
groups = {frozenset({"ada", "alan"}), frozenset({"grace"})}
print("a set of frozensets:", len(groups), "groups")
# Which makes this possible: caching by an unordered collection of inputs.
cache = {}
cache[frozenset(["read", "write"])] = "role: editor"
print("looked up regardless of order:", cache[frozenset(["write", "read"])])
test("a frozenset is hashable", isinstance(hash(frozenset([1])), int), True)
test("order does not affect the key", frozenset([1, 2]) == frozenset([2, 1]), True)
test("but a frozenset cannot be edited", hasattr(frozenset([1]), "add"), False)
What it printed, when the build ran it
a set of frozensets: 2 groups looked up regardless of order: role: editor ok a frozenset is hashable ok order does not affect the key ok but a frozenset cannot be edited
What a set gives up
No order and no duplicates are features, and also the cost. A set cannot
tell you what came first, cannot be indexed (s[0] is an error), and cannot hold
two equal values. If you deduplicate a list through a set you lose the original order — and
if you need both, the standard trick is list(dict.fromkeys(items)), because dicts
preserve insertion order and also have unique keys.
items = ["fig", "apple", "fig", "cherry", "apple"]
# A set deduplicates but discards order.
via_set = list(set(items))
# dict.fromkeys deduplicates AND keeps first-seen order.
via_dict = list(dict.fromkeys(items))
print("original: ", items)
print("via set: ", via_set, " <- order not meaningful")
print("via dict.fromkeys:", via_dict, "<- first-seen order kept")
# And indexing a set is simply not a thing.
try:
set(items)[0]
except TypeError as exc:
print()
print("indexing a set:", exc)
test("both remove duplicates", sorted(via_set), sorted(via_dict))
test("only the dict version preserves order", via_dict, ["fig", "apple", "cherry"])
What it printed, when the build ran it
original: ['fig', 'apple', 'fig', 'cherry', 'apple'] via set: ['fig', 'apple', 'cherry'] <- order not meaningful via dict.fromkeys: ['fig', 'apple', 'cherry'] <- first-seen order kept indexing a set: 'set' object is not subscriptable ok both remove duplicates ok only the dict version preserves order
Choosing a container, which is what Part 2 has been building to
- list — ordered, mutable, duplicates allowed. The default. Reach for it unless you have a reason not to.
- tuple — ordered, immutable, hashable. A fixed group of related values, or a compound dict key.
- dict — keyed lookup, insertion-ordered. When you will look things up by name or id rather than by position.
- set — unordered, unique, fast membership. When the questions you ask are "is this in here?", "what do these have in common?", or "what is missing?".
Getting this choice right is worth more than any micro-optimisation: it is the difference between the permission check above being two readable lines and being a nested loop.