skip to content

What does asyncio.run() do with the event loop, and why prefer it to a hand-made loop?

level: juniorimportance: must knowfreq 78%

answer

  1. It is a lifecycle, not a helper
  2. Fresh loop in, closed loop out
  3. Teardown is the part people skip
  4. Cancel, shutdown async generators, close
  5. Two calls mean two unrelated loops

basics

~20 s

asyncio.run() creates a brand-new event loop, drives the coroutine you hand it to completion, then cancels leftover tasks, shuts down async generators and the default executor, and closes the loop. It is an async program's single entry point.

solid answer

~40 s

`asyncio.run(coro)` is a whole lifecycle, not a helper. It refuses to start if a loop is already running in this thread, builds a fresh loop, sets it as the thread's current loop, wraps `coro` in a Task and runs until that Task finishes, and then tears everything down: cancels the tasks still pending, awaits `shutdown_asyncgens()` so half-consumed async generators get closed properly, awaits `shutdown_default_executor()` so worker threads join, unsets the loop and closes it. The hand-rolled `new_event_loop()` / `run_until_complete()` / `close()` sequence people write instead almost always skips that teardown, so async generators are finalized late by the garbage collector and background tasks disappear mid-flight. `asyncio.Runner` (3.11+) is the escape hatch when you genuinely need several entries on one loop, with the same teardown as a context manager.

code

python · 10 lines
python
import asyncio

async def main():
    return asyncio.get_running_loop()

first = asyncio.run(main())
second = asyncio.run(main())

print(first is second)     # False - a fresh loop per call
print(first.is_closed())   # True  - run() closed it on the way out

go deeper

for a junior

Be ready to say that asyncio.run() is the one entry point of an async program: it makes a loop, runs your coroutine, and closes the loop. Know that you call it once, from synchronous code, never from inside a coroutine.

for a middle

Explain the teardown steps by name — cancelling pending tasks, shutting down async generators and the default executor, then closing the loop — and why the hand-rolled new_event_loop/run_until_complete/close sequence usually skips them.

for a senior

An interviewer expects you to connect the lifecycle to real failures: 'Event loop is closed' from a destructor, abandoned async generators holding connections, and loop-bound objects created at import time that outlive the loop they attached to.

for a principal

Own the entry-point convention across a codebase: exactly one asyncio.run() at the process boundary, libraries exposing coroutines rather than starting loops, and a documented answer for the hosts that own their own loop.

**The loop is the runtime, and `asyncio.run()` owns its whole life.** An asyncio program is one thread turning a crank. The event loop keeps three things: a queue of callbacks that are ready to run now, a heap of timer callbacks ordered by deadline, and a selector holding every file descriptor somebody is waiting on. One iteration of the loop drains the ready queue, asks the selector which descriptors became readable or writable (blocking at most until the next timer deadline), moves the callbacks those wake into the ready queue, and runs the timers that are now due. Coroutines are not scheduled directly: a Task wraps a coroutine and steps it, and every `await` that actually suspends leaves behind a callback that will resume the Task later. `asyncio.run(coro)` is the supported way to obtain one of those loops, use it, and get rid of it. Concretely it: 1. raises `RuntimeError` if a loop is already running in this thread — asyncio does not nest; 2. creates a new loop (or one built by `loop_factory`, since 3.12) and sets it as the thread's current loop; 3. wraps `coro` in a Task and runs the loop until that Task completes; 4. on the way out — normal return, exception, or `KeyboardInterrupt` — cancels every task still pending and lets their `finally` blocks run, awaits `shutdown_asyncgens()`, awaits `shutdown_default_executor()`, unsets the current loop and closes it. Step 4 is the whole argument. Hand-written loop management usually stops at `run_until_complete()` plus `close()`. Anything still running then never learns it is finished: an `async for` over an async generator that was abandoned mid-iteration never runs its `finally`, so a connection or a file handle stays open until the garbage collector gets to it — and by then the loop is closed, so the cleanup coroutine has nothing to run on and you get a confusing "Event loop is closed" traceback from a destructor. Threads created for offloaded work are never joined, so interpreter shutdown races them. ```python import asyncio async def main(): ... asyncio.run(main()) # do this # rather than this, which skips the teardown: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(main()) finally: loop.close() ``` **Each call gets its own loop.** Two `asyncio.run()` calls in the same process create two unrelated loops, and the first is closed before the second exists. That matters because asyncio objects are loop-bound: an `asyncio.Lock` that already has waiters, an `asyncio.Queue` with a pending getter, or an open connection holds futures created by the first loop, and touching them under the second raises about a future attached to a different loop. The rule that follows is practical: build loop-bound objects inside the coroutine `asyncio.run()` drives, never at import time in a module-level global. **When one call is not enough**, reach for `asyncio.Runner` rather than raw loop management: ```python import asyncio with asyncio.Runner() as runner: a = runner.run(step_one()) b = runner.run(step_two(a)) ``` The runner keeps one loop across both `run()` calls and performs the same cancel-and-shutdown sequence when the `with` block exits, so `a`'s loop-bound objects are still valid inside `step_two`. This is what test harnesses and REPL-like tools want; application code almost never does. **Where `asyncio.run()` genuinely does not fit** is a program that is not async at the top: a GUI or a framework that owns its own loop already, or a library that must work inside somebody else's running loop. A library should therefore never call `asyncio.run()` at all — it should expose coroutines and let the caller's entry point supply the loop. Calling it from inside a running coroutine is not a style preference; it raises immediately. Finally, note what `asyncio.run()` is *not*: it is not concurrency. It runs exactly one coroutine. If that coroutine simply awaits three things in sequence, the loop still runs them one after another — concurrency comes from creating tasks inside it. `asyncio.run()` is the boundary between synchronous and asynchronous code, crossed once, at the top.

  • What happens if you call asyncio.run() from inside a coroutine that is already running?
    It raises `RuntimeError: asyncio.run() cannot be called from a running event loop`. There is one loop per thread and it cannot be re-entered, so from inside async code you `await` the coroutine, or wrap it in a task, instead of starting a second loop.
  • Why can an asyncio.Lock created under one asyncio.run() call break under the next?
    asyncio primitives attach to the loop that is running when they first suspend somebody. If the object already holds futures from the first loop, awaiting it under the second raises about a future attached to a different loop. Create loop-bound objects inside the coroutine that asyncio.run drives, not in a module-level global.
  • When would you use asyncio.Runner instead?
    When you need several separate entries on the *same* loop — a test harness running setup, body and teardown as distinct top-level calls, for instance. `asyncio.Runner` keeps one loop across its `run()` calls and performs the full cancel-and-shutdown sequence when its `with` block exits.

It is the ignition-plus-shutdown routine for the engine, not just the accelerator: it starts the loop, drives it, and switches it off cleanly on the way out.

saying these in an interview costs you the question

  • Claiming asyncio.run reuses one global loop for the process
  • Thinking you must call set_event_loop before asyncio.run
  • Believing asyncio.run can be nested inside a running coroutine
  • Treating loop.close as optional cleanup the interpreter handles
  • Saying a hand-made loop does exactly what asyncio.run does
  • Calling asyncio.run inside library code rather than the entry point

context