Measure your test suite

How to profile a pytest suite

Profile slow pytest tests with cProfile, pytest-profiling, py-spy, pyinstrument, Python 3.15's tachyon or flakytest: the commands, what each one shows, and which to use when.

pytest --durations tells you which tests are slow. A profiler tells you why: which functions, which lines, and which library calls the time goes to. Python has several good profilers, and they all work on pytest because pytest is just a Python program. They differ in how much they slow the tests down, how they show the result, and whether they can tell you which test a slow call belongs to.

The question you usually have about a test suite isn't "which function is slow" but "which test is slow, in which phase, and what is it doing": a fixture rebuilding the database, password hashing in setUp, a sleep in a retry loop. Keep that in mind when you read the output of a general-purpose profiler.

Start with durations

Before profiling, find out where to look:

$ pytest --durations=20 --durations-min=1.0

If one test dominates, profile only that test. If the time is spread thin across hundreds of tests, you're looking for something they share (a fixture, a setting, a base class), and you want a profile of the whole run. Find your slowest pytest tests covers this step in depth.

cProfile: exact call counts

cProfile is in the standard library. It's a deterministic profiler: it records every function call and return.

$ python -m cProfile -o pytest.prof -m pytest tests/
$ python -c "import pstats; pstats.Stats('pytest.prof').sort_stats('cumulative').print_stats(30)"

For an interactive view, open the file with snakeviz:

$ pip install snakeviz
$ snakeviz pytest.prof

Good for: exact call counts ("make_password was called 4,812 times"), which is often the clue you need. No extra install.

Watch out for: overhead. Every Python function call is recorded, so code that makes many small calls (ORMs, template engines, serializers) slows down much more than code that waits on I/O, and the profile overstates it. The whole session ends up in one profile: pytest internals, collection and all tests mixed together. Under pytest-xdist it only sees the controller process, so profile without -n. On Python 3.15, cProfile is also available as profiling.tracing; the old name keeps working.

pytest-profiling: cProfile per test

pytest-profiling runs cProfile around each test and writes the results to a prof/ directory:

$ pip install pytest-profiling
$ pytest tests/test_orders.py --profile
$ pytest tests/test_orders.py --profile-svg   # also a call graph, needs gprof2dot and Graphviz

You get one .prof file per test plus prof/combined.prof for the whole run, and the summary of the combined profile is printed at the end. Each file covers the test's whole run protocol (setup, call and teardown together). It has the same overhead and the same strengths as cProfile.

py-spy: low-overhead sampling

py-spy is a sampling profiler: a separate process that reads the Python stack of your test process many times a second. It doesn't run code inside the tests, so it barely slows them down, and it sees time spent waiting (sleeps, network, database) as well as CPU time.

$ pip install py-spy
$ py-spy record -o profile.svg -- python -m pytest tests/
$ py-spy record --subprocesses -o profile.svg -- python -m pytest -n 4   # include xdist workers
$ py-spy record --format speedscope -o profile.json -- python -m pytest  # open in speedscope.app

The default output is a flame graph. --rate raises the sampling rate (the default is 100 samples per second), --idle includes threads that are waiting, and --native includes C extension frames.

Good for: a realistic picture of where a whole run spends its time, with negligible overhead, including xdist workers.

Watch out for: attaching to processes needs permissions. On Linux, py-spy record -- python ... works without root because py-spy starts the process; attaching to a running PID usually needs sudo, and in Docker you need --cap-add SYS_PTRACE. On macOS it needs root. The flame graph merges identical stacks across all tests, so you see that django.contrib.auth.hashers is hot, not which tests call it.

pyinstrument: readable call trees

pyinstrument is a statistical profiler that runs inside the process and prints a compact call tree that's easy to read in a terminal:

$ pip install pyinstrument
$ pyinstrument -m pytest tests/
$ pyinstrument -r html -o pytest.html -m pytest tests/

pyinstrument's documentation also shows how to get one profile per test, with an autouse fixture:

# conftest.py
from pathlib import Path

import pytest
from pyinstrument import Profiler

TESTS_ROOT = Path.cwd()


