How Python runs all 3 written
The bytecode your code compiles to, how memory and references really behave, and measuring instead of guessing.
44 Bytecode: what your source compiles to
Python does not run your source. It compiles it to a sequence of small instructions and runs those. You can read them, and doing so answers questions that guessing cannot.
Three stages
Source text becomes a syntax tree, the tree becomes a code object full of instructions, and the interpreter executes those. All three are available to you.
import ast, dis
source = "total = a + 1"
# 1. Source -> tree. This is the stage that raises SyntaxError.
tree = ast.parse(source)
print("the tree:")
print(ast.dump(tree, indent=2))
# 2. Tree -> code object.
code = compile(tree, "<string>", "exec")
print()
print("names it refers to:", code.co_names)
print("constants it holds:", code.co_consts)
# 3. Code object -> instructions.
print()
dis.dis(code)
kinds = {type(node).__name__ for node in ast.walk(tree)}
test("the tree contains an assignment and an addition", {"Assign", "BinOp"} <= kinds, True)
test("both names ended up in co_names", set(code.co_names), {"a", "total"})
test("and the literal 1 in co_consts", 1 in code.co_consts, True)
What it printed, when the build ran it
the tree:
Module(
body=[
Assign(
targets=[
Name(id='total', ctx=Store())],
value=BinOp(
left=Name(id='a', ctx=Load()),
op=Add(),
right=Constant(value=1)))])
names it refers to: ('a', 'total')
constants it holds: (1, None)
0 RESUME 0
1 LOAD_NAME 0 (a)
LOAD_SMALL_INT 1
BINARY_OP 0 (+)
STORE_NAME 1 (total)
LOAD_CONST 1 (None)
RETURN_VALUE
ok the tree contains an assignment and an addition
ok both names ended up in co_names
ok and the literal 1 in co_consts
Reading a disassembly
Four columns: the source line, the instruction, its numeric argument, and what that argument means in parentheses. The stack is implicit — each instruction pushes or pops values.
import dis, sys
def rate(amount, percent):
return amount * percent / 100
print("this is CPython", ".".join(map(str, sys.version_info[:3])), "- the names below are its names")
print()
dis.dis(rate)
print()
for ins in dis.get_instructions(rate):
print(f" {ins.opname:<34} argval={ins.argval!r}")
names = [i.opname for i in dis.get_instructions(rate)]
test("it starts with RESUME and ends with RETURN_VALUE", (names[0], names[-1]), ("RESUME", "RETURN_VALUE"))
test("the two arguments are loaded", any("LOAD_FAST" in n for n in names), True)
test("100 is a small integer, loaded directly", "LOAD_SMALL_INT" in names, True)
test("and there are two arithmetic operations", names.count("BINARY_OP"), 2)
What it printed, when the build ran it
this is CPython 3.14.7 - the names below are its names
3 RESUME 0
4 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (amount, percent)
BINARY_OP 5 (*)
LOAD_SMALL_INT 100
BINARY_OP 11 (/)
RETURN_VALUE
RESUME argval=0
LOAD_FAST_BORROW_LOAD_FAST_BORROW argval=('amount', 'percent')
BINARY_OP argval=5
LOAD_SMALL_INT argval=100
BINARY_OP argval=11
RETURN_VALUE argval=None
ok it starts with RESUME and ends with RETURN_VALUE
ok the two arguments are loaded
ok 100 is a small integer, loaded directly
ok and there are two arithmetic operations
Opcode names change between versions, and these are 3.14's.
LOAD_SMALL_INT and LOAD_FAST_BORROW did not exist a few releases ago;
LOAD_CONST did the first job and LOAD_FAST the second. Any tutorial you read on
bytecode is pinned to the version that produced it. Run dis on your own interpreter rather
than trusting a printed listing — including this one.
What the compiler already did for you
Arithmetic on literals is computed once, at compile time, and the result stored as a constant. The runtime never does that multiplication.
import dis
code = compile("x = 2 ** 10", "<string>", "exec")
print("source: x = 2 ** 10")
dis.dis(code)
print("co_consts:", code.co_consts)
print()
print("1024 is in there as a constant. No exponentiation happens when this runs.")
names = [i.opname for i in dis.get_instructions(code)]
test("the result was folded in", 1024 in code.co_consts, True)
test("and there is no BINARY_OP left to do", "BINARY_OP" in names, False)
What it printed, when the build ran it
source: x = 2 ** 10
0 RESUME 0
1 LOAD_CONST 2 (1024)
STORE_NAME 0 (x)
LOAD_CONST 1 (None)
RETURN_VALUE
co_consts: (2, None, 1024)
1024 is in there as a constant. No exponentiation happens when this runs.
ok the result was folded in
ok and there is no BINARY_OP left to do
This is also the answer to a puzzle from lesson 4, where
1000 is 1000 came out True even though 1000 is far outside the small-integer
cache. Both literals are the same entry in co_consts, so both names point at one
object.
import dis
code = compile("a = 1000\nb = 1000\nsame = a is b", "<string>", "exec")
print("co_consts:", code.co_consts)
print("1000 appears once, not twice.")
print()
# Which is why this is True, and why it is not something to rely on.
namespace = {}
exec(code, namespace)
print("a is b ->", namespace["same"])
# Build one at runtime instead and the sharing disappears.
thousand = 1000
runtime = int("1000")
print("int(\"1000\") is the literal 1000 ->", runtime is thousand)
test("the constant was stored once", code.co_consts.count(1000), 1)
test("so the two literals are one object", namespace["same"], True)
test("but a value computed at runtime is a different object", runtime is thousand, False)
test("while still being equal", runtime == thousand, True)
What it printed, when the build ran it
co_consts: (1000, None)
1000 appears once, not twice.
a is b -> True
int("1000") is the literal 1000 -> False
ok the constant was stored once
ok so the two literals are one object
ok but a value computed at runtime is a different object
ok while still being equal
The code object
Every function carries one. It holds the instructions plus everything they need to refer to.
def greet(name, greeting="hi", *rest, loud=False, **extra):
local = 1
return greeting
c = greet.__code__
print("co_name: ", c.co_name)
print("co_argcount: ", c.co_argcount, " (positional, not counting *rest)")
print("co_kwonlyargcount: ", c.co_kwonlyargcount)
print("co_varnames: ", c.co_varnames)
print("co_consts: ", c.co_consts)
print("co_stacksize: ", c.co_stacksize, " (deepest the value stack gets)")
print("co_code: ", f"{len(c.co_code)} raw bytes of {type(c.co_code).__name__}")
print("takes *args? ", bool(c.co_flags & 0x04))
print("takes **kwargs? ", bool(c.co_flags & 0x08))
test("two positional parameters", c.co_argcount, 2)
test("one keyword-only parameter", c.co_kwonlyargcount, 1)
test("locals and parameters share one tuple", "local" in c.co_varnames, True)
test("in declaration order, with *args and **kwargs after the keyword-only ones",
c.co_varnames, ("name", "greeting", "loud", "rest", "extra", "local"))
test("the instructions themselves are bytes", isinstance(c.co_code, bytes), True)
What it printed, when the build ran it
co_name: greet
co_argcount: 2 (positional, not counting *rest)
co_kwonlyargcount: 1
co_varnames: ('name', 'greeting', 'loud', 'rest', 'extra', 'local')
co_consts: (1,)
co_stacksize: 1 (deepest the value stack gets)
co_code: 10 raw bytes of bytes
takes *args? True
takes **kwargs? True
ok two positional parameters
ok one keyword-only parameter
ok locals and parameters share one tuple
ok in declaration order, with *args and **kwargs after the keyword-only ones
ok the instructions themselves are bytes
try is free until it fires
Setting up a try block used to cost an instruction on every pass. Since 3.11 it costs
nothing: the handler's location lives in a side table, consulted only when an exception is actually
raised.
import dis
def guarded(x):
try:
return 10 / x
except ZeroDivisionError:
return None
names = [i.opname for i in dis.get_instructions(guarded)]
print("instructions on the happy path, in order:")
print(" ", names[:6])
print()
print("nothing there sets up the try. The handler is here instead:")
print(" ", guarded.__code__.co_exceptiontable.hex())
print()
print("guarded(5) ->", guarded(5))
print("guarded(0) ->", guarded(0))
test("there is no SETUP_FINALLY or SETUP_EXCEPT instruction",
any(n.startswith("SETUP_") for n in names), False)
test("the exception table is not empty", len(guarded.__code__.co_exceptiontable) > 0, True)
test("and the handler still works", guarded(0), None)
What it printed, when the build ran it
instructions on the happy path, in order: ['RESUME', 'NOP', 'LOAD_SMALL_INT', 'LOAD_FAST_BORROW', 'BINARY_OP', 'RETURN_VALUE'] nothing there sets up the try. The handler is here instead: 82080b008b0b1a0399011a03 guarded(5) -> 2.0 guarded(0) -> None ok there is no SETUP_FINALLY or SETUP_EXCEPT instruction ok the exception table is not empty ok and the handler still works
Which settles an old argument. "Don't use try/except in a hot loop, it's slow" was
true once. Now a try that does not fire costs nothing at all, and asking forgiveness rather
than permission is both the idiomatic choice and the fast one.
The interpreter rewrites itself as it runs
The instructions above are what the compiler emitted. They are not necessarily what executes. After a
function has been called a few times, CPython replaces general instructions with specialised ones based on
the types it actually saw. adaptive=True shows the current state.
import dis
def add(a, b):
return a + b
cold = [i.opname for i in dis.get_instructions(add, adaptive=True)]
print("straight after definition:")
print(" ", cold)
for _ in range(100):
add(1, 2) # always ints
warm = [i.opname for i in dis.get_instructions(add, adaptive=True)]
print()
print("after 100 calls with integers:")
print(" ", warm)
print()
dis.dis(add, adaptive=True)
test("it specialised", cold != warm, True)
test("the generic add was replaced by an integer add", any("BINARY_OP_ADD_INT" in n for n in warm), True)
test("and it still computes the same thing", add(2, 3), 5)
What it printed, when the build ran it
straight after definition:
['RESUME', 'LOAD_FAST_BORROW_LOAD_FAST_BORROW', 'BINARY_OP', 'RETURN_VALUE']
after 100 calls with integers:
['RESUME_CHECK', 'LOAD_FAST_BORROW_LOAD_FAST_BORROW', 'BINARY_OP_ADD_INT', 'RETURN_VALUE']
3 RESUME_CHECK 0
4 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
BINARY_OP_ADD_INT 0 (+)
RETURN_VALUE
ok it specialised
ok the generic add was replaced by an integer add
ok and it still computes the same thing
Feed the same function floats or strings and it re-specialises, or gives up and goes back to the general instruction. This is why micro-benchmarks on a cold function mislead, and why the next lesson insists on measuring rather than reasoning.
Comprehensions are not function calls any more
A list comprehension used to compile to a hidden nested function, called once. Since 3.12 it is inlined into the enclosing code object — which is why comprehension variables no longer leak and why the disassembly looks like a loop.
import dis
def make(xs):
return [x * 2 for x in xs]
nested = [c for c in make.__code__.co_consts if hasattr(c, "co_name")]
print("nested code objects inside make:", nested)
print()
names = [i.opname for i in dis.get_instructions(make)]
print("the comprehension's own loop, inlined:")
print(" ", [n for n in names if n in {"GET_ITER", "FOR_ITER", "LIST_APPEND", "JUMP_BACKWARD"}])
print()
print("and no CALL, because nothing is called:")
print(" ", "CALL" in names)
test("there is no hidden function to call", nested, [])
test("the loop is right there in make", "FOR_ITER" in names, True)
test("nothing is invoked", any(n.startswith("CALL") for n in names), False)
test("the comprehension variable does not escape", "x" not in globals(), True)
What it printed, when the build ran it
nested code objects inside make: [] the comprehension's own loop, inlined: ['GET_ITER', 'FOR_ITER', 'LIST_APPEND', 'JUMP_BACKWARD'] and no CALL, because nothing is called: False ok there is no hidden function to call ok the loop is right there in make ok nothing is invoked ok the comprehension variable does not escape
Bytecode on disk: __pycache__
The compile step is cached. The first import of a module writes a .pyc beside it; later runs
skip straight to the instructions. It is a cache, not a distribution format — and it is tied to the exact
interpreter that wrote it.
import importlib.util, py_compile, sys
from pathlib import Path
Path("mod.py").write_text("VALUE = 1 + 2\n", encoding="utf-8")
py_compile.compile("mod.py", cfile="mod.pyc", doraise=True)
raw = Path("mod.pyc").read_bytes()
print("the name Python would choose:", Path(importlib.util.cache_from_source("mod.py")).name)
print("this interpreter's cache tag: ", sys.implementation.cache_tag)
print("first four bytes: ", raw[:4].hex(), "(the magic number)")
print("bytes in total: ", len(raw))
test("the file starts with this build's magic number", raw[:4], importlib.util.MAGIC_NUMBER)
test("and its name carries the version", sys.implementation.cache_tag in
Path(importlib.util.cache_from_source("mod.py")).name, True)
What it printed, when the build ran it
the name Python would choose: mod.cpython-314.pyc this interpreter's cache tag: cpython-314 first four bytes: 2b0e0d0a (the magic number) bytes in total: 123 ok the file starts with this build's magic number ok and its name carries the version
Never ship .pyc files as your source of truth. The magic number changes
every feature release, so a .pyc built by 3.14 is unreadable to 3.13 and vice versa. Add
__pycache__/ to .gitignore and move on.
What this is and is not good for
Reading bytecode explains semantics — what your code means, in what order, and what the compiler
already did. It is a poor predictor of speed. A comprehension compiles to more instructions than
the equivalent append loop and still runs faster, because the instructions are not equal in
cost.
import dis, timeit
def with_append(n):
out = []
for i in range(n):
out.append(i * 2)
return out
def with_comprehension(n):
return [i * 2 for i in range(n)]
append_count = len(list(dis.get_instructions(with_append)))
comp_count = len(list(dis.get_instructions(with_comprehension)))
append_time = timeit.timeit(lambda: with_append(2000), number=200)
comp_time = timeit.timeit(lambda: with_comprehension(2000), number=200)
print(f"append loop: {append_count} instructions, {append_time * 1000:.1f} ms")
print(f"comprehension: {comp_count} instructions, {comp_time * 1000:.1f} ms")
print()
print("more instructions, less time. Counting them predicts nothing.")
test("the comprehension has more instructions", comp_count > append_count, True)
test("and is still faster", comp_time < append_time, True)
test("both produce the same list", with_append(5), with_comprehension(5))
What it printed, when the build ran it
append loop: 21 instructions, 22.5 ms comprehension: 25 instructions, 19.0 ms more instructions, less time. Counting them predicts nothing. ok the comprehension has more instructions ok and is still faster ok both produce the same list
# Useful from a terminal, where there is no build to run it for you.
python3 -m dis script.py # disassemble a whole file
python3 -c "import dis; dis.dis('a + 1')"
python3 -m ast -m exec script.py # dump the syntax tree
# And the two environment variables worth knowing:
PYTHONDONTWRITEBYTECODE=1 python3 app.py # no __pycache__
python3 -X importtime app.py # where import time goes
Shell commands rather than Python, so there is nothing in this page that could execute them. Each one is the command-line form of something a panel above did through the same modules.
- Source becomes a tree, the tree becomes a code object, the code object is a list of
instructions.
ast,compileanddisexpose all three. - Opcode names are specific to the interpreter version. Disassemble yours.
- Literal arithmetic is folded at compile time, and equal literals share one constant — which is what
made
1000 is 1000true. - A
tryblock that does not fire costs nothing since 3.11. The old advice to avoid it in loops is obsolete. - The interpreter specialises instructions as it observes types. Cold code and warm code run differently.
- Comprehensions are inlined, not nested functions, since 3.12.
- Bytecode explains meaning, not speed. For speed, measure — two lessons on.
45 References, mutability and copying
A name in Python does not contain a value; it points at one. Almost every surprise about mutation, copying and identity follows from that single sentence, and this lesson makes it visible.
Names point, objects live
id() is the object's address. Two names with the same id are two labels on one
object, and sys.getrefcount counts how many labels there are.
import sys
original = [1, 2, 3]
alias = original # a second label, not a second list
shared = original is alias
print("same object?", shared)
print("same id? ", id(original) == id(alias))
print("references: ", sys.getrefcount(original), "(one of those is getrefcount's own argument)")
alias.append(4)
print()
print("appended through alias, and original says:", original)
# Rebinding a name moves the label. It does not touch the object.
alias = [9]
print()
print("after alias = [9]:")
print(" original:", original)
print(" alias: ", alias)
print(" still one object?", original is alias)
test("assignment created a second name for one object", shared, True)
test("mutation through either name is visible through both", original, [1, 2, 3, 4])
test("rebinding only moves the label", alias, [9])
test("after which they are different objects", original is alias, False)
What it printed, when the build ran it
same object? True same id? True references: 3 (one of those is getrefcount's own argument) appended through alias, and original says: [1, 2, 3, 4] after alias = [9]: original: [1, 2, 3, 4] alias: [9] still one object? False ok assignment created a second name for one object ok mutation through either name is visible through both ok rebinding only moves the label ok after which they are different objects
del follows the same rule: it removes a name, and the object goes only when the last name
does.
import sys
data = [1, 2, 3]
one_name = sys.getrefcount(data)
print("refs:", one_name)
keep = data
two_names = sys.getrefcount(data)
print("after keep = data:", two_names)
del keep
after_del = sys.getrefcount(data)
print("after del keep: ", after_del)
print("the list is still here:", data)
test("a second name raised the count by one", two_names - one_name, 1)
test("and del put it back", after_del, one_name)
test("without destroying the object", data, [1, 2, 3])
What it printed, when the build ran it
refs: 2 after keep = data: 3 after del keep: 2 the list is still here: [1, 2, 3] ok a second name raised the count by one ok and del put it back ok without destroying the object
The counts from getrefcount are always one higher than you expect.
Passing the object to getrefcount is itself a reference. Use it to watch the number
change, never as an absolute.
Copying, and the layer it stops at
copy.copy makes a new outer object whose items are the same objects. That is fine
until one of those items is mutable.
import copy
order = {"id": 1, "items": ["book"], "address": {"city": "Lagos"}}
shallow = copy.copy(order)
deep = copy.deepcopy(order)
shallow["items"].append("pen") # reaches into the original
shallow["id"] = 99 # does not: rebinding a key
print("original:", order)
print("shallow: ", shallow)
print("deep: ", deep)
test("the shallow copy shares the inner list", order["items"], ["book", "pen"])
test("the deep copy did not", deep["items"], ["book"])
test("replacing a top-level key affects only the copy", order["id"], 1)
test("shared inner objects are the same object", order["address"] is shallow["address"], True)
test("but not in the deep copy", order["address"] is deep["address"], False)
What it printed, when the build ran it
original: {'id': 1, 'items': ['book', 'pen'], 'address': {'city': 'Lagos'}}
shallow: {'id': 99, 'items': ['book', 'pen'], 'address': {'city': 'Lagos'}}
deep: {'id': 1, 'items': ['book'], 'address': {'city': 'Lagos'}}
ok the shallow copy shares the inner list
ok the deep copy did not
ok replacing a top-level key affects only the copy
ok shared inner objects are the same object
ok but not in the deep copy
The slicing and constructor idioms — xs[:], list(xs), dict(d) —
are all shallow copies. There is no short syntax for a deep one, deliberately: it is expensive and usually
not what you want.
import copy
grid = [[0, 0], [0, 0]]
sliced = grid[:]
constructed = list(grid)
deep = copy.deepcopy(grid)
sliced[0][0] = 1 # mutates a row shared with grid
print("grid after mutating sliced[0][0]:", grid)
print("constructed sees it too: ", constructed)
print("deep does not: ", deep)
test("a slice copies the outer list only", grid, [[1, 0], [0, 0]])
test("list() is the same kind of copy", constructed[0] is grid[0], True)
test("deepcopy copied the rows as well", deep[0] is grid[0], False)
What it printed, when the build ran it
grid after mutating sliced[0][0]: [[1, 0], [0, 0]] constructed sees it too: [[1, 0], [0, 0]] deep does not: [[0, 0], [0, 0]] ok a slice copies the outer list only ok list() is the same kind of copy ok deepcopy copied the rows as well
Identity is not something to depend on
Equal values are sometimes one object and sometimes not, and which happens is an implementation detail of the compiler. The previous lesson showed why.
import sys
literal_a = (1, 2, 3)
literal_b = (1, 2, 3)
built = tuple([1, 2, 3])
print("two identical tuple literals:", literal_a is literal_b)
print("one built at runtime: ", literal_a is built)
print("equal either way: ", literal_a == built)
print("interchangeable as dict keys:", len({literal_a: "x", built: "y"}))
words = "hello"
same_literal = "hello"
joined = "".join(["h", "e", "l", "l", "o"])
print()
print("two string literals: ", words is same_literal)
print("a string built at runtime: ", words is joined)
print("the same string, interned: ", sys.intern(joined) is words)
test("equal values may or may not be one object", literal_a is built, False)
test("two string literals are shared", words is same_literal, True)
test("a runtime-built string is not", words is joined, False)
test("equality is the thing that is reliable", literal_a == built, True)
test("hashing follows equality, not identity", hash(literal_a) == hash(built), True)
test("so they are the same dict key", len({literal_a: "x", built: "y"}), 1)
What it printed, when the build ran it
two identical tuple literals: True one built at runtime: False equal either way: True interchangeable as dict keys: 1 two string literals: True a string built at runtime: False the same string, interned: True ok equal values may or may not be one object ok two string literals are shared ok a runtime-built string is not ok equality is the thing that is reliable ok hashing follows equality, not identity ok so they are the same dict key
Use is only for None, True, False and
sentinels you created yourself. Everything else: ==. Python will even warn you —
x is 1000 produces a SyntaxWarning telling you that you probably meant
==.
Measuring what an object costs
sys.getsizeof is shallow. For a container it reports the container, not the things in it,
which for anything interesting is almost all of the memory.
import sys, tracemalloc
# Every element built at runtime, so every element is a distinct object.
rows = [f"row-{i}-" + "x" * 1000 for i in range(1000)]
print("getsizeof(the list):", sys.getsizeof(rows), "bytes")
print("getsizeof(one row): ", sys.getsizeof(rows[0]), "bytes")
print()
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
again = [f"row-{i}-" + "y" * 1000 for i in range(1000)]
real = tracemalloc.get_traced_memory()[0] - base
tracemalloc.stop()
print("what tracemalloc says the same data costs:", round(real / 1024), "KiB")
print("what getsizeof would have told you: ", round(sys.getsizeof(again) / 1024), "KiB")
print("out by a factor of", round(real / sys.getsizeof(again)))
test("the list itself is small", sys.getsizeof(rows) < 20_000, True)
test("while the data it refers to is not", real > 1_000_000, True)
test("getsizeof does not follow references", sys.getsizeof(again) < real / 10, True)
What it printed, when the build ran it
getsizeof(the list): 8856 bytes getsizeof(one row): 1047 bytes what tracemalloc says the same data costs: 1033 KiB what getsizeof would have told you: 9 KiB out by a factor of 119 ok the list itself is small ok while the data it refers to is not ok getsizeof does not follow references
And here is the trap that made a first draft of that panel measure nothing at all.
folded = ["b" * 1000 for _ in range(3)]
runtime = [str(i) + "b" * 999 for i in range(3)]
print("elements of the folded list, all the same object?", folded[0] is folded[1] is folded[2])
print("elements built at runtime? ", runtime[0] is runtime[1])
print("both lists hold three strings of length", len(folded[0]), "and", len(runtime[0]))
test('"b" * 1000 was computed at compile time, so one object is reused',
folded[0] is folded[2], True)
test("which means that list costs one string, not three", len(set(map(id, folded))), 1)
test("building the element at runtime gives three", len(set(map(id, runtime))), 3)
What it printed, when the build ran it
elements of the folded list, all the same object? True elements built at runtime? False both lists hold three strings of length 1000 and 1000 ok "b" * 1000 was computed at compile time, so one object is reused ok which means that list costs one string, not three ok building the element at runtime gives three
__slots__: dropping the per-instance dict
An ordinary instance keeps its attributes in a dictionary, which is flexible and costs a few hundred
bytes each. __slots__ declares the attributes up front and stores them in a fixed array
instead.
import tracemalloc
class Plain:
def __init__(self, x, y):
self.x, self.y = x, y
class Slotted:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
def cost(cls, n=20_000):
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
objects = [cls(i, i) for i in range(n)]
used = tracemalloc.get_traced_memory()[0] - base
tracemalloc.stop()
del objects
return used
plain, slotted = cost(Plain), cost(Slotted)
print("20,000 plain instances: ", round(plain / 1024), "KiB")
print("20,000 slotted instances:", round(slotted / 1024), "KiB")
print("saved:", round(100 * (1 - slotted / plain)), "%")
print()
print("a plain instance has a __dict__: ", hasattr(Plain(1, 2), "__dict__"))
print("a slotted one does not: ", hasattr(Slotted(1, 2), "__dict__"))
test("slots really used less", slotted < plain, True)
test("the saving is substantial", slotted < plain * 0.85, True)
test("and the dict is gone", hasattr(Slotted(1, 2), "__dict__"), False)
What it printed, when the build ran it
20,000 plain instances: 2507 KiB 20,000 slotted instances: 1720 KiB saved: 31 % a plain instance has a __dict__: True a slotted one does not: False ok slots really used less ok the saving is substantial ok and the dict is gone
The cost is that the class is closed: an attribute you did not declare cannot be set.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
p.z = 3
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 7, in <module>
p.z = 3
^^^
AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes
Do not reach for __slots__ by default. It matters when you have hundreds
of thousands of small objects. Below that, the flexibility is worth more than a third of a few hundred
kilobytes — and a dataclass takes slots=True if you decide you need it.
What frees your objects
Mostly reference counting: when the last reference goes, the object is destroyed immediately. The garbage collector exists for the case reference counting cannot handle — objects that refer to each other.
import gc
freed = []
class Node:
def __init__(self, name):
self.name = name
self.peer = None
def __del__(self):
freed.append(self.name)
# No cycle: the last reference goes, and it is freed on the spot.
solo = Node("solo")
del solo
print("after del solo, freed:", freed)
# A cycle: each refers to the other, so neither count reaches zero.
a, b = Node("a"), Node("b")
a.peer, b.peer = b, a
del a, b
print("after del a, b: ", freed, "-- still alive, despite no way to reach them")
collected = gc.collect()
print("after gc.collect(): ", sorted(freed))
print()
print("gc thresholds on this build:", gc.get_threshold())
test("refcounting freed the un-cycled object immediately", "solo" in freed, True)
test("the cycle needed the collector", collected >= 2, True)
test("which then freed both", sorted(freed), ["a", "b", "solo"])
What it printed, when the build ran it
after del solo, freed: ['solo'] after del a, b: ['solo'] -- still alive, despite no way to reach them after gc.collect(): ['a', 'b', 'solo'] gc thresholds on this build: (2000, 10, 10) ok refcounting freed the un-cycled object immediately ok the cycle needed the collector ok which then freed both
Every article about this quotes (700, 10, 10). The panel above printed
what this interpreter actually reports, and the first threshold changed in 3.12. It is a small thing, and
it is the reason this course prints values instead of stating them.
Caches that do not leak: weakref
A normal dict keeps its values alive forever, which is how a cache becomes a memory leak. A weak reference lets the object go when nothing else wants it.
import gc, weakref
class Session:
def __init__(self, user):
self.user = user
strong = {}
weak = weakref.WeakValueDictionary()
for name in ("ada", "alan", "grace"):
session = Session(name)
strong[name] = session
weak[name] = session
print("both hold:", len(strong), len(weak))
strong.clear() # drop the strong dict's references
gc.collect()
after_clear = len(weak)
print("after clearing the strong dict:", after_clear)
print(" one survivor: the loop variable 'session' still names the last one")
del session # the last strong reference in the program
gc.collect()
print("after del session: ", len(weak))
print("the weak dict emptied itself, with nothing told to do it")
test("the strong cache is empty because we emptied it", len(strong), 0)
test("a single surviving reference keeps one entry alive", after_clear, 1)
test("and when that goes, so does the entry", len(weak), 0)
What it printed, when the build ran it
both hold: 3 3 after clearing the strong dict: 1 one survivor: the loop variable 'session' still names the last one after del session: 0 the weak dict emptied itself, with nothing told to do it ok the strong cache is empty because we emptied it ok a single surviving reference keeps one entry alive ok and when that goes, so does the entry
One more mutation hazard
Changing a container while looping over it. A dict raises; a list does not, and quietly skips items.
numbers = [1, 2, 3, 4, 5, 6]
visited = []
for n in numbers:
visited.append(n)
if n % 2 == 0:
numbers.remove(n) # the loop's internal index does not know
print("the loop visited:", visited)
print("the list is now: ", numbers)
print("3 and 5 were never visited, and nothing complained.")
mapping = {"a": 1, "b": 2}
try:
for key in mapping:
mapping[key + "!"] = 0
complaint = None
except RuntimeError as exc:
complaint = str(exc)
print()
print("a dict does complain:", complaint)
test("the list loop skipped items", visited, [1, 2, 4, 6])
test("which is not the whole list", set(visited) != {1, 2, 3, 4, 5, 6}, True)
test("a dict refuses instead", complaint, "dictionary changed size during iteration")
What it printed, when the build ran it
the loop visited: [1, 2, 4, 6] the list is now: [1, 3, 5] 3 and 5 were never visited, and nothing complained. a dict does complain: dictionary changed size during iteration ok the list loop skipped items ok which is not the whole list ok a dict refuses instead
The fix for both is the same: iterate over a copy, or build a new container. A comprehension does the second for free.
numbers = [1, 2, 3, 4, 5, 6]
# Build a new list rather than editing the one you are reading.
odds = [n for n in numbers if n % 2]
# Or, if it must be in place, iterate over a copy.
in_place = [1, 2, 3, 4, 5, 6]
for n in list(in_place):
if n % 2 == 0:
in_place.remove(n)
print("comprehension:", odds)
print("copy then edit:", in_place)
print("original untouched:", numbers)
test("the comprehension is correct and leaves the source alone", odds, [1, 3, 5])
test("and iterating a copy is correct too", in_place, [1, 3, 5])
test("the source list is unchanged", numbers, [1, 2, 3, 4, 5, 6])
What it printed, when the build ran it
comprehension: [1, 3, 5] copy then edit: [1, 3, 5] original untouched: [1, 2, 3, 4, 5, 6] ok the comprehension is correct and leaves the source alone ok and iterating a copy is correct too ok the source list is unchanged
- Names point at objects. Assignment adds a label;
delremoves one; mutation is visible through every label. - Every short copy — slice,
list(),dict(),copy.copy— is shallow.copy.deepcopywhen the contents are mutable too. isasks about identity, and identity is not guaranteed for equal values. Use it forNoneand sentinels only.sys.getsizeofdoes not follow references. Usetracemallocfor a real figure.__slots__removes the per-instance dict and closes the class. Worth it in bulk, not by default.- Reference counting frees most things instantly; the collector exists for cycles. Print
gc.get_threshold()rather than believing an article about it. weakrefis how a cache avoids becoming a leak.- Never mutate a container you are iterating. Build a new one, or iterate a copy.
46 Measuring speed instead of guessing at it
Every claim about speed in this course came with a number, because performance intuition is unusually bad in this language: the interpreter rewrites your code as it runs, and two lines that look identical can differ by a factor of a thousand. This is how to find out.
Two clocks, and which one you want
import time
start_wall, start_cpu = time.perf_counter(), time.process_time()
time.sleep(0.1) # waiting, not working
total = sum(i * i for i in range(200_000)) # working
wall = time.perf_counter() - start_wall
cpu = time.process_time() - start_cpu
print(f"perf_counter (wall clock): {wall:.3f}s")
print(f"process_time (cpu only): {cpu:.3f}s")
print(f"time spent waiting: {wall - cpu:.3f}s")
test("the wall clock saw the sleep", wall > 0.1, True)
test("the cpu clock did not", cpu < wall, True)
test("the gap is roughly the sleep", 0.08 < wall - cpu < 0.2, True)
What it printed, when the build ran it
perf_counter (wall clock): 0.123s process_time (cpu only): 0.019s time spent waiting: 0.105s ok the wall clock saw the sleep ok the cpu clock did not ok the gap is roughly the sleep
perf_counter is the one to reach for: it measures elapsed time, which is what a user
experiences. process_time is useful for one specific question — "is this slow because it is
working, or because it is waiting?" — and that question decides whether
Part 13 is relevant to you.
Never use time.time() for durations. It is the wall calendar, not a
stopwatch: it can jump backwards when the clock is corrected, and produce negative elapsed times.
perf_counter is monotonic and exists for this.
One measurement is not a measurement
timeit runs the code many times and gives you a distribution. The number to quote is the
minimum — noise only ever makes things slower, so the fastest run is the closest to the truth.
import timeit
setup = "import random; random.seed(0); data = [random.random() for _ in range(1000)]"
runs = timeit.repeat("sorted(data)", setup=setup, number=100, repeat=5)
print("five batches of 100 sorts each:")
for r in runs:
print(f" {r * 1000:6.2f} ms")
print()
print(f"min: {min(runs) * 1000:.2f} ms <- quote this")
print(f"max: {max(runs) * 1000:.2f} ms <- {max(runs) / min(runs):.2f}x the min, and that is noise")
test("five measurements were taken", len(runs), 5)
test("they are not all identical", len(set(runs)) > 1, True)
test("and the spread is noise, not signal", max(runs) / min(runs) < 3, True)
What it printed, when the build ran it
five batches of 100 sorts each:
9.06 ms
8.49 ms
8.82 ms
8.35 ms
8.42 ms
min: 8.35 ms <- quote this
max: 9.06 ms <- 1.09x the min, and that is noise
ok five measurements were taken
ok they are not all identical
ok and the spread is noise, not signal
setup is not timed. Everything you need but do not want to measure —
imports, building the input — goes there. If the data were built inside the timed statement you would be
measuring that instead, which is the most common way a benchmark ends up answering a different question
than the one asked.
Profiling: finding out where the time went
timeit compares two things you already suspect. cProfile tells you what to
suspect, by counting every call and how long each took.
import cProfile, io, pstats
def parse(line):
return line.strip().split(",")
def total(rows):
return sum(int(row[1]) for row in rows)
def report(lines):
rows = [parse(line) for line in lines]
return total(rows)
lines = [f"row{i},{i}" for i in range(20_000)]
profiler = cProfile.Profile()
profiler.enable()
answer = report(lines)
profiler.disable()
buffer = io.StringIO()
stats = pstats.Stats(profiler, stream=buffer).sort_stats("tottime")
stats.print_stats(5)
print("\n".join(buffer.getvalue().splitlines()[2:]))
by_name = {key[2]: value for key, value in stats.stats.items()}
test("the report is correct", answer, sum(range(20_000)))
test("parse was called once per line", by_name["parse"][0], 20_000)
test("and report only once", by_name["report"][0], 1)
What it printed, when the build ran it
Ordered by: internal time
List reduced from 8 to 5 due to restriction <5>
ncalls tottime percall cumtime percall filename:lineno(function)
20000 0.013 0.000 0.023 0.000 example.py:3(parse)
20000 0.008 0.000 0.008 0.000 {method 'split' of 'str' objects}
20001 0.005 0.000 0.005 0.000 example.py:7(<genexpr>)
1 0.004 0.004 0.036 0.036 example.py:9(report)
1 0.003 0.003 0.008 0.008 {built-in method builtins.sum}
ok the report is correct
ok parse was called once per line
ok and report only once
Two columns matter. tottime is time spent inside that function, excluding what it
called — that is where the work is. cumtime includes everything it called, which tells you
which branch of your program to follow down. Sorting by tottime finds the hotspot; sorting by
cumtime finds the path to it.
The loop that is worth learning
Profile, find the one slow thing, fix that, measure again. Here is the commonest finding in real code — a membership test against a list, inside a loop.
import time
known = [f"user{i}" for i in range(5000)]
wanted = [f"user{i}" for i in range(0, 5000, 5)]
def check_slow(names, wanted):
return [w for w in wanted if w in names] # scans the list every time
def check_fast(names, wanted):
lookup = set(names) # hashed once
return [w for w in wanted if w in lookup]
start = time.perf_counter(); slow_result = check_slow(known, wanted)
slow = time.perf_counter() - start
start = time.perf_counter(); fast_result = check_fast(known, wanted)
fast = time.perf_counter() - start
print(f"membership in a list: {slow * 1000:7.1f} ms")
print(f"membership in a set: {fast * 1000:7.1f} ms")
print(f"speedup: {slow / fast:.0f}x")
print()
print("one line changed. Nothing was rewritten in C, and nothing was made harder to read.")
test("both give the same answer", fast_result, slow_result)
test("and the set version is far faster", slow / fast > 5, True)
What it printed, when the build ran it
membership in a list: 46.3 ms membership in a set: 0.7 ms speedup: 62x one line changed. Nothing was rewritten in C, and nothing was made harder to read. ok both give the same answer ok and the set version is far faster
That is an algorithmic change — in on a list is a scan, on a set it is a hash lookup — and
algorithmic changes are the only ones that keep paying as your data grows. Compare it to the kind of
micro-optimisation people reach for first.
import timeit
setup = "data = list(range(1000))"
styles = {
"list comprehension": "[x * 2 for x in data]",
"map with a lambda": "list(map(lambda x: x * 2, data))",
"explicit append": "out = []\nfor x in data:\n out.append(x * 2)",
}
results = {}
for label, code in styles.items():
results[label] = min(timeit.repeat(code, setup=setup, number=200, repeat=3))
slowest = max(results.values())
fastest = min(results.values())
for label, seconds in sorted(results.items(), key=lambda kv: kv[1]):
print(f" {label:<20} {seconds * 1000:6.1f} ms")
print()
print(f"best to worst: {slowest / fastest:.1f}x")
print("against the 50x above. This is not where your time is.")
test("all three styles were measured", len(results), 3)
test("and the spread between them is small", slowest / fastest < 5, True)
What it printed, when the build ran it
list comprehension 6.9 ms explicit append 7.0 ms map with a lambda 12.7 ms best to worst: 1.9x against the 50x above. This is not where your time is. ok all three styles were measured ok and the spread between them is small
The advice everyone repeats, tested
"Never build a string with += in a loop — it is quadratic." Here is that loop, timed at
three sizes.
import time
def plain(n):
out = ""
for _ in range(n):
out += "abcdefghij"
return len(out)
def holding_a_reference(n):
out = ""
history = []
for _ in range(n):
history.append(out) # a second name for the string being built
out += "abcdefghij"
return len(out)
def joined(n):
parts = ["abcdefghij"] * n # the parts, as you would really have them
return len("".join(parts))
def elapsed(fn, n, repeat=3):
# Best of three, for the reason the panel above gave.
def once():
start = time.perf_counter()
fn(n)
return time.perf_counter() - start
return min(once() for _ in range(repeat))
print(f"{'n':>7} {'plain +=':>11} {'+= with a kept ref':>20} {'join':>10}")
for n in (5_000, 20_000):
print(f"{n:>7} {elapsed(plain, n) * 1000:>9.1f}ms "
f"{elapsed(holding_a_reference, n) * 1000:>18.1f}ms "
f"{elapsed(joined, n) * 1000:>8.2f}ms")
# Growth rate: quadruple the work and see what happens to the time. Different
# ranges, because the quadratic one cannot be run at the sizes the others can.
def growth(fn, small, large):
return elapsed(fn, large) / elapsed(fn, small)
plain_growth = growth(plain, 20_000, 80_000)
held_growth = growth(holding_a_reference, 2_500, 10_000)
print()
print(f"plain +=, n 20,000 -> 80,000 (4x the work): {plain_growth:5.1f}x the time -> linear")
print(f"kept a ref, n 2,500 -> 10,000 (4x the work): {held_growth:5.1f}x the time -> quadratic")
test("all three build the same string length", (plain(10), joined(10)), (100, 100))
test("plain += grew about as fast as the input", plain_growth < 8, True)
test("holding a reference made it grow with the square", held_growth > 8, True)
What it printed, when the build ran it
n plain += += with a kept ref join 5000 0.5ms 56.2ms 0.05ms 20000 2.0ms 1072.4ms 0.24ms plain +=, n 20,000 -> 80,000 (4x the work): 4.0x the time -> linear kept a ref, n 2,500 -> 10,000 (4x the work): 42.9x the time -> quadratic ok all three build the same string length ok plain += grew about as fast as the input ok holding a reference made it grow with the square
Plain += is linear here, because CPython grows the string's buffer in place when nothing
else refers to it. Add one more reference and the same loop becomes quadratic, because now every step has to
copy. The speed of that line depends on something that is not written on it.
This is the whole argument for measuring. The rule as usually stated is wrong on this
interpreter, and the reason it is wrong is an optimisation that can disappear. "".join(...)
is still the right way to build a string from parts — not because += is always slow, but
because join is fast in every case and does not depend on an invisible property of your
code.
When the data structure is the problem
Lesson 6 promised that list.pop(0) is the wrong shape and
that deque is the right one. Here is the number behind that promise.
import timeit
from_list = timeit.timeit("xs.pop(0)", setup="xs = list(range(20_000))", number=20_000)
from_deque = timeit.timeit("xs.popleft()",
setup="from collections import deque; xs = deque(range(20_000))",
number=20_000)
print(f"list.pop(0) {from_list * 1000:7.1f} ms")
print(f"deque.popleft() {from_deque * 1000:7.1f} ms")
print(f"ratio {from_list / from_deque:7.0f}x")
print()
print("pop(0) moves every remaining element up one. deque does not have to.")
test("the deque is much faster at the front", from_deque < from_list / 5, True)
What it printed, when the build ran it
list.pop(0) 25.1 ms deque.popleft() 0.7 ms ratio 34x pop(0) moves every remaining element up one. deque does not have to. ok the deque is much faster at the front
Measuring memory as well
import tracemalloc
def build_list(n):
return [i * i for i in range(n)]
def build_generator(n):
return (i * i for i in range(n))
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
as_list = build_list(200_000)
list_cost = tracemalloc.get_traced_memory()[0] - base
base = tracemalloc.get_traced_memory()[0]
as_gen = build_generator(200_000)
gen_cost = tracemalloc.get_traced_memory()[0] - base
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"a list of 200,000 squares: {list_cost / 1024 / 1024:.1f} MiB")
print(f"a generator of the same thing: {gen_cost} bytes")
print(f"peak while both existed: {peak / 1024 / 1024:.1f} MiB")
print()
print("the generator has not computed anything yet, and never holds more than one value.")
test("the list cost megabytes", list_cost > 1_000_000, True)
test("the generator cost almost nothing", gen_cost < 1000, True)
test("and both produce the same first value", as_list[0], next(as_gen))
What it printed, when the build ran it
a list of 200,000 squares: 7.7 MiB a generator of the same thing: 408 bytes peak while both existed: 7.7 MiB the generator has not computed anything yet, and never holds more than one value. ok the list cost megabytes ok the generator cost almost nothing ok and both produce the same first value
How to not waste your time
| Question | Tool |
|---|---|
| How long does this whole thing take? | time.perf_counter |
| Which of these two lines is faster? | timeit.repeat, quote the min |
| Where is the time going? | cProfile, sorted by tottime |
| Is it slow because it waits or because it works? | process_time against
perf_counter |
| Why is memory growing? | tracemalloc |
And the order of operations, which matters more than the tools: make it correct, prove it with tests, then measure. Only then change anything — and measure again, because a change you cannot show an improvement for is not an optimisation, it is just a different program.
# The same tools without writing any code around them.
python3 -m timeit -s "data = list(range(1000))" "[x * 2 for x in data]"
python3 -m cProfile -s tottime script.py # profile a whole program
python3 -m cProfile -o out.prof script.py # save it, then:
python3 -c "import pstats; pstats.Stats('out.prof').sort_stats('tottime').print_stats(10)"
python3 -X importtime script.py # what start-up costs, per import
python3 -X tracemalloc=10 script.py # tracebacks on allocations
Shell commands rather than Python. Each is the command-line form of a module used in the panels above, which did run.
perf_counterfor durations, nevertime.time().timeit.repeatand quote the minimum. Put anything you are not measuring insetup.cProfileanswers "where", not "how fast".tottimefor the hotspot,cumtimefor the path.- Algorithmic fixes (list to set, list to deque) are worth 50x. Loop-style micro-optimisations are worth about 2x.
- Received performance wisdom expires. The
+=rule above is wrong on this interpreter, for a reason you would never guess from the source. tracemallocfor memory, sincegetsizeofdoes not follow references.- Correct, then tested, then measured, then changed. In that order.