You run pytest tests/test_users.py::test_create_user and it passes. You run the full suite and half your tests blow up with RuntimeError: Task <Task pending name='Task-12'> attached to a different loop. Nothing about your code changed between those two runs — just how many tests ran.

TLDR: Quick Fix

Most Common Cause: Your async database engine, session, or httpx.AsyncClient was created on one event loop, but pytest-asyncio spun up a fresh loop for the next test — and asyncio refuses to let a task from one loop run on another.

The Fix: Scope your event_loop, async engine, and async client fixtures to match. If you want a fresh event loop per test (the safe default), create your async engine and client inside a function-scoped fixture too — never at module or session scope.

@pytest_asyncio.fixture(scope="function")
async def async_engine():
    engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
    yield engine
    await engine.dispose()

Mismatched scopes between the loop and the resources that run on it are the root cause almost every time.

What’s Actually Happening

Every asyncio event loop owns its own set of tasks, callbacks, and often its own connection objects. pytest-asyncio creates a new event loop for each test by default (when using function-scoped async tests, which is the common setup). That’s great for isolation — one test’s hung task can’t leak into the next.

The problem shows up when you also create an async resource — a SQLAlchemy AsyncEngine, a Redis connection pool, an httpx.AsyncClient — at module or session scope, meaning it’s built once and reused across every test. That resource gets bound to whichever event loop happened to be running the first time it was awaited. Every subsequent test runs on a different loop, and the moment that test tries to use the shared resource, asyncio notices the mismatch and throws:

RuntimeError: Task <Task pending name='Task-8' coro=<...>> attached to a different loop

Sometimes it manifests slightly differently depending on the library:

RuntimeError: Event loop is closed

Both errors have the same root cause: a coroutine-based resource outliving the loop it was born on.

Prerequisites

Before working through the fix, make sure you have:

  • pytest and pytest-asyncio installed (pip install pytest pytest-asyncio)
  • httpx for testing your FastAPI routes (pip install httpx)
  • An async SQLAlchemy setup (asyncpg driver, AsyncEngine, AsyncSession) — this is where the bug shows up most often, but the same fix applies to any async client

Step 1: Set the pytest-asyncio Mode

In pyproject.toml or pytest.ini, tell pytest-asyncio how to discover async tests. auto mode saves you from decorating every test function:

[tool.pytest.ini_options]
asyncio_mode = "auto"

Without this, you’d need @pytest.mark.asyncio on every single async test, and it’s easy to forget one — which produces its own confusing “coroutine was never awaited” warning that looks unrelated to the loop error but often travels with it.

Step 2: Fix Your Event Loop Fixture Scope

This is the part people get wrong most. A common “fix” people copy from an old Stack Overflow answer looks like this:

# conftest.py — the version that causes the bug
import pytest
import asyncio

@pytest.fixture(scope="session")
def event_loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()

That single loop now lives for the entire test session. Meanwhile, if any of your other fixtures (engine, client, Redis pool) are function-scoped, pytest-asyncio still tears down and recreates parts of the async machinery between tests, and you get drift between “the loop that exists” and “the loop my resources think they’re on.”

The safer default is to not override event_loop at all and let pytest-asyncio manage a fresh loop per test function. Then make sure every async resource fixture is scoped to match:

# conftest.py — safe default
import pytest_asyncio

# No custom event_loop fixture needed for function-scoped tests.
# pytest-asyncio creates and tears down a loop per test automatically.

If you genuinely need a session-scoped loop for performance reasons (e.g., an expensive external service connection), then every async resource that touches that loop must also be session-scoped — you can’t mix and match.

Step 3: Scope Your Async Engine and Session to Match

Here’s the fixture that actually caused the error in a real project — a database engine created once at module scope:

# ❌ Before — engine created once, reused across every test's loop
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/test_db"

engine = create_async_engine(TEST_DATABASE_URL)
TestSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

@pytest_asyncio.fixture
async def db_session():
    async with TestSessionLocal() as session:
        yield session

The engine variable is created at import time, before any test — and before any event loop — exists. The first test to actually run a query binds it to that test’s loop. The second test gets a brand-new loop from pytest-asyncio, tries to reuse the same engine, and asyncio rejects it.

# ✅ After — engine created fresh inside a function-scoped fixture
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool

TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/test_db"

@pytest_asyncio.fixture(scope="function")
async def async_engine():
    engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture(scope="function")
