# Debug pytest-asyncio Event-Loop and Fixture Failures

> pytest-asyncio gives one event loop per pytest collector, so every async resource is bound to the loop of whatever created it. That single fact explains attached to a different loop, Event loop is closed, ScopeMismatch on a fixture you never wrote, and the AttributeError on an async_generator that mentions no loop at all. This guide separates the five causes, gives a check that tells them apart, and starts with the two configuration defaults that do not match.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-20T13:30:00.000Z

- Updated: 2026-09-07T11:52:06.257Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/debug-pytest-asyncio-event-loop-fixture-failures

- Tags: pytest, python, asyncio, async-testing, debugging, test-fixtures

**Quick answer:** pytest-asyncio provides one event loop per pytest collector, so every async resource is bound to the loop of the collector that made it. `attached to a different loop` means a fixture and its consumer ran on two different collectors' loops — and which loop each one got is decided by two separate settings with two different defaults. Before debugging anything, check whether `asyncio_default_fixture_loop_scope` is set. If it is unset you do not yet know which loop your fixtures ran on, and pytest-asyncio warns about exactly that.

Four signatures, all of which mean the same underlying thing and none of which say so:

```
RuntimeError: Task <Task pending name='Task-4' coro=<test_charge()>> got Future
<Future pending> attached to a different loop

RuntimeError: Event loop is closed

ScopeMismatch: You tried to access the 'function' scoped fixture
'_function_scoped_runner' with a 'session' scoped request object

RuntimeError: There is no current event loop in thread 'MainThread'
```

None of these is a bug in asyncio, and none is fixed by wrapping the call in `try/except RuntimeError`. They are all the same class of mistake: something was created on one event loop and used or closed on another. The rest of this article is about telling which of the five causes is yours, because the fixes are different and applying the wrong one moves the failure rather than removing it.

## One loop per collector

The mechanism is stated plainly in pytest-asyncio's concepts page, and it is the only model you need: "Pytest-asyncio provides one asyncio event loop for each pytest collector."

pytest builds a hierarchy of collectors — Session at the root, then Package, Module, Class, and Function — and, as the docs point out, "the individual levels resemble the possible scopes of a pytest fixture." pytest-asyncio hangs one loop off each level. A loop's lifetime is therefore the lifetime of its collector: the Function collector's loop is created and closed around a single test, the Session collector's loop lives for the whole run.

By default a test uses the narrowest one. The docs are explicit about both the behaviour and the reason: "each test runs in the event loop provided by the Function collector, i.e. tests use the loop with the narrowest scope. This gives the highest level of isolation between tests."

So *loop scope* is a second axis, separate from fixture caching scope. A fixture can be built once per module and still run on a session loop, or be rebuilt per test and still run on a module loop. Every one of the errors above is that second axis being set by accident.

Make the axis visible before you touch anything else. Loop identity is a number, and printing it converts "a different loop" into two numbers you can compare:

```
import asyncio, logging

LOG = logging.getLogger(__name__)

def loop_id() -> str:
    try:
        return hex(id(asyncio.get_running_loop()))
    except RuntimeError:
        return "<no running loop>"

@pytest_asyncio.fixture(scope="session")
async def engine():
    LOG.warning("engine created on loop=%s", loop_id())
    ...

async def test_charge(engine):
    LOG.warning("test running on loop=%s", loop_id())
```

Run it with `-s` and read the two lines. If they differ, you have confirmed the class of problem in one run and can go straight to the right cause below.

## The two defaults that do not match

This is the most common cause and the least obvious, because it is a disagreement between two configuration options that most projects never set.

For tests, the reference says `asyncio_default_test_loop_scope` "determines the default event loop scope of asynchronous tests. When this configuration option is unset, it defaults to function scope."

For fixtures, `asyncio_default_fixture_loop_scope` "determines the default event loop scope of asynchronous fixtures. When this configuration option is unset, it defaults to the fixture scope. In future versions of pytest-asyncio, the value will default to function when unset."

