Optimisation Techniques
Once the algorithm is right, a handful of techniques reliably help: build less, look up less, let C do the loop, and cache what repeats.
- First: is it worth it?
- Build less
- Look up less
- Let C do the loop
- Cache what repeats
- Avoid repeated work in a loop
- Choose the right tool for numbers
- Do the work once, not per item
- Batch input and output
- When Python itself is the limit
- Readability is a cost too
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
First: is it worth it?
# A function called once, taking 50 milliseconds:
# optimising it to 5 ms saves 45 ms. Nobody notices.
#
# A function called a million times, taking 50 microseconds:
# optimising it to 5 us saves 45 seconds. Everyone notices.Multiply the saving by the call count before spending time on it. Then confirm with a profile that the function is actually hot.
Build less
import timeit
setup = "data = list(range(100_000))"
# Builds a full list, then throws it away
print(min(timeit.repeat("sum([n * n for n in data])", setup=setup, number=20, repeat=3)))
# Builds nothing
print(min(timeit.repeat("sum(n * n for n in data)", setup=setup, number=20, repeat=3)))import timeit
setup = "data = list(range(200_000))"
print(min(timeit.repeat("any(n > 199_000 for n in data)", setup=setup,
number=100, repeat=3))) # stops early
print(min(timeit.repeat("len([n for n in data if n > 199_000]) > 0", setup=setup,
number=100, repeat=3))) # examines everythingany, all and next stop at the first decisive value. Feeding them a generator rather than a list means the remaining work never happens.
Look up less
import timeit
setup = "data = list(range(500_000))"
# An attribute lookup on every iteration
code_a = """
result = []
for n in data:
result.append(n * 2)
"""
# The method bound once
code_b = """
result = []
append = result.append
for n in data:
append(n * 2)
"""
# No lookup at all
code_c = "result = [n * 2 for n in data]"
for label, code in [("attribute", code_a), ("bound", code_b), ("comprehension", code_c)]:
print(f"{label:<16}{min(timeit.repeat(code, setup=setup, number=10, repeat=3)):.4f}s")import math
# A global lookup on every call
def distance_slow(points):
return [math.sqrt(x * x + y * y) for x, y in points]
# Bound to a local once
def distance_fast(points, _sqrt=math.sqrt):
return [_sqrt(x * x + y * y) for x, y in points]This works because local names compile to LOAD_FAST, an array index, while globals and attributes need a dictionary lookup. It is a real gain in a tight loop and pure noise anywhere else - so do it only where a profile says it matters.Let C do the loop
import timeit
setup = "data = list(range(200_000))"
print(min(timeit.repeat("""
total = 0
for n in data:
total += n
""", setup=setup, number=50, repeat=3)))
print(min(timeit.repeat("total = sum(data)", setup=setup, number=50, repeat=3)))import timeit
setup = 'data = [str(n) for n in range(100_000)]'
print(min(timeit.repeat("result = [s.upper() for s in data]",
setup=setup, number=20, repeat=3)))
print(min(timeit.repeat("result = list(map(str.upper, data))",
setup=setup, number=20, repeat=3)))| Instead of a loop | Use |
|---|---|
| Adding values | sum() |
| Finding the largest | max(), min() |
| Applying one existing function | map() |
| Counting | collections.Counter |
| Joining strings | str.join() |
| Checking any or all | any(), all() |
| Sorting | sorted() |
| Running totals | itertools.accumulate |
These are implemented in C, so the loop runs without interpreter overhead per iteration. The gain is typically two to five times, and the code is usually shorter.
Cache what repeats
from functools import cache, lru_cache
import time
@cache
def expensive(n):
time.sleep(0.05)
return n * n
start = time.perf_counter()
for _ in range(20):
expensive(4)
print(f"{time.perf_counter() - start:.3f}s") # one computation, not twenty
print(expensive.cache_info())from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def summary(self):
print(" computing summary")
return {"count": len(self.rows), "total": sum(self.rows)}
r = Report([1, 2, 3])
print(r.summary)
print(r.summary) # no recomputation# Hoist an invariant out of the loop
import re
# Recompiles, and rebuilds the set, on every iteration
def slow(lines, words):
return [l for l in lines if re.search(r"\d+", l) and set(words) & set(l.split())]
# Both hoisted out
PATTERN = re.compile(r"\d+")
def fast(lines, words):
targets = set(words)
return [l for l in lines if PATTERN.search(l) and targets & set(l.split())]Avoid repeated work in a loop
import timeit
setup = "data = list(range(100_000))"
# len() recalculated every iteration
code_a = """
result = []
for i in range(len(data)):
result.append(data[i] * 2)
"""
# Iterate directly, no indexing
code_b = """
result = []
for value in data:
result.append(value * 2)
"""
for label, code in [("index", code_a), ("direct", code_b)]:
print(f"{label:<10}{min(timeit.repeat(code, setup=setup, number=20, repeat=3)):.4f}s")import timeit
setup = 'names = [f"user{i}" for i in range(2000)]'
# Formats and concatenates repeatedly
code_a = """
text = ""
for name in names:
text += f"<li>{name}</li>"
"""
# One join
code_b = 'text = "".join(f"<li>{name}</li>" for name in names)'
for label, code in [("concatenate", code_a), ("join", code_b)]:
print(f"{label:<14}{min(timeit.repeat(code, setup=setup, number=100, repeat=3)):.4f}s")Choose the right tool for numbers
import timeit
import array
# A list of ints stores pointers to int objects
print(timeit.timeit("sum(data)", setup="data = list(range(100_000))", number=200))
# array stores raw machine values: less memory, similar speed for sums
print(timeit.timeit("sum(data)",
setup="import array; data = array.array('i', range(100_000))",
number=200))
import sys
print(sys.getsizeof(list(range(100_000))))
print(sys.getsizeof(array.array("i", range(100_000))))Do the work once, not per item
import time
records = [{"id": i, "dept": f"d{i % 50}"} for i in range(20_000)]
departments = {f"d{i}": f"Department {i}" for i in range(50)}
# A lookup per record, but the dictionary is built once - fine
start = time.perf_counter()
labelled = [{**r, "label": departments[r["dept"]]} for r in records]
print(f"dict lookup: {time.perf_counter() - start:.4f}s")
# Rebuilding the lookup inside the comprehension - not fine
start = time.perf_counter()
labelled = [
{**r, "label": {f"d{i}": f"Department {i}" for i in range(50)}[r["dept"]]}
for r in records
]
print(f"rebuilt: {time.perf_counter() - start:.4f}s")Batch input and output
import time
lines = [f"line {i}\n" for i in range(50_000)]
# One write call per line
start = time.perf_counter()
with open("out_a.txt", "w", encoding="utf-8") as handle:
for line in lines:
handle.write(line)
print(f"per line: {time.perf_counter() - start:.4f}s")
# One call
start = time.perf_counter()
with open("out_b.txt", "w", encoding="utf-8") as handle:
handle.writelines(lines)
print(f"batched: {time.perf_counter() - start:.4f}s")The same principle applies far beyond files: one database query returning a thousand rows beats a thousand queries, and one network request beats a hundred. Batching usually matters more than anything else in this note.
When Python itself is the limit
| Situation | Answer |
|---|---|
| Waiting on files or the network | Threads or asyncio |
| Pure computation across cores | multiprocessing |
| Heavy numeric work | A library that loops in C |
| One very hot function | Rewrite that function only |
| Everything is slow | The algorithm is wrong, not the language |
Readability is a cost too
# Clear
def average(values):
return sum(values) / len(values)
# Marginally faster, considerably worse
def average_micro(values, _sum=sum, _len=len):
return _sum(values) / _len(values)The second version saves a fraction of a microsecond and costs every future reader a moment of confusion. Reserve tricks like this for the small number of functions a profile has identified, and add a comment saying why.
Common mistakes
- Optimising code that a profiler has not identified as hot.
- Applying micro-optimisations while an O(n²) algorithm remains.
- Caching a function whose result depends on changing state.
- Building a large list where a generator would do.
- Rebuilding a constant lookup inside a loop.
- Making code unreadable for an unmeasurable gain.
- Forgetting to verify that the optimised version still produces the same output.
Best practices
- Profile first, and optimise only the hot spot.
- Improve the algorithm before the constants.
- Prefer built ins and comprehensions to hand written loops.
- Use generators when the result is consumed once.
- Hoist invariants out of loops and cache pure functions.
- Batch input and output, database calls and network requests.
- Keep a test that proves the fast version still agrees with the slow one.
Practice
- Take a nested loop over two lists and make it linear with a set or a dictionary.
- Replace a hand written accumulation loop with a built in and measure the difference.
- Find an invariant computed inside a loop in your own code and hoist it out.
- Add
functools.cacheto a repeatedly called pure function and measure the effect. - Convert a per line file write into a batched one and compare the timings.
Conclusion
Build less, look up less, let C run the loop, cache what repeats and batch what is expensive. Apply these only where a profile points, verify the output is unchanged, and stop as soon as it is fast enough - readability has value that does not show up in a benchmark.