pytest --collect-only -q takes eleven seconds on a repository with four thousand tests, and every invocation — including the one-test run you use while iterating — pays it. Collection cost is almost never about the number of tests. It is the directory walk plus the import of every module that walk finds, and both are prunable with configuration rather than code changes.
Prerequisites
- pytest 7.0+;
testpaths,norecursedirsandcollect_ignorebehave as described in 7 and 8. - The discovery mechanics in optimizing test discovery.
Solution
Four controls prune at different stages. Apply them in this order, because each one shrinks the work the next has to do.
testpaths — never walk what is not a test directory. With no path argument, pytest walks from rootdir, which in a monorepo means node_modules, terraform state, documentation and everything else. testpaths replaces that with an explicit list:
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests", "services/billing/tests", "services/catalog/tests"]
norecursedirs = [".git", ".venv", "build", "dist", "node_modules", "*.egg-info", "__snapshots__"]
norecursedirs — prune inside the test paths. The default already excludes .*, build and dist; the list above adds the ones a real repository accumulates. Pruning a directory means pytest never stats its contents, which matters most for directories with thousands of small files such as snapshot or fixture stores.
collect_ignore — skip conditionally, from Python. When the decision depends on the environment — a test package that cannot even be imported without a native dependency — a conftest can prune it dynamically:
# tests/conftest.py
import importlib.util
collect_ignore = []
if importlib.util.find_spec("pyarrow") is None:
# Skipping the import entirely, not just the tests: the module would fail to import.
collect_ignore.append("arrow_integration")
--ignore and --ignore-glob — prune for one run. The command-line equivalent, useful for a fast local loop: pytest --ignore=tests/integration -q.
Deselection flags are a different thing entirely. -k and -m filter items after collection has imported everything, so they make the run shorter without making the collection cheaper. On a suite whose problem is import cost, -m "not slow" saves nothing at the stage that hurts.
Why this works
Collection is a filesystem walk followed by an import. The walk costs a stat per entry and is cheap per directory but expensive when a directory contains tens of thousands of files. The import is where the real time goes: every test module is executed at import, which means every module-scope import django, import pandas or from app import settings in that file runs, transitively, once per session. Pruning a directory removes both costs at once; deferring a heavy import removes only the second, but it is usually the larger of the two.
Finding the expensive imports
Once the walk is pruned, the remaining cost is imports, and two tools localise it.
-X importtime reports the cumulative import cost of everything Python loaded, sorted so the expensive subtrees are visible at the bottom:
$ python -X importtime -m pytest --collect-only -q 2>&1 | sort -k2 -n -r | head -8
import time: 1943 | 512883 | pandas
import time: 402 | 231044 | sqlalchemy
import time: 118 | 88210 | app.settings
Anything in the hundreds of milliseconds is worth deferring. The fix is to move the import out of module scope and into the fixture or function that needs it, which keeps the type available to annotations via TYPE_CHECKING without paying for it at collection:
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING: # only the type checker pays for this
from sqlalchemy.engine import Engine
@pytest.fixture(scope="session")
def engine() -> "Engine":
from sqlalchemy import create_engine # paid once, and only when used
return create_engine("postgresql:///test")
The second tool is pytest's own --durations applied to collection: pytest --collect-only --durations=10 reports the slowest collection steps, which is how you find the single conftest importing the whole application.
For a monorepo, run collection per service and compare — a service that collects in 200ms and another that takes four seconds is a strong signal that the slow one has an import in its conftest that belongs in a fixture. That comparison also makes the isolation check from creating conftest.py hierarchies for monorepos cheap to run in CI.
Edge cases and failure modes
testpathsis ignored when a path argument is given.pytest tests/unitoverrides it entirely, which is correct but means a slow ad-hoc invocation is not evidence that the configuration is wrong.norecursedirsreplaces the default list rather than adding to it. Setting it without including.*,buildanddistre-enables walking those directories.- A pruned directory's conftest never loads. If a fixture lived there and something outside expected it, the failure is "fixture not found" rather than anything mentioning collection.
collect_ignorepaths are relative to the conftest that declares them, not to rootdir; a wrong relative path fails silently by simply not matching anything.- Import cost hides in
__init__.py. A test package whose__init__.pyimports the application makes every module under it expensive, and-X importtimeattributes the cost to the application rather than to the test package.
Keeping the saving from eroding
Collection time regresses quietly: someone adds a module-scope import, a new directory of fixture data appears, a service is added without being listed in testpaths. Three cheap habits keep the number where you left it.
Record the baseline in the repository. A one-line note in the testing README — "collection: 1.8s as of this commit" — turns a vague "feels slower" into a measurable claim, and makes the next investigation start from a number rather than from scratch.
Assert it in CI when the suite is large enough to care. A job step that runs --collect-only -q, times it, and fails above a threshold costs two seconds and catches the regression in the pull request that caused it:
$ /usr/bin/time -f "%e" pytest --collect-only -q >/dev/null
2.04
Review new conftest imports specifically. A conftest is imported for every test beneath it, so a module-scope import added there has a multiplier no other file has. Treating conftest changes as needing a second look — the way schema migrations do — costs nothing and prevents the most common regression.
Finally, re-check the configuration after any directory move. testpaths entries are literal paths, and a renamed service simply stops being collected: the suite goes green, faster than before, and nobody notices that a thousand tests disappeared. Asserting a minimum collected count in CI closes that gap, and is the same guard recommended for markers in pytest markers for conditional test execution.
A note on where collection time actually bites: it is paid on every invocation, including the ones that run a single test. An engineer running pytest tests/test_one.py::test_x in a tight loop pays the full import cost of that module's dependencies each time, which is why a two-second collection is felt as a slow feedback loop long before it shows up in CI totals. Pruning benefits that loop more than it benefits the nightly run, and that is the argument to make when the work needs justifying.
Note also that collection cost interacts with parallel execution: under pytest-xdist every worker collects independently, so a three-second collection with eight workers is twenty-four seconds of CPU before the first test runs. Pruning is therefore worth more in a parallel suite than in a serial one, which is the opposite of the intuition that parallelism hides fixed costs.
Frequently Asked Questions
What is the difference between norecursedirs and testpaths?testpaths sets where collection starts when no path argument is given, so pytest never walks anything outside it. norecursedirs prunes directories pytest would otherwise descend into during that walk. Setting testpaths is the larger win; norecursedirs handles the exceptions inside it.
Does --ignore speed up collection or just hide tests?
It genuinely prunes: an ignored path is never walked or imported, so its import cost disappears. Deselection with -k or -m happens after collection, so those flags do not save collection time at all.
Why is collection slow when I only have a few hundred tests?
Because collection time is dominated by importing test modules and their dependencies, not by the number of tests. A handful of modules that import a web framework at module scope can cost more than a thousand tests that import nothing.
Does --import-mode=importlib change collection cost?
Marginally, and in the right direction: it avoids the sys.path manipulation that prepend mode performs for every test package, and it removes the duplicate-basename collisions that force teams into __init__.py files they do not otherwise want. The saving is small compared with pruning the walk, but the mode is worth adopting for correctness alone, and new projects should start with it.
Related
- Optimizing test discovery — the full discovery pipeline these controls prune.
- pytest-xdist vs pytest-parallel performance comparison — why collection cost multiplies by worker count under parallel runs.
- Creating conftest.py hierarchies for monorepos — keeping per-service collection independent.
- CPU profiling with cProfile and py-spy — when the import cost needs attributing line by line.
← Back to Optimizing Test Discovery