Emmanuel Akinfulubi

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.

Integers, with no ceiling

ran at build time
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.

The most important five lines in this course

ran at build time
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.

Rounding halves to even

ran at build time
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.

The other rounding surprise, which is not a rounding rule at all

ran at build time
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.

Two exact number types, and what each is exact about

ran at build time
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.

sum() is not just shorter than the loop, it is more accurate

ran at build time
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.

Floor division, and the invariant that explains the sign

ran at build time
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 and Fraction for 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

There is no in-place edit

raises TypeError
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.

Every string method returns a new string

ran at build time
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.

Slicing, and why the stop is excluded

ran at build time
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.

An index, unlike a slice, does not forgive

raises IndexError
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.

f-strings: expressions, !r, and format specs

ran at build time
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.

The = specifier, which exists purely for debugging

ran at build time
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.

Concatenating in a loop versus joining once

ran at build time
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.

join needs strings, and says which position failed

raises TypeError
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

strip, split, partition, and the membership tests

ran at build time
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.

The bug that does not raise

ran at build time
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

Building and taking apart a list

ran at build time
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.

remove takes a value, and insists it exists

raises ValueError
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.

Shallow versus deep, and when the difference shows

ran at build time
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.

Repeating a list repeats the reference

ran at build time
# 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.

In place versus returning a new list

ran at build time
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.

Assigning the result of sort() throws the list away

raises TypeError
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.

Removing while iterating, and two fixes

ran at build time
# 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

Unpacking, including the starred form

ran at build time
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.

Too many values to unpack

raises ValueError
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 = a is not a copy. Use a[:] or list(a) for a shallow copy and copy.deepcopy when the contents are mutable too.
  • [[0]] * 3 repeats one inner list. Use a comprehension.
  • sort() mutates and returns None; sorted() returns a new list.
  • Never add or remove items from a list while iterating it — build a new one instead.
  • remove takes 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.

The comma is the tuple

ran at build time
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.

The structure is fixed

raises TypeError
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

Immutability is shallow

ran at build time
# 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.

Compound keys, which only work because tuples are hashable

ran at build time
# 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.

Hashability is inherited from the contents

raises TypeError
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.

Returning two values, swapping, and unpacking in a loop

ran at build time
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.

namedtuple: the readable middle ground

ran at build time
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 a namedtuple or 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

Building, reading and removing

ran at build time
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.

Four ways to ask, meaning four different things

ran at build time
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

Brackets insist the key exists

raises KeyError
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

Keys by default; values and items on request

ran at build time
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.

Insertion order, preserved

ran at build time
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.

A list cannot be a key

raises TypeError
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')

The hashable types you will actually use as keys

ran at build time
# 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.

1, True and 1.0 are one key

ran at build time
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

Merge, comprehension, invert

ran at build time
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.

Three ways to count, and the one to use

ran at build time
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.

Reading a defaultdict has a side effect

ran at build time
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; get when absence is normal; setdefault when 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, True and 1.0 are all the same key.
  • Counter for counting; defaultdict for 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

Creating sets, and the one piece of missing syntax

ran at build time
# 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.

The same question, two containers, measured

ran at build time
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

Union, intersection, difference

ran at build time
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.

A permission check, in two lines

ran at build time
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.

Editing a set

ran at build time
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

A list cannot be a set member

raises TypeError
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.

frozenset, for when the set itself must be a key

ran at build time
# 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.

Deduplicating with and without keeping order

ran at build time
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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.