Async & Concurrency

Configuring asyncio_mode: auto versus strict

A suite reports 340 passed, and eleven of those tests never executed a line of their body. That is the failure asyncio_mode exists to prevent in one direction and can cause in the other: in strict mode a coroutine test without the marker is not run by the plugin at all, and in auto mode pytest-asyncio claims coroutine tests that another plugin was meant to handle. Setting it deliberately — and in the right place — takes two lines and removes both.

Prerequisites

  • pytest >= 8.0 and pytest-asyncio >= 0.24.
  • Knowledge of which async plugins the environment has; pip list | grep -Ei "asyncio|anyio|trio|tornasync" answers it.
  • The loop-lifetime rules from pytest-asyncio in depth, which are configured separately.

Solution

Declare the mode in the project's configuration, and override it for any subtree that belongs to a different plugin.

TOML
# pyproject.toml — the default for the whole repository
[tool.pytest.ini_options]
# auto: every `async def test_*` and async fixture is claimed automatically,
# so no test needs @pytest.mark.asyncio.
asyncio_mode = "auto"
# Pin this too: leaving it unset means a plugin upgrade can move every
# fixture onto a different loop without any change in your repository.
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
INI
; tests/anyio/pytest.ini — this subtree belongs to AnyIO
[pytest]
; strict: pytest-asyncio claims nothing here unless explicitly marked,
; leaving the anyio plugin free to collect these coroutine tests.
asyncio_mode = strict
Python
# tests/anyio/conftest.py
import pytest


@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
    # Safe now: pytest-asyncio is in strict mode here and ignores these tests.
    return request.param

The split is the whole technique. One mode for the bulk of the suite, a narrower one where a different runner owns the tests, and no per-test markers anywhere.

What each collection mode claims Three columns of coroutine tests. In strict mode only tests carrying the asyncio marker are claimed and the rest are errors or skips. In auto mode every coroutine test and async fixture is claimed, including ones intended for another plugin. A per-directory override restores strict mode for the AnyIO subtree so both plugins coexist. Collection, not execution strict (default) claims: marked tests only unmarked coroutine test → error since pytest 8.4 every test needs @pytest.mark.asyncio safe beside other plugins auto claims: every coroutine test and async fixture no markers needed including ones meant for anyio or trio right for asyncio-only suites auto + local override root: auto tests/anyio: strict both plugins coexist nearest config wins no per-test markers the arrangement to copy
The third column is the one to adopt in any repository that has both plugins. The first two are complete answers only when exactly one async plugin is installed.

Why this works

asyncio_mode is consulted during collection, when pytest-asyncio decides which items to wrap with its own pytest_pyfunc_call implementation. In strict mode it inspects each coroutine item for the asyncio marker and passes on anything unmarked; in auto mode it claims every coroutine function and every async generator fixture it sees. Because pytest reads configuration from the nearest applicable file, a pytest.ini inside a subdirectory changes that decision for exactly that subtree, which is what lets one repository host two runners.

Execution is untouched by the setting. Which loop runs a test, and how long that loop lives, comes from loop_scope on the marker or the fixture. Conflating the two is the most common misreading: switching to auto does not change any test's loop, and switching loop_scope does not change what is collected.

Edge cases and failure modes

  • Two plugins, one module. Putting an AnyIO-parametrised test in a directory governed by auto mode means pytest-asyncio may claim it first, and the anyio_backend fixture is then never used. Separate directories, separate modes.
  • Markers left behind after switching to auto. They are harmless but misleading, and --strict-markers will not flag them since asyncio is a real marker. Remove them in the same change.
  • asyncio_mode on the command line. -o asyncio_mode=auto works, but an IDE's test runner will not pass it, so the same test behaves differently depending on how it was launched. Configuration only.
  • Async fixtures in auto mode that nothing awaits. In auto mode an async generator fixture is claimed even if every test using it is synchronous, which produces a confusing "coroutine was never awaited" at setup. Make the fixture synchronous if it does not await.
  • Upgrading from 0.21 or earlier. auto mode existed, but async fixtures were handled differently; combine the mode change with removing any event_loop override rather than doing them separately.

Deciding which mode a repository should use

The decision is nearly mechanical once the inventory is done.

If pytest-asyncio is the only async plugin installed, use auto. It removes a decorator from every async test, removes the class of bug where somebody forgets one, and has no downside — there is no other plugin for it to steal tests from.

If anyio, pytest-trio or pytest-tornasync is also present, decide which one owns the majority of the suite. That one gets the root configuration; the minority gets a subdirectory with an overriding configuration file. Trying to keep both in strict mode and marking every test individually works, but it relies on every future test being marked correctly, which is precisely the discipline auto exists to remove.

A wrinkle worth checking: anyio ships a pytest plugin that is active whenever anyio is importable, and anyio is a transitive dependency of httpx, starlette and several other common packages. Many teams have it installed without knowing, which makes auto mode riskier than it appears.

