Skip to content

Fixing 'Future Attached to a Different Loop'

asyncio objects belong to one event loop. Futures, tasks, locks, queues and every client built on them — connection pools, HTTP sessions — remember the loop they first used, and using them from another loop fails. The errors appear in scripts that call asyncio.run twice, in test suites where fixtures outlive a test's loop, and in programs that run a second loop in a thread. Measured on Python 3.14: a module-level asyncio.Lock worked in the first asyncio.run and raised RuntimeError: <asyncio.locks.Lock object ... [locked]> is bound to a different event loop under contention in the second; a module-level asyncio.Queue raised the same for the second run. Awaiting a future created by a loop running in another thread raised Task <...> got Future <Future pending> attached to a different loop; wrapping a concurrent.futures.Future from run_coroutine_threadsafe with asyncio.wrap_future returned its result normally. An aiohttp ClientSession created in one asyncio.run and reused in the next failed with RuntimeError: Event loop is closed. With pytest-asyncio 1.4.0, a session-scoped async fixture holding a queue failed the second test with "bound to a different event loop", and passed for both tests once the fixture and tests shared a session-scoped loop. This guide traces each error to its cause and fix.

Prerequisites

1. Recognise the three messages

The errors look different but share one cause — an object created or first used on loop A, used on loop B:

RuntimeError: <asyncio.locks.Lock object at 0x... [locked]> is bound to a different event loop
RuntimeError: Task <Task pending ...> got Future <Future pending> attached to a different loop
RuntimeError: Event loop is closed

The first comes from asyncio's primitives — Lock, Event, Condition, Semaphore, Queue — when they need to create a waiter future on a loop other than the one they bound to. The second comes from a task awaiting a future that belongs to another loop. The third comes from objects that hold a reference to a loop that asyncio.run has since closed, typically connection pools and client sessions that try to schedule I/O or cleanup on it. Since Python 3.10 primitives no longer take a loop= argument and bind lazily, which is why the first use always works and the error appears only later — measured, the module-level lock failed in the second asyncio.run, and only once a second task had to wait for it.

Verify: for an error you are seeing, you can name the object and the two loops involved.

The same mistake, four ways A grid of 4 rows by 3 columns. The same mistake, four ways pattern error fix module-level Lock / Queue, second asyncio.run ... is bound to a different event loop create inside the running loop await another loop's future ... got Future ... attached to a different loop run_coroutine_threadsafe + wrap_future ClientSession reused across asyncio.run Event loop is closed one session per loop lifetime session fixture, function-scoped test loops ... is bound to a different event loop share loop_scope='session' Python 3.14, aiohttp, pytest-asyncio 1.4.0.

2. Create loop-bound objects inside the running loop

Module-level asyncio objects are the most common source. They are created at import, before any loop exists, and bind to whichever loop uses them first:

LOCK = asyncio.Lock()                    # binds to the first loop that waits on it

async def update():
    async with LOCK:
        ...

asyncio.run(update())                    # first loop: fine
asyncio.run(update())                    # second loop: RuntimeError once a waiter is needed

Create them where a loop is running, and keep them on an object whose lifetime matches the loop's — an application object built in main(), a service's start-up hook, a dependency created in a lifespan handler:

class App:
    def __init__(self):
        self.lock = asyncio.Lock()
        self.queue: asyncio.Queue = asyncio.Queue()

async def main():
    app = App()                          # created inside the loop that will use it
    await serve(app)

asyncio.run(main())

Measured: both the module-level Lock and Queue failed in the second asyncio.run; created inside main(), each run gets its own and nothing fails. Scripts that call asyncio.run repeatedly — CLIs with several commands, notebooks re-running cells — are where this shows up first; a single long-lived loop via asyncio.Runner, as in reusing one loop across calls with asyncio.Runner, is the other fix.

Verify: no asyncio primitive, queue or client is created at module import time.

3. Cross loops with run_coroutine_threadsafe and wrap_future

When two loops genuinely coexist — a background loop in a thread serving a GUI or legacy code — never await the other loop's futures directly. Submit work to the loop that owns it, and wrap the result for the loop that waits:

other_loop = asyncio.new_event_loop()
threading.Thread(target=other_loop.run_forever, daemon=True).start()

async def call_on_other_loop(coro):
    cf = asyncio.run_coroutine_threadsafe(coro, other_loop)   # concurrent.futures.Future
    return await asyncio.wrap_future(cf)                       # an asyncio future on *this* loop

Measured: awaiting a future created by the other loop raised "got Future attached to a different loop"; the run_coroutine_threadsafe plus wrap_future version returned "from other loop". run_coroutine_threadsafe schedules the coroutine on the target loop from any thread and returns a thread-safe concurrent.futures.Future; wrap_future turns that into a future of the current loop, with cancellation propagated back. Results set from other threads must also go through call_soon_threadsafe, as covered in resolving futures from other threads safely.

