Article summary

TL;DR

  • A pytest hook is a framework callback that runs at a defined point in configuration, collection, test execution, or reporting.
  • Use fixtures to supply test data and resources; use hooks to change or observe pytest itself.
  • A local conftest.py can add CLI options, register markers, filter collected tests, inspect reports, and extend terminal output.
  • Use @pytest.hookimpl(wrapper=True) when code must run around other implementations of the same hook.
  • Keep hook signatures small, test the plugin behavior, and account for other plugins and distributed workers before sharing the hook across projects.

01 / EXTENSION POINT

Pytest hooks let you change the test runner, not the test.

A growing pytest suite eventually needs behavior that does not belong inside an individual test. You may want a project-specific command-line option, automatic marker handling, a collection policy, or a compact failure report for CI.

Copying that logic into test functions makes every test harder to read. Hiding it in an autouse fixture can be just as confusing when the behavior actually belongs to the runner.

That is the job of pytest hooks.

A pytest hook is a function that pytest calls at a defined point in its lifecycle so a plugin can observe or change framework behavior.

This tutorial assumes you can already write and run ordinary pytest tests. If that is not true yet, start with the beginner tutorial for your first pytest test, then return when assert, discovery, and basic failures feel familiar.

We will build one local plugin with five practical hook functions:

  1. pytest_addoption to add --env;
  2. pytest_configure to register an env marker;
  3. pytest_collection_modifyitems to skip tests for other environments;
  4. pytest_runtest_makereport to record failed test calls;
  5. pytest_terminal_summary to print a triage list.

The result is small enough to understand in one sitting, but it exercises configuration, collection, execution, reporting, hook wrappers, and shared plugin state.

02 / LIFECYCLE

Pick a hook by the event you need to intercept.

Pytest implements much of its own behavior through plugins. A hook specification defines a function name, accepted arguments, when it runs, and what its result means. A hook implementation is your function for that specification.

Several plugins can implement the same hook. The call is usually one-to-many: pytest asks every registered implementation to participate, then combines their results according to the hook specification.

The five hooks in this tutorial sit at different points in a run:

startup
  pytest_addoption
  pytest_configure

collection
  pytest_collection_modifyitems

each test
  setup    -> pytest_runtest_makereport(when="setup")
  call     -> pytest_runtest_makereport(when="call")
  teardown -> pytest_runtest_makereport(when="teardown")

finish
  pytest_terminal_summary

This is a working map, not a complete pytest hooks list. The official hook reference also includes bootstrapping, test generation, debugging, assertion, collection, runtest, and reporting hooks.

These are the hook families most application test suites reach for first:

Hook When to use
pytest_addoption Add project-specific command-line or configuration options.
pytest_configure Register markers or initialize plugin state after options are parsed.
pytest_generate_tests Generate parameter sets during collection.
pytest_collection_modifyitems Mark, reorder, skip, or deselect collected test items.
pytest_runtest_setup, pytest_runtest_call, and pytest_runtest_teardown Run logic during setup, during the test call, or during teardown.
pytest_runtest_makereport Inspect the report for a setup, call, or teardown phase.
pytest_terminal_summary Add human-readable output at the end of a terminal run.
pytest_sessionfinish React when the complete session finishes or adjust its exit status.

Choose from the event you need, then read that hook’s exact specification. Similar names can expose different objects or run at different times.

Hook arguments are not ordinary dependency injection. Pytest validates their names against the specification when it registers the plugin. You can omit arguments you do not use, which is why this implementation is valid even though the full specification also provides session:

def pytest_collection_modifyitems(config, items):
    ...

Pytest passes only the named arguments the function requests. This dynamic argument pruning helps existing plugins remain compatible when a hook specification gains a new argument. A misspelled or invented argument fails plugin validation instead of quietly receiving None.

03 / HOOK OR FIXTURE

Use a fixture for test dependencies and a hook for runner behavior.

Hooks and fixtures can both run code around tests, but they solve different problems. The useful distinction is who needs the behavior.

Requirement Better tool Why
Give a test a database record, client, clock, or temporary directory Fixture The dependency is part of the test scenario and can be named in its signature.
Create and clean up a resource for selected tests Yield fixture Setup and teardown stay attached to the tests that request the resource.
Add a CLI option or register project configuration Hook The behavior changes pytest before test functions run.
Mark, reorder, skip, or deselect collected tests by policy Collection hook The decision applies to pytest’s item collection.
Observe pass, fail, and skip reports Reporting hook The final outcome belongs to pytest’s execution and reporting lifecycle.
Generate cases from a CLI value pytest_generate_tests hook Parametrization must be decided while tests are being collected.