Bash
# What is actually registered, as opposed to what you think is installed.
pytest --trace-config 2>&1 | grep -i "plugin registered" | grep -Ei "asyncio|anyio|trio"
Plain text
plugin registered: <module 'anyio.pytest_plugin'>
plugin registered: <module 'pytest_asyncio.plugin'>

Two registered async plugins with auto mode at the root is the configuration that produces silently skipped tests. Either disable the one you do not use — -p no:anyio in addopts — or adopt the per-directory split above.

Choosing a mode from the installed plugin inventory A decision path. If pytest-asyncio is the only async plugin registered, choose auto mode everywhere. Otherwise decide which runner owns most of the suite, give that one the root configuration in auto mode, and add a strict-mode configuration file in the minority runner's directory. Start from what is registered, not from preference More than one async plugin registered? no asyncio_mode = auto everywhere, no markers nothing to collide with yes split by directory majority runner: root config minority: strict in its subtree or disable one with -p no:anyio
The inventory step is the one people skip, and anyio arriving as a transitive dependency of httpx is the usual surprise.

Verifying the arrangement before trusting it

Two commands confirm that every async test is collected exactly once and by the plugin you intended.

Bash
# 1. Nothing is silently unclaimed: no warnings about coroutines.
pytest -q -W error::RuntimeWarning --collect-only 2>&1 | tail -5

# 2. The AnyIO subtree is parametrised by backend, and the rest is not.
pytest tests/anyio --collect-only -q | head -4
pytest tests/unit --collect-only -q | head -4
Plain text
tests/anyio/test_streams.py::test_send_receive[asyncio]
tests/anyio/test_streams.py::test_send_receive[trio]
tests/unit/test_client.py::test_fetch_returns_json
tests/unit/test_client.py::test_fetch_raises_on_404

Backend suffixes in one tree and not the other is the shape that says both plugins are collecting the tests they own. A missing suffix in tests/anyio means pytest-asyncio claimed those tests despite the override — usually because the override file is in the wrong directory, or because pyproject.toml's [tool.pytest.ini_options] at the root is being treated as the rootdir configuration and the subdirectory file is not a recognised configuration filename.

The last point is worth stating precisely: pytest recognises pytest.ini, pyproject.toml, tox.ini and setup.cfg as configuration files, but only one of them is used per run — the one found at the rootdir. A pytest.ini in a subdirectory does not override the root configuration in the way a conftest.py overrides fixtures. Where a genuine per-directory override is required, the reliable mechanism is a separate invocation for that subtree, or the --override-ini flag in a dedicated CI step, and the marker-based approach becomes the simpler answer.

Migrating a marked suite to auto mode

For an asyncio-only repository the migration is a single mechanical change, and it is worth doing because every future async test is then correct by default.

Bash
# Remove the decorator form and the module-level pytestmark assignments.
grep -rln "pytest.mark.asyncio" tests/ | xargs sed -i \
  -e '/^@pytest\.mark\.asyncio$/d' \
  -e '/^pytestmark = pytest\.mark\.asyncio$/d'
pytest -q                     # must report the same number of tests as before

The check afterwards is the part that matters: the collected count must be unchanged. A drop means some tests are no longer being claimed, which in auto mode should be impossible unless another plugin took them — the inventory problem above, discovered at the right moment.

Markers carrying arguments need more care. @pytest.mark.asyncio(loop_scope="module") is not redundant under auto mode, because the loop scope is not implied by the collection mode; deleting it silently moves those tests back to a function-scoped loop and can reintroduce the Event loop is closed failures the marker was added to fix. Delete only the bare form, and keep every marker that carries an argument.

Which markers are safe to delete when switching to auto Two categories. A bare asyncio marker, whether as a decorator or a module-level pytestmark assignment, is redundant under auto mode and can be removed. A marker carrying a loop scope argument is not redundant, because collection mode does not imply loop lifetime, and deleting it changes how the tests execute. Two markers that look alike and are not safe to delete @pytest.mark.asyncio pytestmark = pytest.mark.asyncio auto mode already claims these tests must be kept @pytest.mark.asyncio(loop_scope="module") …(loop_scope="session") deleting these changes the loop lifetime
The sed patterns above anchor on the whole line precisely so that the parametrised forms are left untouched.

Frequently Asked Questions

Does asyncio_mode change how tests execute? No. It only decides which coroutine tests and fixtures pytest-asyncio claims at collection time. Execution — which loop, how long it lives — is controlled separately by loop_scope. A suite can switch from strict to auto without any test behaving differently, as long as no other async plugin is installed.

Can I use auto mode in one directory and strict in another? Per-directory configuration files are not merged the way conftest.py files are, so the reliable split is a separate pytest invocation for that subtree, or --override-ini=asyncio_mode=strict in the job that runs it. Markers remain the fallback when a single invocation must cover both.

What happens if both pytest-asyncio and anyio try to run the same test? Whichever plugin's pytest_pyfunc_call implementation runs first claims the test, and plugin order is not something you should rely on. The test then either runs on the wrong backend or is reported as passing without executing. Keep them in separate directories with separate invocations.

← Back to pytest-asyncio in Depth