Read those together. Unset, a `scope="session"` async fixture takes a session loop, while every test that consumes it takes a function loop. The client, engine or connection pool the fixture built is bound to the session loop; the test awaits it on a per-test loop; you get `attached to a different loop` on the first await that touches the pool.

Worth knowing: the decorator reference describes the default differently again, stating that for `@pytest_asyncio.fixture` "the default event loop scope is function scope." Rather than work out which description applies to your installed version, set the option — pytest-asyncio emits a warning when it is unset, and the project has changed the wording of that warning to make the point harder to miss.

```
[tool.pytest.ini_options]
# Set both. Unset is not a neutral default; it is a documented pending change.
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
```

**Discriminating check:** run `pytest --collect-only 2>&1 | grep -i asyncio`. If the unset-option warning appears, this cause is live in your suite whether or not it is the one biting today. Fix it first, because leaving it unset makes every other diagnosis provisional.

Choosing `session` for both is the usual right answer when your async resources are pools and clients, since those are exactly the things you do not want rebuilt per test. It costs you loop isolation between tests, which matters if a test leaves a task pending — see the debug-mode section for how that surfaces.

## Loop scope cannot be narrower than caching scope

When the error names a fixture you never wrote — `_function_scoped_runner` is the usual one — you have hit a documented constraint rather than a bug.

The decorators reference states it exactly: the loop scope "can be chosen independently from its caching scope. However, the event loop scope must be larger or the same as the fixture's caching scope. In other words, it's possible to reevaluate an async fixture multiple times within the same event loop, but it's not possible to switch out the running event loop in an async fixture."

So `loop_scope` ≥ `scope`, always. The four documented combinations, which are worth reading as a set because the third and fourth differ only in caching:

```
import pytest_asyncio

@pytest_asyncio.fixture
async def fresh_loop_every_function(): ...

@pytest_asyncio.fixture(loop_scope="session", scope="module")
async def session_loop_rebuilt_once_per_module(): ...

@pytest_asyncio.fixture(loop_scope="module", scope="module")
async def module_loop_built_once_per_module(): ...

@pytest_asyncio.fixture(loop_scope="module")
async def module_loop_rebuilt_every_function(): ...
```

The second is the shape most suites want for a database engine: one loop for the whole run, but a fresh engine per module so a module can be run alone.

**Discriminating check:** read which side of the error message names which scope. `'function' scoped fixture ... with a 'session' scoped request object` means a session-scoped thing requested a function-scoped thing — the requester is yours and the requested is usually the plugin's per-function runner. Widen the loop scope on your fixture; do not narrow its caching scope, which is the reflex and which usually just relocates the problem.

## Strict mode declines fixtures it was not asked to own

This cause produces an error with no mention of loops at all, which is why it costs people an afternoon:

```
AttributeError: 'async_generator' object has no attribute 'create_customer'
```

Strict mode is the default, and the concepts page says what that means: "In strict mode pytest-asyncio will only run tests that have the asyncio marker and will only evaluate async fixtures decorated with `@pytest_asyncio.fixture`. Test functions and fixtures without these markers and decorators will not be handled by pytest-asyncio."

An async fixture written with plain `@pytest.fixture` is therefore not an async fixture. pytest calls it, gets an async generator object back, and injects that object into your test. Every attribute access on it fails, and the message names your method rather than the plugin.

```
# Wrong in strict mode: pytest-asyncio never takes ownership of this,
# so the test receives the generator object rather than the client.
@pytest.fixture
async def api_client():
    async with AsyncClient(base_url=BASE) as client:
        yield client


# Right: the decorator is what hands ownership over.
import pytest_asyncio

@pytest_asyncio.fixture(loop_scope="session")
async def api_client():
    async with AsyncClient(base_url=BASE) as client:
        yield client
```

The alternative is auto mode, which "automatically adds the asyncio marker to all asynchronous test functions" and "will also take ownership of all async fixtures, regardless of whether they are decorated with `@pytest.fixture` or `@pytest_asyncio.fixture`." Pick it if asyncio is the only async library in the repository. Keep strict mode if you also run trio or anyio tests — the docs give that coexistence as the reason strict is the default, since "pytest plugins need to coexist peacefully in their default configuration."