Do not add a fixture name to a hook signature. Hook parameters come from the hook specification, not from pytest’s fixture resolver. Although a late runtest hook can inspect state on an item, reaching into item.funcargs couples the plugin to test setup and is rarely a good way to request a dependency.

A practical rule is simple: if the test consumes a value, start with a fixture. If pytest itself must react to an event, look for a hook.

The same preference for visible dependencies applies beyond hooks. The Python unit testing best-practices guide explains when explicit fixtures help and when a local variable is clearer.

04 / DEMO PROJECT

Start with two tests and one environment marker.

The complete example is available in the istranin.dev code examples repository under pytest-hooks-examples/. It is a self-contained uv project that locks pytest 9.1.1 and targets Python 3.13.

pytest-hooks-examples/
├── checkout.py
├── conftest.py
├── failure_example.py
├── test_checkout.py
├── test_hooks.py
├── pyproject.toml
└── uv.lock

The application behavior is deliberately small:

# checkout.py
def checkout_message(environment: str) -> str:
    """Return the checkout status for an environment."""
    return f"checkout ready in {environment}"

One test is valid everywhere. The other should run only when the suite selects the staging environment:

# test_checkout.py
import pytest

from checkout import checkout_message


def test_checkout_is_available_locally() -> None:
    assert checkout_message("local") == "checkout ready in local"


@pytest.mark.env("staging")
def test_checkout_is_available_in_staging() -> None:
    assert checkout_message("staging") == "checkout ready in staging"

There is no custom decorator or helper to call from the tests. The marker describes a property of the test. The local plugin in conftest.py will decide what that property means for collection.

05 / CONFIG AND COLLECTION

Combine startup and collection hooks into one useful policy.

Put the first three hook functions in the repository-level conftest.py:

import pytest


def pytest_addoption(parser: pytest.Parser) -> None:
    """Add the environment selector used by this test suite."""
    parser.addoption(
        "--env",
        action="store",
        default="local",
        choices=("local", "staging"),
        help="run tests for the selected environment",
    )


def pytest_configure(config: pytest.Config) -> None:
    """Register the environment marker in pytest's active configuration."""
    config.addinivalue_line(
        "markers",
        "env(name): run the test only for the named environment",
    )


def pytest_collection_modifyitems(
    config: pytest.Config,
    items: list[pytest.Item],
) -> None:
    """Skip tests whose environment marker does not match ``--env``."""
    selected_env = config.getoption("--env")

    for item in items:
        env_marker = item.get_closest_marker("env")
        if env_marker is None:
            continue

        required_env = env_marker.args[0]
        if required_env != selected_env:
            item.add_marker(
                pytest.mark.skip(reason=f"requires --env={required_env}")
            )

pytest_addoption runs early enough to make --env part of pytest’s parser. The choices constraint rejects unsupported values before collection begins.

pytest_configure registers the marker description. This prevents unknown-marker warnings and makes pytest --markers explain the project convention.

pytest_collection_modifyitems runs after collection. It receives the item list, finds the closest env marker on each item, and adds a normal skip marker when the selected environment does not match. Unmarked tests are left alone.

Run only the demonstration tests with the default environment:

$ uv run pytest test_checkout.py -q
.s                                                                       [100%]
1 passed, 1 skipped

Select staging and both tests run:

$ uv run pytest test_checkout.py -q --env=staging
..                                                                       [100%]
2 passed

This example skips nonmatching items so the report shows that they exist. If you remove items from the collection instead, report them through config.hook.pytest_deselected(items=deselected) so pytest and other plugins receive the correct deselection event.

Basic hook implementations do not need @pytest.hookimpl. Their pytest_* names are enough. The marker becomes useful when you need options such as wrapper, tryfirst, trylast, or specname.

06 / FAILURE REPORTS

Wrap report creation and keep shared state on the pytest config.

Now add the two reporting hooks. The first observes the report created for each test phase. The second adds the collected failures to the terminal summary.

from collections.abc import Generator

import pytest


FAILED_TESTS = pytest.StashKey[list[str]]()


