Python Performance — Profiling and Optimisation
Finding real bottlenecks before optimising anything — profiling tools and the optimisations that actually matter.
The Single Rule That Matters More Than Any Optimisation Trick
"Premature optimisation is the root of all evil" is one of the most quoted lines in software engineering, and it holds up: engineers routinely guess wrong about where a program's time is actually going, spend hours optimising a function that accounts for 2% of runtime, and leave the real bottleneck — often somewhere unremarkable-looking — completely untouched. The entire discipline of performance work starts with one rule: measure before you optimise anything.
Python's Built-In Profiler
cProfile is part of the standard library — no installation required — and reports exactly how much time was spent in every function call across an entire program run.
python -m cProfile -s cumulative my_script.pyimport cProfile
def process_all_records(records):
return [transform(r) for r in records]
cProfile.run("process_all_records(records)") 1000004 function calls in 2.145 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.012 0.012 2.145 2.145 script.py:4(process_all_records)
500000 1.203 0.000 1.980 0.000 script.py:8(is_duplicate)
500000 0.777 0.000 0.777 0.000 {method 'append' of 'list' objects}
1 0.153 0.153 0.153 0.153 script.py:2(load_records)Two columns matter most. tottime is time spent inside that function alone, excluding time spent in functions it calls — this is what tells you where the actual work is happening. cumtime is cumulative time, including everything called from within that function — useful for seeing which top-level call chain is expensive overall, even if the time is really being spent several calls deeper. Sorting by cumulative (as in the command above) surfaces the functions worth investigating first.
Recognising an Accidental O(n²) Before It Becomes a Production Problem
A profiler tells you where time is going; understanding algorithmic complexity tells you why a specific piece of code is disproportionately slow, and whether that slowness will get catastrophically worse as data grows — not just annoyingly slower.
def is_duplicate(record, seen):
return record["id"] in seen # membership check on a LIST
def deduplicate(records):
seen = []
result = []
for record in records:
if not is_duplicate(record, seen):
result.append(record)
seen.append(record["id"])
return resultx in a_list scans the list from the start until it finds a match or reaches the end — an O(n) operation on its own. Called once, that is fine. Called once per record, inside a loop over every record, with seen growing by one each iteration, the total cost becomes O(n²): for 500,000 records, roughly 500,000 × (up to 500,000) comparisons in the worst case — exactly the kind of function cProfile would flag with a suspiciously large tottime for what looks like a trivial one-line check.
def deduplicate(records):
seen = set() # membership check is O(1) on average, not O(n)
result = []
for record in records:
if record["id"] not in seen:
result.append(record)
seen.add(record["id"])
return result
# Same logic, same result — O(n) overall instead of O(n²).
# On 500,000 records, this is the difference between roughly 5 seconds and
# a genuinely unusable multi-minute runtime.set instead of a list for membership checks — is one of the single most common, highest-leverage optimisations in real Python code. Any time you see x in some_list inside a loop, ask whether some_list could become a set (or dict keys, when values are also needed) instead — the fix is usually a one-line change with a dramatic effect at scale.Comparing Two Small Alternatives Precisely
cProfile is the right tool for finding where time goes across a whole program. timeit is the right tool for a much narrower question: "which of these two small snippets is actually faster?" — it runs a snippet many times and reports precise, averaged timing, avoiding the noise a single manual timing run would have.
import timeit
def concat_with_plus():
result = ""
for i in range(1000):
result += str(i)
return result
def concat_with_join():
return "".join(str(i) for i in range(1000))
print(timeit.timeit(concat_with_plus, number=1000)) # e.g. 0.412 seconds total
print(timeit.timeit(concat_with_join, number=1000)) # e.g. 0.187 seconds total — clearly fasterThis confirms a well-known Python performance fact directly: repeated += string concatenation in a loop creates a new string object on every iteration (strings are immutable, covered back in the Strings module), while "".join(...) builds the result once — timeit is how you verify a claim like this empirically rather than trusting it as folklore.
Data Structure, Algorithm, Caching, or Leave It Alone
Once a real bottleneck is identified (via profiling, never guessing), there are only a handful of genuinely different categories of fix — recognising which one applies avoids wasted effort on the wrong kind of change.
1. Wrong data structure?
-> list membership checks in a loop, linear search for something a dict/set
would find in O(1) — usually the highest-leverage, lowest-risk fix.
2. Wrong algorithm?
-> nested loops that could be restructured (e.g. sorting once instead of
repeatedly scanning), redundant repeated work that could be computed once.
3. Repeated expensive work with the same inputs?
-> functools.lru_cache (covered in the Decorators module) or a manual cache,
IF the function is pure (same input always -> same output) and called
repeatedly with overlapping inputs.
4. Genuinely CPU-bound work at the limits of what pure Python can do?
-> reach for NumPy/pandas (next module) for vectorised numeric work, or
multiprocessing (covered earlier in this phase) for true parallelism.
5. Is it actually a problem worth fixing at all?
-> a function that runs once at startup taking 200ms extra is very often
not worth any engineering time, no matter how "inefficient" it looks.A Nightly Batch Job That Grew From 5 Minutes to 3 Hours, at a Phoenix Retail Analytics Company
A nightly job deduplicating that day's transaction records ran in 5 minutes when it was written, against a modest dataset. A year of organic business growth later, it takes over 3 hours and increasingly threatens to miss its overnight processing window entirely. Two engineers separately assume, without profiling, that the database write step must be the bottleneck and spend a day investigating batch-write tuning with no meaningful improvement.
ncalls tottime percall cumtime percall filename:lineno(function)
2000000 847.2 0.000 847.2 0.000 dedupe.py:12(is_duplicate)
1 0.4 0.4 3.1 3.1 dedupe.py:31(write_to_database)The actual bottleneck, and why it had been invisible for a year
is_duplicate — a small, unremarkable-looking helper checking membership against a plain Python list — accounted for over 99% of total runtime, not the database write step everyone had assumed. The bug had existed since the code was first written; it was simply invisible when the dataset was small enough that O(n²) still finished in seconds. As the business grew and transaction volume grew with it, the same unchanged code silently crossed from "fine" to "the single largest operational risk in the nightly pipeline," with no code change ever having introduced the regression — only data volume did.
seen = set() # was: seen = []
# ...
if record["id"] not in seen: # now O(1) instead of O(n)The job's runtime dropped from over 3 hours back to under 2 minutes — faster than its original 5-minute runtime a year earlier, since the fixed version now scales linearly instead of quadratically. The team's retrospective conclusion: "an hour with a profiler would have found this on day one of the slowdown; a full day of tuning the wrong subsystem found nothing, because nobody had actually measured where the time was going."
Four Misconceptions About Performance Work
5 Interview Questions — With Complete Answers
Performance Work Mistakes Beginners Make Constantly
Issues You Will Hit With Profiling & Performance — And Exactly Why
🎯 Key Takeaways
- ✓Always measure before optimising — a profiler finds the real bottleneck; intuition frequently guesses wrong, wasting effort on code that was never the actual problem.
- ✓cProfile reports tottime (time in a function alone) and cumtime (including everything it calls) — sort by cumulative to find the most expensive call chains first.
- ✓A membership check (x in a_list) inside a loop over a growing list is a classic accidental O(n²) — switching to a set makes it O(1) per check, one of the highest-leverage fixes in real Python code.
- ✓timeit is for precise micro-benchmarks comparing small alternatives; cProfile is for finding where time goes across a whole program — they answer different questions.
- ✓@functools.lru_cache is a fast, safe win only for pure functions (same input always produces the same output) — never for functions with side effects or dependence on changing external state.
- ✓Not every "inefficient-looking" piece of code is worth optimising — let measured, real-world impact decide where performance effort actually goes.
What comes next
Module 43 introduces NumPy and pandas — the bridge from core Python into real data work, and why vectorised operations exist at all.
Module 43 → Intro to NumPy and pandasDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.