Skip to content

Usage Guide

Requirements

Python 3.11+, and a git 2.24+ CLI available on PATH. Revisions are passed to git after --end-of-options (added in git 2.24) so that a ref can never be parsed as a command-line option.

Basic Usage

Activate the plugin by passing the --impacted flag along with --impacted-module to pytest:

pytest --impacted --impacted-module=my_package

This runs only the tests impacted by files with uncommitted changes (staged or unstaged, including deletions) and untracked files in your current git repository.

Note

The --impacted-module value must be a valid Python package name (underscores, not hyphens). If you accidentally use hyphens, the plugin will suggest the corrected name.

Tests not impacted

Tests no change impacts are deselected, as -k and -m deselect theirs: they are left out of the run and its reports (verbose output, JUnit XML), and the summary counts them (2 passed, 812 deselected). A line at the end of the run says how many were left out as not impacted, which under pytest-xdist, whose summary has no deselected count, is the only place it shows.

A run whose changes impact no test, such as a docs-only commit, exits with status 0, as does a shard a sharding plugin handed only deselected tests. pytest itself exits with status 5 when no test runs; that still happens when the plugin deselected nothing (-k matching no test, or no tests collected at all). A run another plugin empties after this one deselected some, such as --lf --lfnf=none with no failures recorded, exits with status 0 too.

Under pytest-xdist the analysis runs once, on the controller, and every worker filters by its result: workers analysing for themselves could disagree, and xdist stops a run whose workers collect different tests. Its notices are then shown too. With a strategy extension installed, with --tx workers, or when the analysis fails on the controller (a warning says so), each worker analyses for itself instead, as before 0.36, and marks the tests it finds unimpacted skipped, whatever --impacted-unimpacted says: tests skipped differently by two workers stop nothing, while tests collected differently would stop the run. The workers' notices are then not shown, as before.

To mark them skipped instead, as releases up to 0.35 did, pass --impacted-unimpacted=skip (ini: impacted_unimpacted = "skip"). Each then reports SKIPPED (not impacted by the changes). --impacted-unimpacted=deselect restores the default when the ini says skip.

Git Modes

Unstaged Mode (default)

Counts every uncommitted change — whether or not it has been staged with git add — plus untracked files. Deletions and renames count too: a removed conftest.py or lockfile still reaches the pattern-based strategies, and a rename is reported as a deletion plus an addition:

pytest --impacted --impacted-module=my_package --impacted-git-mode=unstaged

Branch Mode

Compares your current HEAD with the point where it forked from a base ref — git diff <base>...HEAD, the same diff a pull request shows. Commits the base branch gained after you branched are not your changes, so they no longer select tests:

pytest --impacted \
       --impacted-module=my_package \
       --impacted-git-mode=branch \
       --impacted-base-branch=main

The --impacted-base-branch flag accepts any valid git ref, including expressions like HEAD~4.

When there is no fork point — unrelated histories, or a shallow clone whose history stops short of it — pytest-impacted diffs against the base branch's tip instead, which still covers every change on your branch, and prints a warning saying which case applies. In CI, the base ref must be fetched at all: a stock actions/checkout (fetch-depth: 1) fetches only the checked-out commit, so --impacted-base-branch=origin/main is rejected as unknown. fetch-depth: 0 fetches everything; a shallow git fetch --depth=N origin main works too, falling back if the fork point is deeper than N. With several fork points (criss-cross merges) it combines the diffs from each. Fetch enough history (fetch-depth: 0) for the precise diff. To always diff against the tip (git diff <base> HEAD), pass --impacted-no-merge-base (ini: impacted_no_merge_base = true).

External Tests Directory

When your tests live outside the namespace package (a common project layout), use --impacted-tests-dir so the dependency graph includes them:

pytest --impacted \
       --impacted-module=my_package \
       --impacted-tests-dir=tests

The tests directory does not need to contain __init__.py — the plugin uses filesystem-based discovery that matches pytest's own behavior.

Monorepo / src-Layout Support

The plugin works in monorepos where the Python project lives in a subdirectory — the .git directory does not need to be in the project directory. Parent directories are searched automatically to find the git repository. Note: Changed files outside the rootdir subtree cannot be resolved to Python modules, so they never impact tests through import analysis — with one exception: an __init__.py in a directory above the rootdir, when every directory from it down to the rootdir is a package too, selects every test (see package __init__.py files). They are still seen by the file-pattern strategies: a sibling frontend/requirements.txt, or a file matching an --impacted-invalidate-all glob, still marks every test as impacted.

