# One event loop per async test is slow

> pytest-asyncio gives every async test its own event loop by default. Share one loop with asyncio_default_test_loop_scope and loop_scope="session" so async fixtures can be shared too.

By default, pytest-asyncio runs every async test in a new event loop, and closes it when the test ends. That's the safest choice for isolation, but it has two costs. Creating and tearing down a loop for every test takes time on its own. And more importantly, anything bound to a loop (a database engine or connection pool, an HTTP client, a server) can't outlive it, so those fixtures have to be rebuilt for every test as well.

Running the tests in one shared loop removes both costs.

## Why it's slow

For each function-scoped loop, pytest-asyncio creates a new loop before the test and, after it, cancels leftover tasks, shuts down async generators, shuts down the loop's default thread pool executor (waiting for its threads to finish) and closes the loop. If a test used `asyncio.to_thread()`, `run_in_executor()` or the loop's DNS lookups, that executor shutdown has threads to join.

The bigger cost is usually indirect. An async fixture can only be shared by tests that run in the same loop, because objects like `asyncpg` pools, SQLAlchemy `AsyncEngine` connections, `aiohttp.ClientSession` and `httpx.AsyncClient` connections are tied to the loop they were created in. With a loop per test, a session-scoped async engine isn't possible, so every test opens new database connections.

## The fix: run tests in a session-scoped loop

With pytest-asyncio 0.26 or later (including 1.x), set the default loop scope for both tests and async fixtures:

```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
```

The same options work in `pytest.ini` (`asyncio_default_test_loop_scope = session`). Valid scopes are `function`, `class`, `module`, `package` and `session`. Set both: if tests run in the session loop but fixtures default to their own scope's loop, a function-scoped async fixture runs in a different loop than the test that uses it, and loop-bound objects fail with errors like "attached to a different loop".

`asyncio_default_fixture_loop_scope` also silences the warning pytest-asyncio prints when it's unset.

### Per module or per test: the loop_scope argument

To share a loop only within part of the suite, use `loop_scope` on the mark. For a whole module:

```python
import pytest

pytestmark = pytest.mark.asyncio(loop_scope="module")


async def test_create_order(client): ...
```

`loop_scope="package"` in a package's `__init__.py` applies to that package, but not to its subpackages. In strict mode, every async test needs the `asyncio` mark anyway, so add `loop_scope` there: `@pytest.mark.asyncio(loop_scope="session")`.

### pytest-asyncio 0.24 and 0.25

`asyncio_default_test_loop_scope` was added in 0.26. On 0.24 and 0.25, set `asyncio_default_fixture_loop_scope` and mark every async test from a hook in the root `conftest.py`:

```python
import pytest
from pytest_asyncio import is_async_test


def pytest_collection_modifyitems(items):
    session_scope_marker = pytest.mark.asyncio(loop_scope="session")
    for item in items:
        if is_async_test(item):
            item.add_marker(session_scope_marker, append=False)
```

In strict mode, this also overrides the `asyncio` marks you applied by hand.

## Share the expensive async fixtures

Once tests share a loop, async fixtures can have a wider scope. `scope` is how often the fixture is created, `loop_scope` is which loop it runs in, and the loop scope must be at least as wide as the fixture scope:

```python
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine

DATABASE_URL = "postgresql+asyncpg://localhost/test"


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def engine():
    engine = create_async_engine(DATABASE_URL)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture(loop_scope="session")
async def session(engine):
    async with engine.connect() as connection:
        transaction = await connection.begin()
        async with AsyncSession(bind=connection, join_transaction_mode="create_savepoint") as session:
            yield session
        await transaction.rollback()
```

The engine and its connection pool are created once per run; each test gets a session inside a transaction that's rolled back afterwards. See [create the schema once, roll back per test](https://flakytest.dev/guides/sqlalchemy-rollback-per-test/) for the full pattern, and [expensive function-scoped fixtures](https://flakytest.dev/guides/pytest-fixture-scope/) for fixture scopes in general.

## anyio's pytest plugin

If your tests use anyio's plugin (`@pytest.mark.anyio`) rather than pytest-asyncio, the rule is different: anyio starts a test runner (with its event loop) for each test, unless an async fixture with a scope wider than `function` is in use, in which case the runner lives as long as that fixture. The built-in `anyio_backend` fixture is function-scoped, so to use wider-scoped async fixtures, override it in `conftest.py`:

```python
import pytest


@pytest.fixture(scope="session")
def anyio_backend():
    return "asyncio"
```

A session-scoped async fixture shared by all tests then keeps one runner for the whole session.

## Keep the tests correct

- Tests then share loop state: don't leave tasks running between tests. Cancel and await background tasks in fixture teardown. pytest-asyncio 1.1+ cancels leftover tasks when the loop's scope ends, but for a session loop that's the end of the run, so until then they keep running during later tests.
- Loop-level settings (`loop.set_debug()`, an exception handler, a custom task factory) and anything else a test does to the loop itself carry over to later tests.
- Shared async fixtures carry state too. Give each test its own transaction or a reset, and run the suite in random order (pytest-randomly) now and then to catch leaks.
- Don't call `asyncio.run()` or close the loop inside a test that runs in the shared loop.

## How flakytest finds it

flakytest measures the time spent creating event loops (`asyncio.new_event_loop` and the loop constructor) and tearing them down (`close` and `shutdown_default_executor`). When that's more than half a second and 2% of test time, it suggests **A new event loop is created for every async test**, with the tests and lines where it happens. The saving from shared fixtures comes on top and shows up in other categories (database connections, network), so measure before and after with `flakytest compare`.

## Further reading

- [pytest-asyncio: configuration](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html)
- [pytest-asyncio: how to change the default event loop scope of all tests](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_default_test_loop.html)
- [pytest-asyncio: event loops and loop scopes](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html)
- [anyio: testing with AnyIO](https://anyio.readthedocs.io/en/stable/testing.html)