@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(
    item: pytest.Item,
    call: pytest.CallInfo[None],
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
    """Remember failed test calls after other report hooks have run."""
    report = yield

    if report.when == "call" and report.failed:
        failed_test_ids = item.config.stash.setdefault(FAILED_TESTS, [])
        failed_test_ids.append(report.nodeid)

    return report


def pytest_terminal_summary(
    terminalreporter: pytest.TerminalReporter,
    config: pytest.Config,
) -> None:
    """Add a compact failure list to the end of terminal output."""
    failed_test_ids = config.stash.get(FAILED_TESTS, [])
    if not failed_test_ids:
        return

    terminalreporter.section("failed tests for triage")
    for nodeid in failed_test_ids:
        terminalreporter.write_line(nodeid)

pytest_runtest_makereport is called after setup, call, and teardown. Filtering on report.when == "call" means the list contains failed test bodies, not fixture setup or teardown errors. Whether that is correct depends on your reporting requirement; a CI artifact collector may want all three phases.

The wrapper yields exactly once. Code before yield runs before later hook implementations. The yielded expression receives the report after the wrapped implementations finish, so this plugin can inspect the outcome without constructing a report itself. Returning the report preserves the result for the rest of the call chain.

pytest.StashKey gives the plugin a private typed key on pytest’s config object. It avoids a custom attribute name that may collide with another plugin. The state still belongs to one pytest process. A plugin that must aggregate failures from pytest-xdist workers needs an explicit worker-to-controller data path rather than assuming one shared stash.

The package includes failure_example.py, which pytest ignores during normal discovery. Run it explicitly to see the extra section:

$ uv run pytest failure_example.py -q
...
=========================== failed tests for triage ============================
failure_example.py::test_inventory_count
...
1 failed

The command exits with status 1. Custom reporting should never turn a real failure into a successful process.

07 / ORDER AND SAFETY

Treat every hook as part of a larger plugin chain.

The difficult part of pytest hooks is not memorizing names. It is remembering that your implementation can run beside built-in plugins, third-party plugins, and other conftest.py files.

For a single hook call, the useful ordering model is:

  1. hook wrappers run until yield;
  2. regular implementations marked tryfirst=True run early;
  3. unmarked regular implementations run;
  4. regular implementations marked trylast=True run late;
  5. wrappers resume after yield in reverse nesting order.

tryfirst and trylast influence order within the relevant group. They do not make one plugin the permanent owner of a hook. Avoid designs that depend on an unspecified order between two unmarked implementations.

Some hook specifications use firstresult=True. For those hooks, pytest stops after the first implementation returns a non-None value. That behavior belongs to the specification, not to the implementation. Read the reference before returning a value from a hook that can replace pytest behavior.

There are several other constraints worth keeping close:

  • Pytest validates hook argument names when the plugin is registered.
  • Hook functions other than pytest_runtest_* should not raise exceptions, because an exception can break the entire run.
  • A conftest.py hook is visible only within that file’s directory scope and descendants.
  • Some very early startup hooks cannot be implemented in a later-loaded conftest.py.
  • Current pytest documentation uses wrapper=True; older examples may show the older hookwrapper=True protocol with different result handling.

Start in a root conftest.py when the behavior is local to one test suite. Move it into an installable plugin when several repositories need it, when configuration grows, or when compatibility with other plugins becomes a product requirement. The official plugin guide covers discovery and packaging.

Plugin behavior deserves tests too. The companion package uses pytest’s pytester plugin to run a deliberately failing test in isolation and assert that the custom triage section appears. This protects the user-visible report instead of unit-testing the generator one line at a time.

Only declare a new hook specification with pytest_addhooks when other plugins genuinely need to extend your plugin. Most application test suites need to implement existing pytest hooks, not invent another plugin API.

08 / NEXT STEP

Add the smallest hook that removes a real test-suite problem.

Before adding a hook, walk through this sequence:

  1. Name the pytest event you need to observe or change.
  2. Check whether an explicit fixture, marker, or configuration file solves the problem more clearly.
  3. Read the hook specification, including its arguments, return behavior, and timing.
  4. Implement only the arguments you use in a local conftest.py.
  5. Keep frequent hooks free of unnecessary network, filesystem, or database work.
  6. Test the visible outcome through a real pytest run.
  7. Exercise the plugin beside the third-party plugins and execution modes your CI actually uses.

The best first hook is usually not a custom collector or a new plugin framework. It is a small response to a repeated problem: one missing command option, one noisy collection rule, or one report detail that developers keep reconstructing by hand.

Continue from pytest runner customization to fixtures, parametrization, mocking, coverage, and CI in the complete Pytest course.

View course

Implement that one hook, run the suite, and make its effect obvious. If the behavior later becomes useful outside the repository, the tested local plugin gives you a clean starting point for extracting it.