async def db_session(async_engine):
    session_factory = sessionmaker(
        async_engine, class_=AsyncSession, expire_on_commit=False
    )
    async with session_factory() as session:
        yield session

NullPool matters here too — connection pools keep sockets open in the background, and a pooled connection from a dead loop is just as broken as any other cross-loop resource. For tests, disabling pooling entirely is worth the small performance cost.

Step 4: Bind httpx.AsyncClient to the Same Loop

The other place this bites people is the test client. If you’re testing async routes, don’t reach for the synchronous TestClient and then also try to await things around it — pick one. For fully async tests, use httpx.AsyncClient with ASGITransport, created fresh per test, with your database dependency overridden to use the same scoped session:

import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from main import app
from database import get_db

@pytest_asyncio.fixture(scope="function")
async def client(db_session):
    async def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac

    app.dependency_overrides.clear()
# test_users.py
import pytest

async def test_create_user(client):
    response = await client.post("/users", json={"name": "Ada"})
    assert response.status_code == 201

async def test_list_users(client):
    response = await client.get("/users")
    assert response.status_code == 200

Because client depends on db_session, which depends on async_engine, all three get created and torn down inside the same test’s event loop, in the right order. No fixture outlives the loop it was born on.

Diagnosing Which Fixture Is at Fault

If the fix above doesn’t immediately solve it, you need to find out which resource is actually crossing loops. The fastest way is to print the loop identity at the point each resource gets created and again right before it’s used:

import asyncio

@pytest_asyncio.fixture(scope="function")
async def async_engine():
    loop = asyncio.get_running_loop()
    print(f"[fixture] engine created on loop {id(loop)}")
    engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
    yield engine
    await engine.dispose()

async def test_create_user(client):
    loop = asyncio.get_running_loop()
    print(f"[test] running on loop {id(loop)}")
    response = await client.post("/users", json={"name": "Ada"})
    assert response.status_code == 201

Run with pytest -s so the print statements aren’t captured, and compare the loop IDs across a couple of tests. If a fixture logs one ID and the test that uses it logs a different one, you’ve found your mismatched fixture — check its scope and whatever it depends on.

This is also the quickest way to catch a sneaky variant of the bug: a fixture that looks function-scoped but actually depends on something session-scoped underneath it, like a shared Redis client imported from your app’s module-level config instead of created inside a fixture.

Testing Your Fix

Run the full suite, not just one file — the bug only shows up when multiple tests share process state:

pytest tests/ -v

If you still see the loop error on a specific test, check for global state outside the fixture chain: a module-level httpx.AsyncClient(), a cached Redis connection created at import time, or a background task started in a previous test that never got cancelled. Any of these can hold a reference to a dead loop.

You can also run with -p no:cacheprovider and --forked (via pytest-forked) as a diagnostic — if isolating each test into its own process makes the failures disappear, that confirms the issue is shared async state, not something wrong with the routes themselves.

Common Mistakes

  • Session-scoped event_loop with function-scoped resources. Pick one lifetime and scope everything to it consistently.
  • Reusing a module-level engine “for speed.” It feels efficient but breaks the moment you have more than one test file. Use NullPool and accept the per-test connection cost — it’s usually milliseconds, not seconds.
  • Mixing sync TestClient and async fixtures in the same test. TestClient manages its own loop internally via anyio; wiring it up alongside your own async session fixtures is a common source of the exact same error.
  • Forgetting app.dependency_overrides.clear(). Leftover overrides leak into unrelated tests and can bind a stale session to a new test’s loop.
  • Not disposing the engine. await engine.dispose() at fixture teardown closes pooled connections cleanly; skipping it can leave dangling connections that reference a closed loop and surface as flaky failures in CI only.

Next Steps

Once your fixtures are scoped correctly, this class of error tends to disappear entirely rather than becoming an occasional flake — which is a good sign you found the real cause rather than papering over a symptom. If your app also mixes sync and async database code outside of tests, it’s worth checking our guide on debugging async/sync blocking issues in FastAPI, since the underlying event-loop mental model is the same. And if your tests are timing out or your engine feels sluggish under concurrent runs, see our post on FastAPI SQLAlchemy pool exhaustion for pooling settings that work well in both production and test environments.

Got a cryptic asyncio traceback and not sure which line actually matters? Use Debugly’s trace formatter to quickly parse and analyze Python tracebacks and jump straight to the frame that caused it.