**Discriminating check:** the type in the error. `async_generator` or `coroutine` in an `AttributeError` or `TypeError` means ownership, not loops. No loop error will ever name a generator type.

## A closed loop means the resource outlived its owner

`Event loop is closed` is the one that appears during teardown, or on the second test rather than the first. The resource was created on a loop that has since been closed, and something is still holding it.

```
# Wrong. The engine is cached for the session, but with the fixture loop
# scope unset it was created on whichever loop was running at the time.
# dispose() then runs during session teardown, after that loop is gone.
@pytest_asyncio.fixture(scope="session")
async def engine():
    engine = create_async_engine(TEST_DATABASE_URL)
    yield engine
    await engine.dispose()   # RuntimeError: Event loop is closed
```

```
# Right. The loop scope is stated, so creation and disposal happen on the
# same loop, and that loop outlives the fixture that owns the engine.
@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def engine():
    engine = create_async_engine(TEST_DATABASE_URL)
    yield engine
    await engine.dispose()

# Per-test isolation comes from the connection, not from rebuilding the
# engine — and this fixture is allowed a narrower caching scope because
# its loop scope is still session.
@pytest_asyncio.fixture(loop_scope="session")
async def db(engine):
    async with engine.connect() as conn:
        transaction = await conn.begin()
        yield AsyncSession(bind=conn)
        await transaction.rollback()
```

**Discriminating check:** which test fails. If the first test in a module passes and the second fails, the resource is being reused across loops — a caching scope wider than the loop scope. If the failure is only ever in teardown, the create and the close are on different loops. Run one test alone (`pytest path::test_one`): if it passes and the pair fails, it is reuse; if the single test also fails at teardown, it is disposal.

## A sync test calling asyncio.run unsets the loop

The last cause is contamination from a test that is not even async. pytest-asyncio's changelog records both symptoms as bugs it had to fix: `RuntimeError: There is no current event loop in thread 'MainThread'` arising "when any test unsets the event loop (such as when using `asyncio.run` and `asyncio.Runner`)", and a `ResourceWarning: unclosed event loop` that "could occur when a synchronous test called `asyncio.run()` or otherwise unset the current event loop after pytest-asyncio had run an async test or fixture."

Those specific fixes have shipped, so on a current version you may not see them. The underlying practice is still wrong and still produces confusing failures with third-party code that reaches for the current loop:

```
# Wrong. asyncio.run() creates a loop, runs the coroutine, closes the
# loop, and leaves no current loop set. Anything later in the session
# that expects one is now running in a different world.
def test_health_sync():
    assert asyncio.run(check_health()) == "ok"


# Right. Let pytest-asyncio own the loop.
async def test_health():
    assert await check_health() == "ok"
```

**Discriminating check:** ordering. If the failure disappears when you run the failing test alone but reappears in the full file, grep the module for `asyncio.run`, `asyncio.Runner`, `new_event_loop` and `set_event_loop`. This is the only cause on this list whose reproduction depends on which tests ran before it, which makes it the fastest to confirm and the easiest to miss.

## Debug mode answers where, not just what

pytest-asyncio exposes asyncio's debug mode as a first-class option: `asyncio_debug = true` in the config file, or `--asyncio-debug` on the command line. It "enables asyncio debug mode for the default event loop used by asynchronous tests and fixtures," and is off by default.

What it buys is location. A pending coroutine normally reports only that it happened:

```
test.py:7: RuntimeWarning: coroutine 'test' was never awaited
```

With debug mode on, CPython attaches the creation traceback:

```
test.py:7: RuntimeWarning: coroutine 'test' was never awaited
Coroutine created at (most recent call last)
  File "../t.py", line 9, in <module>
  ...
  File "../t.py", line 7, in main
    test()
```

Three more behaviours are worth the noise while you are hunting a loop problem. Exceptions set on a Future that nobody awaits are otherwise lost entirely — Python's docs note that in that case "asyncio would emit a log message when the Future object is garbage collected," which is how a background task that failed silently becomes visible. Callbacks taking longer than 100 milliseconds are logged, with `loop.slow_callback_duration` available to change the threshold. And "many non-threadsafe asyncio APIs (such as `loop.call_soon()` and `loop.call_at()` methods) raise an exception if they are called from a wrong thread" — which turns a class of silent corruption into an immediate failure.

