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¶
- Python 3.10+, where primitives bind to a loop on first use.
- Running loops in threads, from running multiple event loops in separate threads.
- The topic overview, Future Objects & Callbacks.
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.
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 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.
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.
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_threadsafeandwrap_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.
Related¶
- Future Objects & Callbacks — up to the topic overview.
- Batching individual calls with futures — futures used well within one loop.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.