Paths are resolved against pytest's rootdir (or the CLI's --root-dir), not the directory you happen to run from, so --impacted-module and --impacted-tests-dir are relative to that root and running from a subdirectory works.

src-Layout Projects

For projects using the src-layout convention (e.g. src/my_package/), point --impacted-module at the full path including the src/ prefix:

# From the project directory (e.g. monorepo/backend/)
pytest --impacted \
       --impacted-module=src/my_package \
       --impacted-tests-dir=tests

The plugin automatically detects that src/ is not a Python package (no __init__.py) and uses the correct importable module name (my_package) for dependency analysis. This means AST-parsed imports like from my_package import ... will correctly match discovered modules.

Sub-directories of the package do not need an __init__.py either: Python imports them as namespace packages, so my_package/processors/ocr.py is analysed as my_package.processors.ocr whether or not processors/ has one — and test files kept in such a directory inside the package are found without --impacted-tests-dir.

The package directory itself may sit inside a namespace package too (src/company/my_package, with no __init__.py in company/). Imports such as from company.my_package import ... and from my_package import ... both match, whichever of the two you pass to --impacted-module, because the plugin cannot know which directory is on sys.path and accepts every candidate.

The same holds when a directory above the package is a package itself — a src/ that has an __init__.py and is on sys.path all the same (an editable install, or pythonpath = src). Both from my_package import ... and from src.my_package import ... then match (since 0.35; before, only the second did, and an edit under src/my_package selected no test importing it the first way). Pass the package itself, --impacted-module=src/my_package, not src: every directory above the one you pass may be where it is imported from, but inside it a directory with an __init__.py never is, so with --impacted-module=src only src.my_package... matches.

A flat directory of modules on pythonpath

Some projects keep top-level modules in a plain directory that pytest puts on sys.path with its pythonpath setting: pythonpath = lib, and lib/alpha.py imported as import alpha. Pass that directory as the module, --impacted-module=lib. Since 0.38 each pythonpath directory inside the rootdir (or above it) is also where imports are looked up, so import alpha matches lib/alpha.py (analysed as lib.alpha), as does any other module there, a sub-directory included (import tools.check). Before, an edit there selected no test that imported it by its bare name. The impacted-tests CLI reads pythonpath from the configuration too. An entry outside the rootdir that is not above it, such as a sibling library of a monorepo service, is like any other file outside the rootdir: an edit there selects no test through imports (list it in --impacted-invalidate-all). On a case-insensitive filesystem (macOS, Windows), spell each entry as the directory is named, as for --impacted-module.

Tip

If you accidentally pass just --impacted-module=my_package in a src-layout project, the plugin will detect that src/my_package exists and suggest the correct flag.

Monorepo Layout

In a monorepo where the Python project is nested under a subdirectory:

monorepo/              ← git root
  backend/             ← rootdir (pyproject.toml here)
    src/
      my_package/
    tests/
  frontend/

Run pytest from the backend/ directory as usual. The plugin will:

  1. Find the git repository by searching parent directories
  2. Convert git-root-relative file paths (e.g. backend/src/my_package/module.py) to rootdir-relative paths (e.g. src/my_package/module.py)
  3. Resolve only changes inside the rootdir subtree to modules — sibling changes (e.g. frontend/) contribute nothing to import analysis, though the dependency-file and invalidation-pattern strategies still see them (as absolute paths)

Impact Analysis Strategies

The plugin uses a modular, strategy-based architecture to determine which tests are affected by code changes. Strategies are composable — the default pipeline combines the built-in strategies below (InvalidationFileImpactStrategy only when configured).

ASTImpactStrategy

The core strategy. It uses static analysis to:

  1. Discover the package and all its submodules via filesystem scanning (no imports executed)
  2. Parse each source file's AST to extract import relationships
  3. Build a dependency graph with NetworkX
  4. Trace transitive dependencies from changed modules to test modules

The package's own __init__.py is a module too. A test that imports from the package root (import my_package or from my_package import name), itself or through a module that does (including from . import name in a top-level module), depends on the __init__.py and on everything it imports, directly or not. Editing the __init__.py, or any module it imports, selects that test.