Pair it with `-W default` so `ResourceWarning` is displayed rather than suppressed. Unclosed transports and unclosed loops are `ResourceWarning`s, and by default you never see them.

```
pytest --asyncio-debug -W default -s tests/integration/
```

## Apply this now

Set both loop-scope options explicitly today, even if nothing is currently failing. Unset is not a stable state — the reference documents the fixture default as changing in a future version, which means an upgrade can move every async fixture in your suite onto a different loop without any change of yours.

Then add the loop-identity log line to the widest async fixture you own and to one test that consumes it, and run that pair with `-s`. Two matching hex values is the evidence that the ownership is now what you think it is; that one line, kept in the fixture behind a debug-level logger, is what makes the next occurrence a two-minute diagnosis.

Finally, grep the suite for `asyncio.run` outside of `if __name__ == "__main__"` blocks. Every hit inside a test is a latent ordering-dependent failure.

## Questions about async fixture ownership

### Should every test share one session loop?

It is the right default when your async resources are connection pools and HTTP clients, because those are expensive and loop-bound. The cost is that a test which leaves a task pending now leaks it into every later test rather than having it destroyed with its own loop. If you take session scope, take debug mode with it in CI — unretrieved task exceptions are the failure this trade introduces, and debug mode is what makes them visible.

### Can neighbouring tests use different loop scopes?

They can, and pytest-asyncio advises against it: "it's highly recommended for neighboring tests to use the same event loop scope. For example, all tests in a class or module should use the same scope. Assigning neighboring tests to different event loop scopes is discouraged as it can make test code hard to follow." In practice, mixing them within a module is also how you end up with a fixture that works for the first test and not the second, so treat the module as the unit at which loop scope is decided.

### Why does the suite pass locally and fail under parallel workers?

Because a loop belongs to a process. Each xdist worker is its own process with its own collectors and therefore its own loops, so anything shared between workers — a connection pool built in a plugin, a client cached in a module-level global at import time — is being used from a loop that did not create it. The fix is not loop configuration; it is making the resource per-worker. Objects created at import time are the ones to look at first, since import happens before any loop exists.

### Is catching RuntimeError around the await ever right?

No, and it is worth being blunt because it is a common suggestion. `attached to a different loop` is raised before your coroutine does any work, so catching it does not retry anything — it converts a loud ownership bug into a test that passes without having tested. The only legitimate use of `except RuntimeError` near a loop is the one in the diagnostic helper earlier in this article, where `get_running_loop()` is called deliberately outside a loop to report that fact.

### What should a fixture do about background tasks?

Own them explicitly: keep a reference to every task it starts, and cancel and await them before it yields control back. A task holds a reference to its loop, so a task still pending when the loop closes is the direct cause of both `Event loop is closed` during teardown and unretrieved-exception log lines afterwards. Debug mode is how you find the ones you did not know about — the garbage-collection log message for an unawaited Future exception is often the first evidence that a fixture started something it never finished.

## Primary references