Verify: no coroutine awaits a future, task or primitive created on a different loop; cross-loop calls go through run_coroutine_threadsafe.

Calling into another thread's loop correctly A sequence of 6 messages between 3 participants. Calling into another thread's loop correctly task on loop A wrap_future loop B thread run_coroutine_threadsafe(coro, B) concurrent.futures.Future await wrap_future(cf) run coro on loop B set result (thread-safe) resume with result Each loop only ever awaits its own futures.

4. Tie client sessions and pools to the loop's lifetime

Connection pools and HTTP sessions hold transports and futures on the loop they were created on. Reusing one after that loop has closed fails, often with a message that does not mention loops at all:

SESSION = None

async def init():
    global SESSION
    SESSION = aiohttp.ClientSession()        # bound to this asyncio.run's loop

asyncio.run(init())
asyncio.run(fetch_with(SESSION))             # RuntimeError: Event loop is closed

Measured: the reused session failed with RuntimeError: Event loop is closed, and the first loop also reported the session as unclosed at exit. Create such clients in the same place you create the loop's other long-lived state, close them before the loop ends, and pass them to the code that needs them:

async def main():
    async with aiohttp.ClientSession() as session:
        await run_commands(session)          # every use is inside this loop's lifetime

asyncio.run(main())

For libraries that keep a global default client, check whether they create it lazily per loop; if not, avoid the global and construct the client yourself inside main(), as recommended in managing aioboto3 clients without leaking connections.

Verify: every long-lived client is created and closed inside the same asyncio.run or lifespan.

5. Align fixture and test loop scopes in pytest

pytest-asyncio gives each test function its own event loop by default. A fixture with a wider scope that creates loop-bound objects hands them to tests running on a different loop:

@pytest_asyncio.fixture(scope="session")                              # mismatched
async def shared_queue():
    return asyncio.Queue()

@pytest_asyncio.fixture(scope="session", loop_scope="session")        # fixture on the session loop
async def shared_queue_same_loop():
    return asyncio.Queue()

@pytest.mark.asyncio(loop_scope="session")                            # tests on the same loop
async def test_c(shared_queue_same_loop):
    ...

Measured with pytest-asyncio 1.4.0: with the mismatched fixture, the first test passed and the second failed with RuntimeError: <Queue ... tasks=1> is bound to a different event loop; with fixture and tests sharing loop_scope="session", both passed. The rule is that an async fixture's loop scope must be at least as wide as its scope, and tests using it must run on that loop. Expensive shared resources — a database pool, an HTTP client — usually belong on a session-scoped loop; state that tests mutate belongs in function-scoped fixtures, as in Testing Async Code.

Verify: the test suite passes when run in any order, including a single test on its own.

Where does the second loop come from? A decision on Why are there two loops with 4 outcomes. Where does the second loop come from? Why are there two loops? asyncio.run called twice create objects inside main() or one asyncio.Runner a loop in another thread run_coroutine_threadsafe + wrap_future never await its futures client outlived its loop create and close in the same lifespan 'Event loop is closed' pytest fixture scope > loop scope loop_scope='session' on both 1 failed to 0 Every loop-bound object should be born and die with one loop.

Verification

Different-loop errors are designed out when:

  • No asyncio objects or clients are created at import time.
  • Long-lived state is built inside the running loop and closed before it ends.
  • Cross-loop calls use run_coroutine_threadsafe and wrap_future.
  • Async fixtures and the tests using them share a loop scope.

Diagnostic Hook: when one of these errors appears, log id(asyncio.get_running_loop()) where the object is created and where it fails. Two different IDs confirm the diagnosis in one run, and the creation site is the line to change.

Pitfalls & edge cases

  • Module-level primitives. Measured: fine in the first asyncio.run, broken in the second.
  • Errors only under contention. A lock binds when a waiter is needed, so tests without contention pass.
  • Global HTTP sessions. Measured: "Event loop is closed" on reuse.
  • Session fixtures on function loops. Measured: the second test failed.

Frequently Asked Questions

What does 'Future attached to a different loop' mean?

A task awaited a future created by another event loop. Do not await other loops' futures; submit work with run_coroutine_threadsafe and await asyncio.wrap_future of the result.

Why is my asyncio.Lock 'bound to a different event loop'?

It was created at module level and first used by an earlier loop, for example a previous asyncio.run; it failed on the second run once a waiter was needed. Create it inside the running loop.

Why do I get 'Event loop is closed' with aiohttp?

The ClientSession was created in one asyncio.run and used in another. Create and close it inside the same loop's lifetime.

How do I share an async fixture across pytest tests?

Give the fixture loop_scope matching its scope and run the tests with the same loop_scope; with pytest-asyncio 1.4.0 that fixed the 'bound to a different event loop' failure.