@pytest.fixture(autouse=True)
def auto_profile(request):
    profile_root = TESTS_ROOT / ".profiles"
    profiler = Profiler()
    profiler.start()
    yield
    profiler.stop()
    profile_root.mkdir(exist_ok=True)
    profiler.write_html(profile_root / f"{request.node.name}.html")

Good for: quickly reading where one test or one run spends its time. As a sampling profiler it has much less overhead than cProfile.

Watch out for: the fixture version only covers what happens while the fixture is active: fixtures set up before it, and class or session fixtures, aren't included, and you get one HTML file per test with no overview across them.

tachyon: the standard library sampler on Python 3.15

Python 3.15 added a sampling profiler to the standard library, Tachyon, as profiling.sampling. Like py-spy it reads the stack from outside the process, and it supports high sampling rates:

$ python -m profiling.sampling run --flamegraph -o pytest.html -m pytest tests/
$ python -m profiling.sampling run --subprocesses --flamegraph -m pytest -n 4
$ python -m profiling.sampling run --mode cpu -m pytest tests/

The default mode measures wall-clock time; --mode cpu counts only time on the CPU, which separates computation from waiting. With --subprocesses you get a separate flame graph per process (one per xdist worker). It can also write pstats, collapsed stacks, Firefox Profiler (--gecko) and heatmap output. It needs no install, but only exists on 3.15+.

What flakytest adds

flakytest uses the same kind of sampling profiler (tachyon on Python 3.15+, py-spy on 3.11 to 3.14) and adds what a general-purpose profiler can't know: which test, and which phase of it, each sample belongs to.

$ pip install flakytest
$ flakytest run                          # pytest with profiling, then opens the report
$ flakytest report --test tests/test_orders.py::TestCheckout
  • Per-test attribution. Each test phase runs inside a small generated function named after the test, which shows up in every sampled stack, so every sample belongs to exactly one test and phase. That works the same under pytest-xdist, and one report covers all workers.
  • Setup, call and teardown. Durations come from pytest itself (a test that took 1.82s took 1.82s); samples only split that time. Fixture setup times are measured exactly, and class, module and session fixtures are marked as shared.
  • Categories. Every sample is put in one category (database, password hashing, network, sleep, fixture loading, imports, garbage collection, logging and more), so "41% of test time is password hashing" is on the first screen instead of hidden in a flame graph.
  • Database queries per test for Django and SQLAlchemy, including repeated queries (N+1).
  • Ranked fixes. "Fix next" lists suggestions ranked by the seconds per run they'd save, with the lines of your code that cause them and how to keep the tests correct while making the change.
  • Before and after. flakytest compare shows which tests got faster or slower, and flags tests whose outcome changed or that stopped running.

It doesn't replace the others. For micro-optimizing one function, cProfile's exact call counts or a flame graph you can zoom into may be what you want, and pyinstrument's call tree is quicker to read for a one-off script. flakytest also needs enough samples per test: very short tests get no breakdown unless you raise the rate (--flakytest-rate 5000).

Which one to use

Tool Overhead Per test Sees xdist workers Output
cProfile high no no pstats, snakeviz
pytest-profiling high yes (one file each) per-test files only pstats, SVG call graph
py-spy low no yes (--subprocesses) flame graph, speedscope
pyinstrument lower than cProfile with a fixture no text, HTML call tree
tachyon (3.15+) low no yes (--subprocesses) flame graph, pstats, heatmap, gecko
flakytest low yes, per phase yes, in one report HTML report, Markdown, JSON

Further reading

Related guides

  • Find your slowest pytest tests: List the slowest pytest tests with --durations and --durations-min, read setup vs call vs teardown times, then fix, split or mark slow tests so they stop holding up the suite.
  • How to speed up a slow pytest suite: A checklist for slow Python test suites, from the fixes that usually save the most time (password hashing, migrations, test data) to parallelism and CI. Each item links to a detailed guide.
  • Fix slow pytest startup and heavy imports in tests: pytest takes seconds before the first test runs? Find slow imports with python -X importtime, set testpaths, trim conftest.py imports and disable plugins you don't use.
  • Run tests in parallel with pytest-xdist: Run pytest in parallel on every CPU with pytest-xdist and -n auto. Per-worker databases with pytest-django, what breaks in parallel, pytest-randomly, and the --dist modes.

Updated October 18, 2026 · Suggest a change