- [pytest-asyncio — Concepts: asyncio event loops](https://pytest-asyncio.readthedocs.io/en/latest/concepts.html#asyncio-event-loops). That "pytest-asyncio provides one asyncio event loop for each pytest collector"; that by default tests use the Function collector's loop, "the loop with the narrowest scope", for maximum isolation; and the recommendation that neighbouring tests share one loop scope.

- [pytest-asyncio — Configuration: asyncio_default_fixture_loop_scope](https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html#asyncio-default-fixture-loop-scope). That when unset it "defaults to the fixture scope", and that "in future versions of pytest-asyncio, the value will default to function when unset".

- [pytest-asyncio — Configuration: asyncio_default_test_loop_scope](https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html#asyncio-default-test-loop-scope). That when unset it "defaults to function scope" — the other half of the mismatch that produces `attached to a different loop`.

- [pytest-asyncio — Decorators: @pytest_asyncio.fixture](https://pytest-asyncio.readthedocs.io/en/latest/reference/decorators/index.html#decorators). The constraint that "the event loop scope must be larger or the same as the fixture's caching scope" because "it's not possible to switch out the running event loop in an async fixture", the four documented loop_scope/scope combinations, and its own statement that the default loop scope is function.

- [pytest-asyncio — Concepts: Test discovery modes](https://pytest-asyncio.readthedocs.io/en/latest/concepts.html#test-discovery-modes). That strict is the default; that in strict mode only fixtures decorated with `@pytest_asyncio.fixture` are evaluated and undecorated ones "will not be handled by pytest-asyncio"; that auto mode takes ownership of all async fixtures either way; and that strict is default so plugins "coexist peacefully in their default configuration".

- [pytest-asyncio — Configuration: asyncio_debug](https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html#asyncio-debug). That the option and the `--asyncio-debug` flag enable asyncio debug mode "for the default event loop used by asynchronous tests and fixtures", and that it is disabled by default.

- [Python — Developing with asyncio: Debug Mode](https://docs.python.org/3/library/asyncio-dev.html#debug-mode). What debug mode changes: non-threadsafe APIs such as `loop.call_soon()` raise when called from the wrong thread, slow I/O selector time is logged, and "callbacks taking longer than 100 milliseconds are logged" with `loop.slow_callback_duration` as the threshold. Also the advice to display `ResourceWarning` via `-W default`.

- [Python — Detect never-awaited coroutines](https://docs.python.org/3/library/asyncio-dev.html#detect-never-awaited-coroutines). The verbatim `RuntimeWarning: coroutine 'test' was never awaited`, and that debug mode additionally reports "Coroutine created at (most recent call last)" with the creation traceback.

- [Python — Detect never-retrieved exceptions](https://docs.python.org/3/library/asyncio-dev.html#detect-never-retrieved-exceptions). That when `Future.set_exception()` is called on a Future that is never awaited, "the exception would never be propagated to the user code" and asyncio "would emit a log message when the Future object is garbage collected".

- [pytest-asyncio — Markers: pytest.mark.asyncio](https://pytest-asyncio.readthedocs.io/en/latest/reference/markers/index.html#markers). That a marked coroutine "is executed as an asyncio task in the event loop provided by pytest-asyncio", that `pytestmark` can apply it to a whole module, and that `loop_scope` on the marker takes function, class, module, package or session.

- [pytest-asyncio — Changelog](https://pytest-asyncio.readthedocs.io/en/latest/reference/changelog.html). That a warning is displayed when `asyncio_default_fixture_loop_scope` is unset (and its wording was revised for readability); the recorded `RuntimeError: There is no current event loop in thread 'MainThread'` arising when a test unsets the loop "such as when using asyncio.run and asyncio.Runner"; and the `ResourceWarning: unclosed event loop` from a synchronous test calling `asyncio.run()` after pytest-asyncio had run an async test or fixture.

## Continue reading on AutomationTester.in

- [Use pytest Parametrization Without Hiding Test Intent](https://automationtester.in/blog/automation-tutorials/pytest-parametrization-with-clear-test-intent)
- [Design pytest Fixtures as Explicit Resource Contracts](https://automationtester.in/blog/automation-tutorials/pytest-fixtures-explicit-resource-contracts)
- [Detect Breaking API Changes Before Consumers Do](https://automationtester.in/blog/automation-tutorials/detect-breaking-api-changes-before-consumers)
- [Verify Pagination, Filtering, and Sorting Contracts](https://automationtester.in/blog/automation-tutorials/api-pagination-filtering-sorting-contract-tests)
- [Test API Authorization Beyond Happy-Path Tokens](https://automationtester.in/blog/automation-tutorials/api-authorization-testing-object-function-property)

Source: [Debug pytest-asyncio Event-Loop and Fixture Failures](https://automationtester.in/blog/automation-tutorials/debug-pytest-asyncio-event-loop-fixture-failures) by Shashank Rawlani.
