timeit and cProfile
15 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Programs are slow in surprising places. Measuring finds the real place, and the tools for it are in the standard library.
Measure first, then change
A benchmark is a repeatable measurement of how long code takes, run on the same data and the same machine so that two versions can be compared fairly.
Guessing what is slow wastes time. Take a clock reading before and after the code you suspect. time.perf_counter() is the right clock for short intervals.
import time
start = time.perf_counter()total = sum(range(1_000_000))elapsed = time.perf_counter() - startprint(total)print(elapsed > 0)Example 1 · Timing one call
import time
def build_report(rows): return [row * 2 for row in rows]
start = time.perf_counter()result = build_report(range(1_000_000))elapsed = time.perf_counter() - startprint(len(result))print(f"took {elapsed:.3f} seconds")1000000 took 0.064 seconds
What this code does
- The length is always 1000000.
- The time line is a sample: your result will be different.
- Never put a printed time in a test; compare two versions instead.
The answer is always the same, but the time is not: it depends on your machine, its load and the Python version. That is why the examples in this module print comparisons such as True, not seconds.
timeit for small snippets
timeit is the standard-library module for timing small pieces of code. It repeats the code many times and reports the total time, which is far more reliable than one stopwatch reading.
One reading is noisy. The timeit module runs a snippet many times and repeat gives several such totals. Take the smallest, the run least disturbed by other programs.
import timeit
runs = timeit.repeat("sum(range(1000))", number=200, repeat=3)print(len(runs))print(min(runs) > 0)Example 2 · Comparing two versions with timeit
import timeit
orders = list(range(2000))ids_list = [n * 3 for n in orders]ids_set = set(ids_list)
def in_list(): return 5997 in ids_list
def in_set(): return 5997 in ids_set
t_list = min(timeit.repeat(in_list, number=500, repeat=3))t_set = min(timeit.repeat(in_set, number=500, repeat=3))print("set lookup is faster:", t_set < t_list)set lookup is faster: True
What this code does
- Both functions answer the same question.
repeatruns three readings andminkeeps the best one.- A list scans item by item; a set jumps straight to the item.
number is how many times the snippet runs inside one reading. You may also pass a function instead of text, which avoids quoting problems. timeit turns the garbage collector off while it times, so memory-heavy code looks cheaper than it is.
cProfile finds where the time goes
A profiler records which functions run, how often, and how long each takes. It points to the slow part of a program so you can fix that part instead of guessing.
timeit compares two versions. A profiler answers a different question: which function in a big program is the slow one? cProfile counts every call and the time spent in each function.
import cProfileimport pstats
def helper(): return 1
def main(): for _ in range(100): helper()
profile = cProfile.Profile()profile.runcall(main)stats = pstats.Stats(profile)calls = {key[2]: row[1] for key, row in stats.stats.items()}print(calls["helper"])Example 3 · Reading a profile table
import cProfileimport pstats
def parse(line): return line.split(",")
def load(lines): return [parse(line) for line in lines]
profile = cProfile.Profile()profile.runcall(load, ["a,b,c"] * 1000)stats = pstats.Stats(profile)stats.sort_stats("tottime")stats.print_stats(3) 2002 function calls in 0.001 seconds
Ordered by: internal time
List reduced from 4 to 3 due to restriction <3>
ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.000 0.000 0.000 0.000 app.py:4(parse)
1 0.000 0.000 0.001 0.001 app.py:7(load)
1000 0.000 0.000 0.000 0.000 {method 'split' of 'str' objects}What this code does
- The table is a sample: file names and times differ on every machine.
ncallsofparseis 1000, once per line.- Read the columns
tottimeandcumtimefirst to see where to look.
Call counts are exact, so they are safe to print. The times vary. print_stats shows the table: ncalls, tottime (time inside the function itself) and cumtime (including everything it calls).
The measure, fix, measure loop
Profile to find the slowest function, change one thing, then measure again on the same data. If the number did not improve, undo the change. Clean code that is fast enough beats clever code.
import timeit
data = list(range(5000))lookup = set(data)slow = timeit.timeit(lambda: 4999 in data, number=300)fast = timeit.timeit(lambda: 4999 in lookup, number=300)print("set is faster:", fast < slow)Example 4 · Finding the slowest function in code
import cProfileimport pstats
def heavy(): return sum(i * i for i in range(300000))
def light(): return 1 + 1
def run(): heavy() for _ in range(50): light()
with cProfile.Profile() as profile: run()stats = pstats.Stats(profile)rows = {key[2]: row[2] for key, row in stats.stats.items() if key[2] in ("heavy", "light", "<genexpr>")}print(max(rows, key=rows.get) in ("heavy", "<genexpr>"))print({key[2]: row[1] for key, row in stats.stats.items()}["light"])True 50
What this code does
Profile()works as a context manager from Python 3.8.- The time sits in
heavyor its generator, never inlight. - Call counts are exact:
lightran 50 times.
The set wins by a wide margin, so the comparison is stable. Small differences of a few percent are noise: repeat the test before you believe them.
Next step
Check your understanding
12 quick questions