Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced+200 XP

Python Performance — Profiling and Optimisation

Finding real bottlenecks before optimising anything — profiling tools and the optimisations that actually matter.

40 min August 2026
// Part 01 — Measure First, Always

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.

⚠️ Important
Never optimise based on intuition alone. A profiler routinely reveals that the slow part of a program is not the nested loop everyone assumed, but a single innocuous-looking line — a repeated database call inside a loop, a list-membership check that quietly became O(n²), a logging call that serializes a huge object on every request. Guessing wastes engineer time and often makes the code more complex without making it any faster.
// Part 02 — cProfile

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.

Profiling a script from the command line
python -m cProfile -s cumulative my_script.py
Profiling a specific function from within code
import cProfile

def process_all_records(records):
    return [transform(r) for r in records]

cProfile.run("process_all_records(records)")
Reading the output
         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.

// Part 03 — Big-O Intuition, Applied to Real Code

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.

A deduplication function that looks reasonable
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 result

x 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.

The fix — a set instead of a list
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.
🎯 Pro Tip
The specific fix above — a 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.
// Part 04 — timeit for Micro-Benchmarks

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.

Comparing string concatenation approaches
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 faster

This 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.

// Part 05 — A Decision Framework

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.

A practical decision order
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.
🎯 Pro Tip
Step 5 is not a throwaway line. A genuinely common mistake among engineers new to performance work is optimising code that does not matter — a script run once a day, a function contributing 0.01% of total request time — while the actual user-facing slowness remains unaddressed. Profiling data, not intuition or a general sense that code "looks slow," should be what decides where optimisation effort goes.
// Part 06 — Real World
💼 What This Looks Like at Work

A Nightly Batch Job That Grew From 5 Minutes to 3 Hours, at a Phoenix Retail Analytics Company

Scenario — Retail analytics company, Phoenix · Performance escalation

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.

What cProfile actually revealed, once someone finally ran it
   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.

The fix — switching the membership check to a set, exactly as in Part 03
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."

// Part 07 — Misconceptions

Four Misconceptions About Performance Work

✕ ""An experienced engineer can usually guess where the bottleneck is without profiling""
Even experienced engineers guess wrong regularly — as shown in the Real World example above, two engineers independently assumed the database write was the problem and spent a full day on the wrong subsystem. Profiling data beats intuition consistently enough that skipping it is considered a real mistake, not a shortcut.
✕ ""Big-O complexity is mostly academic and rarely matters in real production code""
An accidental O(n²) in unremarkable-looking code (like a list membership check inside a loop) is one of the most common real production performance bugs, and it gets dramatically worse — not just linearly worse — as data volume grows, exactly as the Real World example demonstrates.
✕ ""tottime and cumtime in a cProfile report mean basically the same thing""
tottime is time spent inside that function alone, excluding calls it makes to other functions — it tells you where actual work is happening. cumtime includes everything called from within it, useful for seeing which top-level call is expensive overall even if the real cost is several calls deeper.
✕ ""Caching a function with @lru_cache is always safe to add for a performance win""
It is only correct for a PURE function — one where the same inputs always produce the same output, with no side effects. Caching a function that depends on changing external state (the current time, a database that might be updated) can return stale, incorrect results.
// Part 08 — Interview Prep

5 Interview Questions — With Complete Answers

What is the single most important rule in performance optimisation work?
Measure before optimising anything — use a profiler to find the actual bottleneck rather than guessing. Engineers routinely guess wrong about where time is going, and optimising the wrong part wastes effort while leaving the real problem untouched.
What is the difference between tottime and cumtime in a cProfile report?
tottime is time spent inside that specific function alone, excluding any functions it calls — it identifies where actual work happens. cumtime is cumulative, including time spent in everything that function calls, useful for identifying which overall call chain is expensive even if the real cost is nested several calls deep.
Why does checking membership with "x in a_list" inside a loop often become a serious performance problem at scale?
A single "in" check on a list is O(n). Repeated once per item in a loop over n items (with the list growing alongside it, as in a deduplication pattern) makes the total cost O(n²) — quadratic growth means the runtime does not just get proportionally slower as data grows, it gets DISPROPORTIONATELY slower, which is exactly why a function that was fine on a small dataset can become the dominant bottleneck once data volume grows.
What is the fix for an O(n²) list-membership pattern, and why does it work?
Replace the list with a set (or dict, if values are also needed) — membership checks against a set are O(1) on average, using hashing rather than a linear scan, so the overall loop becomes O(n) instead of O(n²). This is one of the highest-leverage, lowest-risk optimisations in real Python code.
When is it appropriate to skip optimising code, even if a profiler shows it is technically inefficient?
When the actual absolute cost does not matter for the use case — a function contributing a negligible fraction of total runtime, or code that runs rarely (e.g. once at startup) where a fraction of a second has no real user-facing impact. Optimisation effort should be driven by actual measured impact, not by code merely looking inefficient.
// Common Mistakes

Performance Work Mistakes Beginners Make Constantly

Optimising based on intuition instead of profiler data
Leads to spending real engineering time on code that was never the actual bottleneck, exactly as shown by the two engineers in the Real World example who tuned database writes while the real cost was a list membership check.
Using timeit to measure something that should be profiled with cProfile instead
timeit is for comparing small, isolated snippets precisely — it is the wrong tool for finding which function, among many, is responsible for a whole program's slowness. Use cProfile for that broader question.
Adding @lru_cache to a function with side effects or non-deterministic output
Caching assumes the same inputs always produce the same output — applying it to a function that reads changing external state (current time, a mutable database) can silently return stale, incorrect results instead of a performance win.
Rewriting an algorithm for performance before confirming it is actually the bottleneck
A more "clever" or complex algorithm is not automatically faster in practice, and adds real maintenance cost — always confirm via profiling that the rewrite target is genuinely where time is going before investing effort in restructuring it.
// Error Library

Issues You Will Hit With Profiling & Performance — And Exactly Why

A profiled run takes noticeably longer than the unprofiled program
Cause: cProfile itself adds measurement overhead to every function call — this is expected and normal, and does not indicate a real additional bug; the RELATIVE proportions between functions in the report are what matter, not the absolute profiled runtime.
Fix: Compare functions' relative tottime/cumtime to each other within the same profiled run, not the profiled run's total time to the unprofiled program's total time.
The profiler shows time concentrated in a built-in function like {method 'append' of 'list' objects}
Cause: The reported time is genuinely being spent inside that many calls to a built-in operation — often because it is being called an enormous number of times, not because any single call is slow.
Fix: Look at ncalls for that line — an unexpectedly huge call count is usually the real signal, pointing to a loop structure worth reconsidering rather than the built-in operation itself.
Two runs of the same code report meaningfully different timings with timeit
Cause: Background system load, CPU frequency scaling, or other processes running on the same machine can introduce noise into wall-clock timing measurements.
Fix: Increase the "number" argument to run more iterations, and prefer the built-in "min" of multiple timeit.repeat() runs over a single timeit.timeit() call for a more stable result.

🎯 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 pandas
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...