Pytest & CI

Cutting pytest Collection Time with norecursedirs

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, norecursedirs and collect_ignore behave 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:

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

Python
# 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.

Where each control prunes A left-to-right pipeline of collection: pytest determines the start directories, walks them recursively, imports each matching module, and builds test items, with testpaths pruning the first stage, norecursedirs and ignore pruning the walk, and deselection acting only after items exist. Where each control prunes start dirs testpaths directory walk norecursedirs, --ignore import modules the expensive part build items -k and -m act here An imported module stays imported — its cost is paid even if every test in it is deselected.
The first two stages are where time is saved; filtering at the last stage shortens the run but not the collection.

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.

Effect of each control on a 4,800-test monorepo A bar chart of collection time after applying each control cumulatively: the unconfigured baseline, after setting testpaths, after adding norecursedirs, and after deferring heavy module-scope imports into fixtures. Effect of each control on a 4,800-test monorepo unconfigured baseline 11.2 s + testpaths 5.3 s + norecursedirs 4.2 s + deferred imports 1.8 s Measured with pytest --collect-only -q, warm page cache, on one repository.
Most of the remaining cost after pruning is import time, which no amount of directory configuration can remove.

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:

Bash
$ 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:

Python
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.

Which control to reach for A table of four pruning controls - testpaths, norecursedirs, collect_ignore and the ignore flag - describing what each skips, where it is configured, and whether the decision can depend on the environment. Which control to reach for Criterion Skips Conditional? testpaths everything outside it static norecursedirs directories in the walk static collect_ignore paths, from a conftest yes, in Python --ignore / --ignore-glob paths, for one run per invocation
Static configuration covers the permanent structure; collect_ignore is for the cases that depend on what is installed.

Edge cases and failure modes

  • testpaths is ignored when a path argument is given. pytest tests/unit overrides it entirely, which is correct but means a slow ad-hoc invocation is not evidence that the configuration is wrong.
  • norecursedirs replaces the default list rather than adding to it. Setting it without including .*, build and dist re-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_ignore paths 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__.py imports the application makes every module under it expensive, and -X importtime attributes 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:

Bash
$ /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.

← Back to Optimizing Test Discovery