Package __init__.py files. Python runs every package's __init__.py above a module before the module itself: from my_package.core.x import f runs my_package/__init__.py and my_package/core/__init__.py too, though it names neither. So (since 0.34) editing, adding or deleting an __init__.py — the package root's, a sub-package's, one in the tests directory, or one above --impacted-module (backend/__init__.py for backend/app) — counts as a change to every module inside its package. That selects every test importing anything inside the package, directly or not — even a module missing from it, as an optional import in try/except ImportError is — and, for tests kept inside the package or in a tests directory that is itself a package (with __init__.py), every test beneath it. The pytest rules see those modules as changed too: a conftest importing one selects the tests beneath it (for application code, only with --impacted-conftest-imports), and a package holding a pytest_plugins module selects every test. It is only the __init__.py itself that reaches the whole package: a module it imports (a re-export) is linked to the tests that import from the package root by name, not to every test importing a sub-module, although Python runs it for those too. An __init__.py above the rootdir (in a monorepo holding the project) changes nothing, since imports start inside the rootdir — unless every directory from it down to the rootdir is a package too, when pytest may import your test modules through it: then every test is selected.

Modules outside the analysed directories. An import that names no module in --impacted-module or --impacted-tests-dir is looked up on disk — never imported — under the rootdir and every directory the analysed modules' own names are rooted at (src/ for src/my_package, backend/ for a tests dir backend/tests whose modules import as tests.x), the directories in between that are not regular packages (no __init__.py), and every directory above --impacted-module or --impacted-tests-dir, a package or not (since 0.35; never one inside the analysed package). When such a directory is itself a package, or inside one, like src/ with an __init__.py, it is used for names that spell your package from it (my_package.x), and for other names only once your code imports the package that way somewhere — never for a standard-library name, since a logging.py in a directory really on sys.path would shadow the standard library. Each directory of pytest's pythonpath setting inside the rootdir or above it (since 0.38) is used for every name, standard-library names too, package or not, since pytest puts it on sys.path: a module there named like one of the standard library's may shadow it, unless that was imported first. Before importing a test module or conftest, pytest's default import mode puts its basedir on sys.path: the first directory from its own upward without an __init__.py. Since 0.38 imports are looked up in those directories too: import helpers and from test_orders import Order beside a test module, import helpers from below a tests/conftest.py beside tests/helpers.py, or from unit.helpers import h with a tests/unit/__init__.py and none in tests/. An edit to such a file selects the tests importing it, and so does deleting it. pytest keeps every such directory on sys.path for the session, and sys.modules keeps the first module of a name for every later import, so a bare name anywhere may mean the file of that name beneath any of them: each the walks found is linked, another service's tests/helpers.py too. A root conftest's pytest_plugins entries, imported before any other such directory is on sys.path, are looked up only in those above it. As with a guessed directory above, such an import never makes a module application code. A test module here is one the plugin calls one (named test_*/*_test, or under a test/tests directory). A file found there joins the graph and is followed like any other module, together with what it imports: a fixture helper in testing/factories.py imported by a conftest, a shared library in src/shared/ your package imports, or a pytest_plugins module kept outside both directories. It counts as a test module only if its path from the rootdir looks like one, as for any module. A local package named like a standard-library module (profile/, email/) is followed too, since with your project first on sys.path it shadows the standard library's; a distribution installed into the project directory (pip install -t ., with its .dist-info) is not. A changed Python file outside the analysed directories that no test depends on — a script, a migration, or a deleted module only untested code imported — is named in a notice, since import analysis selects no tests for it (a deleted file nothing imports needs none). If tests use it some other way, such as running it as a subprocess, list it in --impacted-invalidate-all.

Deleted modules. A deleted module that something still imports — inside a function, behind try/except ImportError, or through a module no test collects — selects the tests of whatever still imports it, so they fail, as they should, rather than being skipped. (An import at the top of a test module already fails its collection.)

PytestImpactStrategy

Extends the AST analysis with pytest-specific dependency detection:

  • conftest.py handling: When a conftest.py file is modified, all tests in the same directory and subdirectories are considered impacted — conftests above your package and tests directory, up to the pytest rootdir (usually the repository root), included. This is critical because conftest.py files are implicitly loaded by pytest at runtime and are not captured through static import analysis.
  • Test code a conftest imports: The same applies when a conftest imports changed test code, directly or through other modules: another conftest, or a fixture module or helper in --impacted-tests-dir. A shared helper imported by a top-level conftest therefore selects every test beneath it, just as editing that conftest does. Conftests above your package and tests directory count too: editing backend/conftest.py also selects the tests beneath any conftest that imports it. A helper outside both --impacted-module and --impacted-tests-dir that a conftest imports is followed too, as test code — unless application code imports it as well, which makes it application code (see modules outside the analysed directories and ConftestImportImpactStrategy below).
  • Application code a conftest imports — the rest of --impacted-module — is not followed into the conftest's directory by default. See ConftestImportImpactStrategy.
  • pytest_plugins modules: A module named in a pytest_plugins declaration is loaded by pytest, not imported by your tests, and pytest registers it for the whole session — its fixtures and hooks reach every test, wherever it was declared. So a change to a plugin module, or to anything it imports, impacts every test — as does a change to a plugin loaded with -p (on the command line or in addopts) or through PYTEST_PLUGINS. Unlike conftests, this includes application code a plugin imports, with or without --impacted-conftest-imports: keep fixture modules that import your application in conftests if that costs you full runs. Declarations are read from conftests, test modules and (transitively) the plugins themselves, in any form pytest accepts: a comma-separated string or a list/tuple, assigned, +=, .append/.extend, concatenated, including inside module-level if/try/match/loop blocks. Only literal strings are followed, not names computed at runtime; a plugin outside the analysed package and tests directory is followed when it is found on disk, like any imported module — so, as for a plugin inside them, application code it imports makes an edit to that code session-wide. A -p or PYTEST_PLUGINS plugin outside both directories that nothing imports is session-wide when the plugin file itself changes, but a module only it imports is not followed.

    Two limitations. Editing the declaration in a test module (rather than the root conftest.py, where any edit already selects every test) selects only that module, even though pytest registers the plugin session-wide — declare plugins in the root conftest. And the standalone impacted-tests CLI has no pytest session, so it knows PYTEST_PLUGINS but not -p options. - Designed to be extended with additional pytest-specific heuristics in the future.

ConftestImportImpactStrategy (opt-in)

Always in the default pipeline, but by default it only reports: a notice names the conftests that import changed application code. Enabled with --impacted-conftest-imports (ini: impacted_conftest_imports = true; impacted-tests CLI: --conftest-imports), it selects too: a conftest that imports changed application code, directly or through other modules, then impacts every test in its directory and below, exactly as an edited conftest does. A db fixture built on app/db.py means a change to app/db.py impacts every test under that conftest, even though no test imports app/db.py itself. Modules outside --impacted-module and --impacted-tests-dir are followed too when an analysed module imports them (see ASTImpactStrategy).

Application code is what discovering --impacted-module finds, less conftests and anything --impacted-tests-dir finds too. So if your tests live inside the package, pass --impacted-tests-dir (e.g. my_package/tests): without it their fixture modules count as application code, and a conftest importing them is only followed with this option. (A tests dir holding the whole package cannot tell the two apart and is ignored for this.) A module outside both directories that the graph follows is application code when application code imports it — a shared library — and test code otherwise, like a fixture helper; a deleted module is placed the same way, by what still imports it. An import that resolves only from a directory above the analysed ones that no module name is rooted at — a package itself, like src/ with an __init__.py (import shared for src/shared.py), or a directory inside one — does not count for this: whether that directory is on sys.path is a guess, so the module stays test code, which is followed. A directory of pytest's pythonpath (since 0.38) is certainly on sys.path, so a library found there that the application imports is application code, as one under the rootdir is — unless the directory is a guessed one of that kind (a src/ with an __init__.py above the package, on the pythonpath too), which keeps the rule above, as before 0.38. That includes a module deleted from the package that the application imported that way: a conftest reaching it selects the tests beneath it.

The trade-off

Tests never import their conftest — pytest injects its fixtures by name — so without this option, a test that uses changed application code only through a conftest fixture is not selected. This is a deliberate exception to erring on the side of caution, like the pytest_plugins limitations above. The option closes the gap, but coarsely: a top-level tests/conftest.py that imports your application (an app factory, a database session) makes almost every change select almost every test. It was the default in 0.31.0 and turned typical edits into full runs, which is why it is now opt-in.

Turn it on where missing a fixture-only dependency costs more than a longer run — for example in a pre-merge CI job. Pass it on that job's command line (--impacted-conftest-imports, or --conftest-imports for the impacted-tests CLI) rather than in the ini: an ini true cannot be switched off from the pytest command line except with -o impacted_conftest_imports=false (the CLI has --no-conftest-imports).

pytest --impacted --impacted-module=my_package --impacted-conftest-imports

DependencyFileImpactStrategy

Detects changes in dependency and configuration files. When these files change, any test could potentially be affected — so all test modules are marked as impacted.

Monitored files — each matched by file name, in any directory:

  • Lockfiles and metadata: uv.lock, poetry.lock, pdm.lock, pixi.lock, Pipfile, Pipfile.lock, pylock*.toml (PEP 751), requirements*.lock (rye), pyproject.toml, setup.py, setup.cfg
  • pytest configuration: pytest.ini, .pytest.ini, pytest.toml, .pytest.toml, tox.ini — settings such as addopts, markers and filterwarnings apply to every test
  • Requirements: any *requirements*.txt or *requirements*.in (requirements-dev.txt, test-requirements.txt, pip-tools inputs, …) and *constraints*.txt

Plus files inside a requirements/ directory, one or two levels deep: requirements/*.txt, requirements/*/*.txt, and the same for .in. And whichever config file the running pytest actually loaded — including one passed with -c under any name (pytest plugin only: the impacted-tests CLI has no pytest session).

This strategy is enabled by default. To disable it, use:

pytest --impacted --impacted-module=my_package --no-impacted-dep-files

Tip

This is especially useful in CI where dependency version bumps (e.g. updating uv.lock) don't change any .py files but could still break tests due to changed third-party behavior.

InvalidationFileImpactStrategy

Static import analysis cannot see that a test depends on a JSON fixture, a SQL schema, or a YAML config. This strategy is the user-extensible counterpart to the built-in dependency-file list: any changed file matching one of your --impacted-invalidate-all globs marks all tests as impacted, exactly as a pyproject.toml change does. It is only added to the pipeline when at least one pattern is configured.

The option is repeatable:

pytest --impacted --impacted-module=my_package --impacted-tests-dir=tests \
    --impacted-invalidate-all='*.json' \
    --impacted-invalidate-all='my_package/config/*.yaml'

pytest-bdd feature files

A .feature file is not Python, so an edit to one reaches no test through imports. Add --impacted-invalidate-all='*.feature' (or a narrower glob) to run the suite when scenarios change.

Or in pyproject.toml:

[tool.pytest.ini_options]
impacted_invalidate_all = ["*.json", "my_package/config/*.yaml"]

Patterns are matched against each changed file's path as reported to the strategies — rootdir-relative for files inside the pytest rootdir, absolute for files elsewhere in the repository — anchored at the end of the path (Python's PurePath.match semantics, with a recursive **):

  • *.json matches a JSON file at any depth
  • config/*.json matches JSON files directly inside any directory named config
  • schema.graphql matches that filename anywhere
  • * never spans a /, so fixtures/*.sql does not match fixtures/sub/x.sql
  • Write globs with /, which works on every platform. On Windows, \ also works as the separator where it reaches pytest as given: on the command line, or in a TOML list (["config\\*.json"]). pytest splits an ini value, or a single TOML string, as a shell would, which drops a lone \ (since 0.38.1, as for --impacted-map)
  • A glob Python cannot match (empty, . or ./) is a usage error, as for --impacted-map (since 0.38.1; before, it ended the run with an INTERNALERROR once a file changed)
  • ** as a whole path segment stands for any number of path segments, none included (since 0.38; before, it behaved like a single *):

    • fixtures/**/*.sql matches fixtures/x.sql and fixtures/a/b/x.sql
    • config/** matches every file in a config tree, at any depth — any directory named config, as above, so also services/a/config/x.yaml — and a path config itself: a submodule or a symlinked directory there, as git reports a change to it, but also a file of that name
    • inside a segment, ** is a plain *: a**b never spans a /

    These are Python 3.13's PurePath.full_match rules for **, except that full_match does not match config itself. A glob still matches every file it matched before 0.38, so ** only ever selects more tests; since that includes what PurePath.match matches, its rare differences between Python versions remain. A file outside the rootdir is matched by its absolute path, which names the directories above the repository too, so a relative ** glob can match one of those as well (src/** in a checkout under /usr/src): more tests, never fewer.

Note

This is independent of --no-impacted-dep-files, which only switches off the built-in dependency-file list. Custom invalidation patterns stay active either way.

Mapping files to the code they belong to

When a data file belongs to particular tests, invalidating every test is more than you need. --impacted-map GLOB=PATH (since 0.36) says which file it belongs to: a changed file matching GLOB counts as a change to PATH, a file or directory relative to the rootdir, and the run selects what an edit to PATH would:

PATH Selects
a test file that test file
a module the tests that import it, directly or through other modules (and, with --impacted-conftest-imports, the tests beneath a conftest importing it)
a package __init__.py the tests of every module in its package
a conftest.py every test in its directory and below
a directory what an edit to every file git knows in it and below would (tracked, or untracked and not ignored: a virtualenv git ignores is left out)
a dependency file (pyproject.toml, ...) every test
pytest --impacted --impacted-module=my_package --impacted-tests-dir=tests \
    --impacted-map='reports/*.json=tests/test_bench.py' \
    --impacted-map='schema/*.sql=my_package/db.py'

Or in pyproject.toml:

[tool.pytest.ini_options]
impacted_map = ["reports/*.json=tests/test_bench.py", "schema/*.sql=my_package/db.py"]

GLOB is matched as --impacted-invalidate-all globs are (see above); a glob may map to several paths, one entry each, and a mapped file another glob matches is mapped on. PATH must be relative and inside the rootdir, and is spelled as on disk, case included. A symlink stands for itself, as an edit to it is reported, unless it points at a directory, which it then stands for; a path through a symlinked directory reaches the file it leads to. One git knows no file at is named in a notice, since it selects nothing. The mapped paths join the changed files before any strategy runs, so every strategy, an extension's included, sees them as changed. The impacted-tests CLI takes --map GLOB=PATH.

CompositeImpactStrategy

Combines multiple strategies, deduplicating and sorting results. The default composition is:

CompositeImpactStrategy(
    [
        ASTImpactStrategy(),
        PytestImpactStrategy(),
        # Selects only with --impacted-conftest-imports; otherwise it reports
        ConftestImportImpactStrategy(report_only=True),
        DependencyFileImpactStrategy(),
        # Only present when --impacted-invalidate-all is set
        InvalidationFileImpactStrategy([...]),
    ]
)

Custom strategies and packaged extensions

The strategy system is fully extensible. You can plug in your own strategy programmatically (a one-off integration via the get_impacted_tests() API) or ship one as an installable package that pytest-impacted auto-discovers via Python entry points. Both paths share the same ImpactStrategy base class, lifecycle hooks, dependency graph, and helper utilities.

See the Extensions guide for the full reference: programmatic strategies, packaged-extension entry points, configuration options, lifecycle hooks (enrich_dep_tree, setup, teardown), the extension API helpers, the recommended cross-run cache convention, error handling, and testing patterns.

CI Integration

For CI pipelines where git analysis and test execution happen in separate stages, use the standalone impacted-tests CLI:

# Stage 1: identify impacted tests. Stop if it fails (e.g. exit 1 when git is
# unavailable) — an empty file must mean "nothing impacted", never "unknown".
impacted-tests --module=my_package --git-mode=branch --base-branch=origin/main > impacted_tests.txt || exit 1

# Stage 2: run only those tests. An empty list means nothing was impacted —
# guard it, or a bare `pytest` would run the whole suite.
if [ -s impacted_tests.txt ]; then
  pytest $(cat impacted_tests.txt)
else
  echo "No impacted tests."
fi

If your tests live outside the package, pass --tests-dir here too — the CLI needs it for the same reason the pytest flag does, otherwise the dependency graph will not contain your test modules:

impacted-tests --module=my_package --tests-dir=tests --git-mode=branch --base-branch=origin/main

impacted-tests options

Option Default Description
--module (required, here or in the configuration) Namespace (top-level) module for the package under test
--git-mode unstaged Git comparison mode: unstaged or branch
--base-branch main Base branch/ref for branch-mode comparison
--no-merge-base / --merge-base false In branch mode, diff against the base branch's tip instead of the fork point
--root-dir . Root directory of the project repository; --module and --tests-dir are relative to it
-c, --config-file (found as pytest finds it) The pytest configuration file to take defaults from
--tests-dir None Directory containing test files outside the namespace module
--verbose false Verbose output (written to stderr, so it will not pollute the piped test list)
--no-dep-files / --dep-files false Disable dependency and test-config file change detection
--invalidate-all [] Glob for files that, when changed, mark all tests as impacted (repeatable)
--map [] GLOB=PATH: a changed file matching GLOB counts as a change to PATH (repeatable)
--conftest-imports / --no-conftest-imports false Also mark every test beneath a conftest.py that imports changed application code as impacted
--disable-ext [] Disable a strategy extension by name (repeatable)
--impacted-ext-{ext}-{option} (per extension) Set a config option on an installed extension; it takes a value, true or false for a boolean. Run impacted-tests --help to list them

Settings from the pytest configuration

An option left off the command line takes its pytest setting, the one the plugin reads: impacted_module for --module, impacted_git_mode, impacted_base_branch, impacted_no_merge_base, impacted_tests_dir, no_impacted_dep_files, impacted_invalidate_all, impacted_map, impacted_conftest_imports, impacted_disable_ext, and impacted_ext_{ext}_{option} for an extension's option. Only then does it take the default above. A flag given wins, and each boolean has a flag to turn it off (--no-conftest-imports), so an ini true can be overridden.

The settings come from the file pytest would read for a run from --root-dir: the nearest directory, from there up, holding a pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml with a [tool.pytest] or [tool.pytest.ini_options] table, tox.ini with [pytest], or setup.cfg with [tool:pytest], in that order. (pytest 8 reads no pytest.toml and no [tool.pytest], and neither then does the CLI.) -c FILE names one instead, as pytest's -c does. With pytest 9.2 and later, as with pytest, a regular file with a suffix other than .toml, .ini or .cfg is a usage error (a path that is no regular file, such as -c /dev/null, reads as no configuration), and a .toml file not named pytest.toml, .pytest.toml or pyproject.toml is read from its [pytest] table, as a pytest.toml is, unless it has [tool.pytest] tables; options outside any table of a pytest.toml, .pytest.toml or such a file are a usage error, not passed over. The CLI prints the file it read as config-file:. A file it cannot read is a usage error, as it is for pytest, rather than passed over with its settings lost. Paths in the settings are relative to --root-dir, whichever directory the file is in, as they are for pytest --rootdir=<root-dir>: running from a service directory of a monorepo, --module and impacted_module both name a package there. Only these settings are read, and pytest's own pythonpath (since 0.38), printed as pythonpath:, whose directories are where imports are looked up, as in the plugin; its paths are relative to the configuration file's directory, as in pytest, not to --root-dir, and one neither inside --root-dir nor above it holds no module of the project. Not addopts, and no -o overrides; nor is any conftest loaded, as the CLI runs no project code.

Configuration via pyproject.toml

All pytest options can be set as defaults in your pyproject.toml (or pytest.ini). The impacted-tests CLI reads them too (since 0.36), from the file pytest would read for a run from --root-dir, so a CI script need not repeat them as flags. See where the CLI reads them.

[tool.pytest.ini_options]
impacted = true
impacted_module = "my_package"
impacted_git_mode = "branch"
impacted_base_branch = "main"
impacted_tests_dir = "tests"
no_impacted_dep_files = false  # set to true to disable dep file detection
impacted_invalidate_all = ["*.json"]  # non-Python files that should trigger every test
impacted_map = ["reports/*.json=tests/test_bench.py"]  # a changed data file counts as a change to the file it belongs to
impacted_conftest_imports = false  # true: a conftest importing changed app code selects every test beneath it
impacted_unimpacted = "deselect"  # "skip": mark tests not impacted skipped instead of deselecting them
impacted_no_merge_base = false  # true: branch mode diffs against the base tip
impacted_disable_ext = []  # extension names to disable

Command-line flags override these defaults. A boolean set to true here has no flag to turn it off; override it with -o, e.g. -o impacted_conftest_imports=false.

Input Validation

The plugin validates configuration early and provides helpful error messages:

Scenario What happens
--impacted-module=my-package (hyphens) Suggests my_package if it exists
--impacted-module=my_package (src-layout) Suggests src/my_package if found under src/
--impacted-module=nonexistent Clear error naming the rootdir the package was looked for under
--impacted-tests-dir=bad_path Error indicating the directory doesn't exist
--impacted-base-branch=no_such_branch Error listing available git refs
--impacted-base-branch=--some-option Rejected before reaching git — refs may not begin with -, since git would parse them as options rather than revisions
No git repository found (branch mode) Clear error naming the rootdir searched: no .git found at or above it
No git repository found (unstaged mode) Not validated up front — the failure surfaces from GitPython during collection
git executable not installed (or GitPython missing) Fails open: every test runs. If git was missing when the plugin loaded, a WARNING line in the report header says so; if it fails only when invoked, a terminal warning does. The impacted-tests CLI exits with status 1 instead of printing an empty list. When git is available, a missing repository is different — that is a configuration error and stays one

All Options

Option Default Description
--impacted false Enable the plugin
--impacted-module (required) Top-level Python package to analyze
--impacted-git-mode unstaged Git comparison mode: unstaged or branch
--impacted-base-branch (required for branch mode) Base branch/ref for branch-mode comparison
--impacted-no-merge-base false In branch mode, diff against the base branch's tip instead of the fork point
--impacted-tests-dir None Directory containing tests outside the package
--no-impacted-dep-files false Disable dependency and test-config file change detection
--impacted-invalidate-all [] Glob for files that, when changed, mark all tests as impacted (repeatable)
--impacted-map [] GLOB=PATH: a changed file matching GLOB counts as a change to PATH, a file or directory (repeatable; details)
--impacted-conftest-imports false Also mark every test beneath a conftest.py that imports changed application code as impacted (details)
--impacted-unimpacted deselect What becomes of tests not impacted: deselect them, or skip them (details)
--impacted-disable-ext [] Disable a strategy extension by name (repeatable)
--impacted-ext-{ext}-{option} (per extension) Set a config option on an installed extension. Installed extensions register their own flags — run pytest --help to list them, or see the Extensions guide

How It Works (Pipeline)

graph LR
    A[Git diff] --> B[Changed files]
    B --> C[Module resolution]
    C --> D[AST import parsing]
    D --> E[Dependency graph]
    E --> G[Impacted tests]
    E --> P[conftest / plugin rules]
    P --> G
    B --> F[Dep file detection]
    F -->|uv.lock, requirements*.txt, pytest.ini, etc.| G
    B --> H[Invalidation patterns]
    H -->|--impacted-invalidate-all| G
    B --> M[File mappings]
    M -->|--impacted-map| C
  1. Git introspection identifies which files changed (uncommitted edits staged or not, untracked files and deletions, or a branch diff)
  2. Filesystem discovery maps file paths to Python module names — without importing anything; a module outside the analysed directories joins when an analysed module imports it, and a deleted one reaches whatever still imports it
  3. AST parsing (via astroid, or the optional Rust extension using ruff's hand-written recursive descent parser) extracts import relationships from source files
  4. Dependency graph (via NetworkX) traces transitive dependencies from changed modules to test modules; a changed package __init__.py counts as a change to every module in its package, since Python runs it for any import from it
  5. pytest-aware rules — a conftest.py that changed, or imports changed test code, selects every test in its directory and below — and with --impacted-conftest-imports, so does one importing changed application code; a change reaching a pytest_plugins, -p or PYTEST_PLUGINS plugin selects every test
  6. Dependency file detection — if files like uv.lock, requirements*.txt, pyproject.toml or pytest.ini changed, all tests are marked as impacted regardless of import analysis
  7. Invalidation patterns — user-declared globs for non-Python files that static analysis cannot see (see InvalidationFileImpactStrategy), or user-declared mappings from them to the file they belong to (see Mapping files to the code they belong to)
  8. Test filtering deselects tests whose modules are not in the impact set (or skips them, with --impacted-unimpacted=skip) — a test counts as belonging both to the file it was collected from and to the file it is defined in, so inherited test methods and pytest-bdd scenarios are covered; doctests and tests collected from non-Python files (e.g. --doctest-glob) always run

The philosophy is to err on the side of caution: false positives (running a test that didn't need to run) are preferred over false negatives (missing a test that should have run). The few deliberate exceptions are documented where they apply — most notably application code used only through a conftest fixture.

Performance: Rust Acceleration

For large codebases with many modules, import parsing can become a bottleneck. An optional Rust extension can provide up to 37-65x faster import parsing on some large codebases (results vary by project and environment) using ruff's Python parser and rayon for parallel file processing.

Installation

Install with the fast extra to get pre-built Rust wheels (no Rust toolchain needed):

pip install pytest-impacted[fast]

Or with uv:

uv add pytest-impacted[fast]

For development from source (requires a Rust toolchain and maturin):

pip install maturin
cd rust && maturin develop --release

On macOS 27 and later, build with Rust 1.98 or newer (rustup update). An extension built with an older toolchain does not load there, and pytest-impacted falls back to the pure-Python parser with no warning: backend= in the pytest-impacted: line of the report header shows which parser is in use. After updating Rust, build the extension again; in a checkout of this repository set up with uv, that is uv sync --all-extras --dev --reinstall-package pytest-impacted-rs, because a plain uv sync does not rebuild it.

How It Works

When the Rust extension (pytest_impacted_rs) is installed, build_dep_tree() automatically uses parallel batch parsing instead of sequential astroid parsing. No configuration or flags are needed — the extension is detected at import time.

Both backends extract the same imports from the same source, so switching backends does not change which tests run, with one known exception, grammar: astroid parses with the running interpreter's grammar, while ruff's parser accepts newer syntax on any interpreter. A module using syntax newer than your Python (for example PEP 695 type aliases on 3.11) logs a syntax-error warning and contributes no edges on the pure-Python backend; every test that imports it is then selected only through other paths.

The Rust extension:

  1. Reads all source files in parallel via rayon
  2. Parses Python ASTs using ruff's hand-written recursive descent parser (the same parser used by the ruff linter)
  3. Extracts import statements by recursively walking all statement bodies (if, try/except/else/finally, with, for, while, match/case, function and class blocks)
  4. Returns one dict[str, list[str]] mapping module name to its imports — only the final data crosses the Rust/Python boundary

Benchmarks

Run the included benchmark script to measure speedup on your codebase:

python -m benchmarks.bench_parsing --module my_package --tests-dir tests

Note

The Rust extension is completely optional. When not installed, the pure-Python (astroid) implementation is used automatically. Apart from the grammar difference above, both modes select the same tests.