# 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:

```console
$ 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](https://flakytest.dev/guides/slowest-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.

```console
$ 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](https://jiffyclub.github.io/snakeviz/):

```console
$ 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](https://github.com/man-group/pytest-plugins/tree/master/pytest-profiling) runs cProfile around each test and writes the results to a `prof/` directory:

```console
$ 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](https://github.com/benfred/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.

```console
$ 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](https://pyinstrument.readthedocs.io/) is a statistical profiler that runs inside the process and prints a compact call tree that's easy to read in a terminal:

```console
$ 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:

```python
# 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:

```console
$ 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.

```console
$ 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

- [Python: the Python profilers (cProfile, pstats)](https://docs.python.org/3/library/profile.html)
- [Python 3.15: `profiling.sampling` (Tachyon)](https://docs.python.org/3.15/library/profiling.sampling.html)
- [py-spy](https://github.com/benfred/py-spy)
- [pyinstrument: profile pytest tests](https://pyinstrument.readthedocs.io/en/latest/guide.html#profile-pytest-tests)
- [pytest: profiling test execution duration](https://docs.pytest.org/en/stable/how-to/usage.html#profiling-test-execution-duration)
- [flakytest docs: how profiling works](https://flakytest.dev/docs/#how)
