Profilers explain where time goes; benchmarks measure how much time there is. After a profiling session identifies a hot spot and a fix, the question "is it actually faster, and by how much?" needs a measurement that repeats the operation enough times to average out noise, reports the spread as well as the centre, and can be compared with a previous run. Ad-hoc time.perf_counter() calls around a single execution do none of those things.
pytest-benchmark turns a benchmark into an ordinary test. A benchmark fixture calibrates how many times to call the function, runs it in rounds, and reports minimum, maximum, mean, median, standard deviation and outliers. Results can be saved as JSON, compared against earlier runs, and used to fail a build when performance regresses beyond a threshold. The tool is simple; the craft is in writing benchmarks that measure the right thing and reading their numbers honestly.
Prerequisites
pytest >= 8.0,pytest-benchmark >= 4.0.- Background from CPU profiling with cProfile and py-spy.
Solution
# benchmarks/test_pricing_bench.py
import copy
import pytest
from pricing import apply_rules, build_index
def test_apply_rules_large_order(benchmark, large_order, all_rules):
result = benchmark(apply_rules, large_order, all_rules)
assert result > 0 # still a correctness check
def test_build_index(benchmark, all_rules):
benchmark.group = "index"
benchmark(build_index, all_rules)
def test_apply_rules_mutating(benchmark, large_order, all_rules):
# Each round needs a fresh copy because apply_rules_inplace mutates the order.
def setup():
return (copy.deepcopy(large_order), all_rules), {}
benchmark.pedantic(apply_rules_inplace, setup=setup, rounds=50, warmup_rounds=3)
# Run only benchmarks, save the results on main.
pytest benchmarks/ --benchmark-only --benchmark-autosave
# On a branch: compare against the latest saved run and fail on a 10% median regression.
pytest benchmarks/ --benchmark-only \
--benchmark-compare --benchmark-compare-fail=median:10%
# Keep benchmarks out of the normal test run.
pytest --benchmark-skip
Why this works
A single timing of a fast function is dominated by noise: timer resolution, cache state, an interrupt, the garbage collector choosing that moment to run. pytest-benchmark handles this in two ways. It groups calls into rounds long enough that timer resolution is negligible, and it repeats rounds enough times that a distribution emerges. The report's minimum approximates "the function with nothing in the way", the median is robust to occasional slow outliers, and the standard deviation and interquartile range tell you how much to trust either.
benchmark.pedantic exists for functions that cannot simply be called repeatedly — ones that mutate their input, consume an iterator or depend on fresh state. The setup callable runs before each round, outside the timed region, and returns the arguments. Setting iterations=1 (the default in pedantic mode) ensures each timed call gets fresh input.
Saved results are JSON files under .benchmarks/, keyed by machine and Python version. --benchmark-compare loads the most recent one (or a named one) and prints side-by-side columns; --benchmark-compare-fail turns a comparison into a pass/fail decision based on a statistic and a threshold.
Making CI comparisons trustworthy
Benchmarks in CI are harder than on a quiet laptop. Shared runners throttle, vary in CPU model between jobs, and run other workloads on neighbouring cores. A 10% threshold on the mean can fail on noise alone. Several practices make the signal usable.
Compare like with like: keep baselines per runner type, and on hosted runners prefer a baseline measured in the same workflow run — benchmark main and the branch back-to-back in one job — over a baseline saved days ago on different hardware. Compare the median or the minimum, not the mean, since outliers from interruptions inflate the mean. Set thresholds from observed noise: run the same commit ten times, look at the spread, and set the failure threshold comfortably above it. And isolate the benchmark job: no pytest-xdist, no other test jobs on the same runner, garbage collection disabled during rounds with --benchmark-disable-gc when allocation noise dominates.
Reading the report table
A pytest-benchmark run ends with a table that is easy to skim and easy to misread. Each row is one benchmark; the columns are Min, Max, Mean, StdDev, Median, IQR, Outliers, OPS (operations per second, the reciprocal of the mean), Rounds and Iterations. Benchmarks in the same group are sorted and compared within that group, so give related benchmarks a shared group name — "index", "pricing", "serialise" — to get meaningful relative comparisons.
Read the spread before the centre. If StdDev is a large fraction of the Mean, or the IQR is wide, the benchmark is noisy and any comparison built on it is weak. The Outliers column, written as a;b, counts rounds beyond one standard deviation and beyond 1.5 IQR from the quartiles; many outliers suggest interference — garbage collection, other processes, thermal throttling — rather than a property of the code. Check Rounds too: a benchmark that only managed five rounds within max_time has a poorly estimated median, and raising --benchmark-max-time or --benchmark-min-rounds is worth doing before drawing conclusions.
When comparing runs, pytest-benchmark prints the saved and current results side by side, with the percentage change for each statistic in brackets. A change in Min with a stable Median usually means the best case moved — for example, a cache became warmer — while a change in Median with a stable Min usually means the typical case got slower. Both are real, but they point at different causes.
Choosing what to benchmark
A benchmark suite is only as useful as the operations it measures. The temptation is to benchmark every function that was ever slow; the result is a long, noisy suite that nobody reads. A better rule is to benchmark the operations whose speed someone would notice: the request handler behind the busiest endpoint, the batch job's inner transformation, the serialiser used on every message, the query builder on the reporting path. Five to fifteen well-chosen benchmarks usually cover a service. Each benchmark should use realistic input sizes, taken from production data shapes rather than toy fixtures, because algorithmic problems often only show at scale — a quadratic loop is invisible at ten items and dominant at ten thousand.
It also pays to benchmark at two sizes. A pair of benchmarks, one at a typical size and one at ten times that, shows the scaling behaviour directly: if the larger one takes ten times as long, the operation is linear; if it takes a hundred times as long, something quadratic has crept in. That signal survives CI noise much better than a single absolute number, because both measurements share the same machine and moment. A ratio check — assert that the large benchmark's median is less than, say, fifteen times the small one's — can even be written as an ordinary assertion over the saved JSON, giving a regression gate for algorithmic complexity that is almost immune to runner speed.
Edge cases and failure modes
- Benchmarks in the normal test run. They slow the suite and produce meaningless numbers under xdist. Keep them in a separate directory and use
--benchmark-skipfor regular runs. - Optimised-away work. If the result is unused, some code paths — especially in JIT-backed libraries — may skip work. Assert on the result, as in the first test.
- Fixture cost leaking in. Work done inside the benchmarked callable counts. Build inputs in fixtures or pedantic
setup, not inside the function passed tobenchmark. - Caches warming across rounds. A function with an internal cache is fast after the first call, so the benchmark measures the cache hit. Clear the cache in
setupif the cold path is what matters. - Comparing across Python versions. Saved results are tagged by interpreter; comparing 3.12 to 3.13 is legitimate only as an explicit experiment, not as a regression gate.
Frequently Asked Questions
How does pytest-benchmark decide how many times to run my function?
It calibrates: it times a single call, then chooses how many calls to group into each round so a round lasts at least the timer's resolution times a safety factor, and runs rounds until max_time is reached or min_rounds is met.
How do I fail CI when performance regresses?
Save a baseline with --benchmark-autosave or --benchmark-save, then run later builds with --benchmark-compare and --benchmark-compare-fail, for example mean:10% to fail when the mean is more than ten percent slower.
Why are my benchmark results so noisy in CI? Shared CI runners have variable CPU frequency, noisy neighbours and throttling. Compare against a baseline measured on the same runner type, use medians or minimums rather than means, and set thresholds wider than the observed noise.
Related
- CPU Profiling with cProfile and py-spy — finding what to benchmark.
- Line-Level Profiling with line_profiler — locating the slow line.
- Profiling Async Code with yappi — when the hot path is a coroutine.
- Catching Per-Test Memory Growth in pytest — the memory counterpart.
← Back to CPU Profiling with cProfile and py-spy