Performance and Memory
Make it fast, and understand why.
Ava's order report takes a minute to run, and she wonders why. The golden rule of performance is, measure first. Most of the time, a tiny part of the program uses nearly all of the time. Find that part with a profiler, fix only that, and leave the rest alone. Guessing wastes hours.

- The report takes a minute
- Measure before you optimise
- Fix the real bottleneck
Profiling with cProfile
Python includes a profiler. It records how many times each function runs, and how long it takes. Here, a program has two functions, and one of them is slow. The report, sorted by time, points straight to the culprit. Now you know exactly where to look.
import cProfile, pstats, io
def slow():
return sum(i * i for i in range(300_000))
def fast():
return 42
def main():
for _ in range(5):
slow()
fast()
pr = cProfile.Profile()
pr.enable(); main(); pr.disable()
s = io.StringIO()
pstats.Stats(pr, stream=s).sort_stats("cumtime").print_stats(3)
print([l for l in s.getvalue().splitlines() if "slow" in l][0].split()[0])5
Common speed-ups
Many speed ups are simple. Move work out of loops. Use the right data structure, a set for membership. Use built in functions, like sum and join, which run in fast C code. Build strings with join, not repeated plus. And cache results of expensive pure functions. Here, we compare two ways of building a string.
import timeit
words = ["leaf"] * 2000
def concat():
s = ""
for w in words:
s += w + " "
return s
def joined():
return " ".join(words) + " "
print(concat() == joined())
a = timeit.timeit(concat, number=200)
b = timeit.timeit(joined, number=200)
print("join is faster:", b < a)True join is faster: True
How Python stores objects
In Python, a variable is a name, pointing to an object. The id function shows the identity. Two names can point to the same object, and the is operator checks that. Memory is managed by reference counting. When nothing points to an object any more, it is freed. The sys module reveals the count, and the size.
import sys
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a is b, a is c, a == c)
b.append(4)
print(a)
print(sys.getrefcount(a) >= 3)
del b
print(sys.getsizeof([]), sys.getsizeof(list(range(100))) > 800)True False True [1, 2, 3, 4] True 56 True
Saving memory with slots and generators
Every normal object carries a dictionary of attributes, which uses memory. Declaring slots tells Python the exact attributes, and drops the dictionary. For millions of small objects, it saves a lot. And generators, as we saw, avoid holding everything at once. The tracemalloc module shows real memory use.
import tracemalloc
class A:
def __init__(self, x): self.x = x
class B:
__slots__ = ("x",)
def __init__(self, x): self.x = x
def measure(cls):
tracemalloc.start()
items = [cls(i) for i in range(20000)]
cur, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
return cur
print("slots use less:", measure(B) < measure(A))slots use less: True
Optimisation rules of thumb
A few rules guide you. First make it correct, then make it clear, and only then, make it fast, if you need to. Measure before and after. Prefer better algorithms over clever tricks. Use NumPy for number crunching, and processes for heavy work. And remember, developer time is usually more expensive, than computer time.
Time two approaches
Your turn. Use timeit, to compare building a list of squares, with a loop, and with a comprehension. Print True, if the comprehension is not slower. Ready? Here is one solution. Two small functions, two timings, and a comparison.
Use timeit to compare a loop and a list comprehension for squares of range(1000).
Show hint
timeit.timeit(function, number=500)
Show solution
import timeit
def loop():
out = []
for i in range(1000):
out.append(i * i)
return out
def comp():
return [i * i for i in range(1000)]
a = timeit.timeit(loop, number=500)
b = timeit.timeit(comp, number=500)
print(b <= a * 1.2)True
Summary
Good work. You can profile to find bottlenecks, apply simple speed ups, understand references and memory, and save memory with slots. Next, two advanced features that power many frameworks, descriptors, and metaclasses.
Key takeaways- Profile first, then fix
- join, sets, builtins are fast
- __slots__ and generators save memory