Expensive function-scoped pytest fixtures
A pytest fixture without a scope runs again for every test that uses it. Give expensive fixtures module or session scope, or split them into a shared part and a cheap reset.
A pytest fixture with no scope argument is function-scoped: pytest runs it again for every test that requests it, and tears it down after each one. That's the right default for cheap things, and the safe one, since every test gets a fresh object.
It gets expensive when the fixture does real work: starting an application, loading a large file or a machine learning model, building a database schema, starting a container, compiling templates. A fixture that takes 300ms and is used by 400 tests costs two minutes per run, even if every test only reads what it builds.
Find the fixtures that are rebuilt
pytest can show you when fixtures are set up and torn down:
$ pytest --setup-show tests/test_api.py
tests/test_api.py
SETUP F app
tests/test_api.py::test_list (fixtures used: app).
TEARDOWN F app
SETUP F app
tests/test_api.py::test_detail (fixtures used: app).
TEARDOWN F app
The F means function scope. pytest --durations=20 lists the slowest setup and teardown phases next to the slowest calls; a fixture that shows up in the setup of many tests is the one to look at.
Widen the scope when tests only read it
If tests only read what the fixture builds, give it a wider scope so it's built once:
# Before: loads the model for every test
@pytest.fixture
def classifier():
return load_model("models/classifier.bin")
# After: loads it once per test session
@pytest.fixture(scope="session")
def classifier():
return load_model("models/classifier.bin")
The scopes, from narrowest to widest:
| Scope | Built once per |
|---|---|
function (default) |
test |
class |
test class |
module |
test file |
package |
package (directory with __init__.py) |
session |
whole run (per worker under pytest-xdist) |
A fixture can only depend on fixtures of the same or a wider scope. A session-scoped fixture that requests tmp_path or db fails with a ScopeMismatch error. Use tmp_path_factory instead of tmp_path in wide-scoped fixtures:
@pytest.fixture(scope="session")
def dataset_dir(tmp_path_factory):
path = tmp_path_factory.mktemp("dataset")
generate_dataset(path)
return path
Split it when tests modify it
If tests change what the fixture builds, a wider scope leaks those changes from one test into the next. Split the fixture in two: an expensive higher-scoped fixture that builds the thing once, and a cheap function-scoped one that resets or copies it.
import copy
import json
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def _catalog_data():
return json.loads(Path("tests/data/catalog.json").read_text())
@pytest.fixture
def catalog(_catalog_data):
return copy.deepcopy(_catalog_data)
Tests keep requesting catalog and get a private copy, while the file is parsed once. Check that the copy is actually cheaper than building: deepcopy of a big object graph can be slow too.
The same pattern applies to anything with state:
- A database: create the schema once per session and wrap each test in a transaction that is rolled back (see roll back per test with SQLAlchemy). pytest-django and Django's
TestCasealready do this. - An app or client: build the app once, and reset what tests change (dependency overrides, settings) in a function-scoped fixture.
- A container or server: start it once per session and clear its data per test (truncate tables,
FLUSHDB, empty a bucket).
Watch out for autouse
An autouse=True fixture runs for every test in its scope, whether the test needs it or not. A function-scoped autouse fixture in the root conftest.py that does real work is paid by every test in the suite. Move it into the conftest.py of the directory that needs it, give it a wider scope, or make it opt-in and request it by name.
Keep the tests correct
- A wider scope shares state between tests: check that no test mutates it. Look for tests that append to lists, change attributes, or write to the shared object's files.
- Run the suite in random order with pytest-randomly to catch leaks: a test that only passes after (or before) another one depends on shared state. See keep tests independent.
- Session scope under pytest-xdist means once per worker, not once per run. Each worker builds its own copy, which is usually what you want for isolation.
- Fixtures that hold resources (connections, files, processes) still need a teardown; with
yieldit runs at the end of the fixture's scope.
How flakytest finds it
flakytest measures the setup time of every fixture exactly, with its scope and how many times it ran. When a function-scoped fixture ran at least 5 times, took at least 50ms on average, and adds up to at least 1 second and 3% of test time, it suggests Fixture name is rebuilt for every test, with its call count, mean and maximum time, and the tests that use it. The report's fixtures view also marks class, module and session fixtures as shared, charged to the test that triggered them.
After widening a scope, run flakytest run again and flakytest compare to check the time saved and that no test changed outcome.
Further reading
Related guides
- pytest fixtures best practices: How to write pytest fixtures that stay fast and readable: scopes, yield teardown, factories as fixtures, conftest.py placement, autouse, parametrize, tmp_path and monkeypatch.
- Keep tests independent of each other: Tests should pass alone, in any order and in parallel. Find order-dependent pytest tests with pytest-randomly and pytest-xdist, and fix the shared state behind them.
- Create the SQLAlchemy schema once, roll back per test: Running metadata.create_all and drop_all for every test is slow. Create the SQLAlchemy schema once per session and roll back each test with join_transaction_mode="create_savepoint".
- 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