asyncio.Runner: Reuse the Event Loop Safely

Published on: September 2, 2026
Reading time: 6 minutes
Asynchronous programming with Python asyncio.Runner

asyncio.Runner provides a structured way to execute several top-level coroutines with the same event loop. It is useful for command-line programs, administrative tools, tests, integration scripts, and applications that must invoke asynchronous code more than once without rebuilding the complete runtime for every call. Although asyncio.run() remains the simplest choice for one asynchronous entry point, Runner gives applications more lifecycle control when they contain distinct phases.

This guide explains how to create a Runner, reuse its loop, preserve context variables, handle signals, enable debug mode, close resources, and avoid common mistakes. The goal is not merely to present syntax, but to show a dependable design for real applications.

Why asyncio.Runner exists

asyncio.run() creates a new event loop, executes one coroutine, finalizes asynchronous generators, and closes the loop. That behavior is ideal for a program with one main coroutine. Consider, however, a tool that loads configuration, synchronizes data, and then produces a report. Each phase is asynchronous, but synchronous orchestration may need to inspect the result before starting the next phase.

Calling asyncio.run() repeatedly creates separate loops. Resources, tasks, and context associated with one loop cannot be naturally reused in the next. Runner encapsulates a loop and allows multiple sequential run() calls while preserving its own context and lifecycle.

Basic use

import asyncio

async def load_config():
    await asyncio.sleep(0.1)
    return {"environment": "production"}

async def synchronize(config):
    await asyncio.sleep(0.1)
    return f"synced in {config['environment']}"

with asyncio.Runner() as runner:
    config = runner.run(load_config())
    result = runner.run(synchronize(config))
    print(result)

The context manager closes the Runner at the end of the block. Every call to run() receives an awaitable and returns its value or propagates its exception. The event loop remains available between calls.

Do not use Runner inside an active loop

Like asyncio.run(), Runner.run() cannot be invoked while another event loop is already running in the same thread. This situation is common in notebooks, asynchronous web servers, GUI integrations, and frameworks that own the loop. In those environments, use await directly or integrate the coroutine with the framework.

async def existing_flow():
    config = await load_config()
    return await synchronize(config)

# Inside async code, await coroutines.
# Do not create a nested Runner.

This rule prevents nested loops and unpredictable scheduling. A reusable library should normally expose asynchronous functions and leave loop startup to the executable application.

Sharing ContextVar values

Context variables are useful for correlation IDs, tenant identifiers, tracing information, and request-scoped metadata. Runner maintains a context that can be reused across its calls. A specific context may also be passed to run().

import asyncio
import contextvars

job_id = contextvars.ContextVar("job_id", default="unknown")

async def log_phase(name):
    print(name, job_id.get())

ctx = contextvars.copy_context()
ctx.run(job_id.set, "job-847")

with asyncio.Runner() as runner:
    runner.run(log_phase("start"), context=ctx)
    runner.run(log_phase("finish"), context=ctx)

Do not place unnecessary secrets in context variables. Context propagation simplifies observability, but it is not an authorization boundary and does not replace explicit security controls.

Debug mode

Passing debug=True enables additional asyncio checks. Debug mode can reveal slow callbacks, coroutines that were created but never awaited, and operations called from an inappropriate thread.

with asyncio.Runner(debug=True) as runner:
    runner.run(load_config())

These checks are valuable in development and continuous integration. They add overhead and may generate verbose logs, so production systems should enable them deliberately through configuration and should avoid logging sensitive values.

Custom loop creation

The loop_factory argument controls how the event loop is created. It can be used for instrumentation, platform-specific setup, or an alternative event-loop implementation. The factory is responsible for creating and correctly registering the loop.

import asyncio

def create_loop():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    loop.set_debug(False)
    return loop

with asyncio.Runner(loop_factory=create_loop) as runner:
    runner.run(load_config())

Use this feature only when a concrete requirement exists. An incorrect factory can leave resources open or conflict with libraries that assume standard behavior.

Ctrl+C and graceful interruption

Runner handles KeyboardInterrupt carefully. When the user presses Ctrl+C, the main task is cancelled so the coroutine can execute finally blocks and close resources. If the program does not respond, a subsequent interruption can stop it more directly.

async def temporary_server():
    try:
        while True:
            await asyncio.sleep(1)
    finally:
        print("releasing resources")

with asyncio.Runner() as runner:
    runner.run(temporary_server())

Do not swallow CancelledError without a deliberate reason. Perform cleanup and normally re-raise cancellation. Suppressing it may prevent predictable shutdown and leave tasks running.

Closing tasks and executors

When closed, Runner finalizes asynchronous generators, shuts down the default executor, and closes the loop. This helps prevent leaked threads and file descriptors. The application must still manage resources it created, such as HTTP clients, database pools, files, queues, and temporary directories.

async def main():
    client = create_client()
    try:
        return await client.fetch_data()
    finally:
        await client.aclose()

Prefer asynchronous context managers when a library supports them. They make ownership visible, simplify tests, and ensure that cleanup remains close to resource creation.

Organizing multiple phases

A command-line application is a strong Runner use case. Separate phases can return values to synchronous orchestration while retaining the same loop and context.

async def validate():
    return True

async def import_data():
    return 120

async def write_report(total):
    print(f"processed {total} items")

with asyncio.Runner() as runner:
    if runner.run(validate()):
        total = runner.run(import_data())
        runner.run(write_report(total))

Do not turn every small asynchronous function into a separate Runner call. If operations belong to one continuous asynchronous flow, create one main coroutine and use await internally. Multiple calls are most useful when genuinely separate phases are controlled by synchronous code.

Structured concurrency

Inside a coroutine executed by Runner, use asyncio.TaskGroup to coordinate related tasks. It propagates failures consistently and cancels sibling tasks when appropriate.

async def download(name):
    await asyncio.sleep(0.1)
    return name

async def batch():
    async with asyncio.TaskGroup() as group:
        group.create_task(download("a"))
        group.create_task(download("b"))

Avoid creating detached tasks without retaining references. An unsupervised task may fail without proper reporting or remain pending when the Runner closes.

Timeouts and resource limits

External operations need deadlines. Use asyncio.timeout() around a bounded unit of work and handle expiration at a layer that can decide whether to retry, report, or abort.

async def query_service():
    async with asyncio.timeout(5):
        return await remote_call()

Combine deadlines with concurrency limits, exponential backoff, idempotency, and circuit-breaking where appropriate. Runner manages the loop lifecycle; it does not automatically protect a service from overload or unstable dependencies.

Error handling

An exception raised by the top-level awaitable leaves run() and can be handled by synchronous orchestration. Catch only exceptions that the current layer can meaningfully address. Record useful context, but do not hide programmer errors behind a generic success result.

with asyncio.Runner() as runner:
    try:
        runner.run(synchronize({"environment": "production"}))
    except TimeoutError:
        print("operation timed out")

When several tasks fail in a TaskGroup, Python may raise an exception group. Handle specific subgroups carefully and preserve information about each failure.

Testing Runner-based code

For asynchronous unit tests, prefer the native async support provided by the test framework. Runner is especially helpful when testing a synchronous function that coordinates asynchronous phases. Every test should close its Runner and avoid global state shared between cases.

Cover success, ordinary exceptions, cancellation, timeout, resource cleanup, and pending-task detection. When signal behavior matters, add focused integration tests rather than making all unit tests depend on operating-system signals.

Threads and blocking work

Blocking calls freeze the event loop. Move short blocking functions to asyncio.to_thread(), or use a dedicated process for CPU-intensive work. Limit the number of concurrent jobs so the default executor does not become an uncontrolled queue.

async def read_legacy_file(path):
    return await asyncio.to_thread(path.read_text, encoding="utf-8")

Cancellation of the awaiting coroutine does not necessarily stop the underlying thread immediately. Design blocking functions with bounded duration and explicit cancellation strategies when required.

Production checklist

  • Use asyncio.run() for one simple asynchronous entry point.
  • Use Runner when synchronous orchestration needs several asynchronous phases.
  • Never call Runner inside an active loop.
  • Close clients, pools, and files explicitly.
  • Propagate cancellation after cleanup.
  • Use TaskGroup for related concurrent tasks.
  • Apply timeouts and concurrency limits.
  • Enable debug checks in development.
  • Avoid a custom loop factory without a real requirement.
  • Keep loop ownership in the application layer.

Continue learning with the Academify guides on Python asyncio, Python TaskGroup, Python contextvars, and asyncio timeouts.

Conclusion

asyncio.Runner is a high-level lifecycle tool for synchronous programs that execute several asynchronous phases. It reuses one event loop, preserves context, coordinates interruption handling, and closes the underlying runtime. Its strongest use cases are command-line applications and administrative tools with distinct phases. Inside a continuous asynchronous workflow, prefer one main coroutine, direct await, structured concurrency, and explicit resource ownership.

External sources

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Binary data compression with Zstandard in Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    compression.zstd: Zstandard Streams and Dictionaries

    Learn Python compression.zstd for Zstandard compression, streaming, dictionaries, safe limits, testing, and production workflows.

    Ler mais

    Tempo de leitura: 6 minutos
    01/09/2026
    Python application packaged as an executable zipapp archive
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python zipapp: Build Executable Apps

    Learn Python zipapp to package applications as executable pyz archives, include dependencies, and distribute tools safely.

    Ler mais

    Tempo de leitura: 5 minutos
    01/09/2026
    Python code used to compose functions with functools.Placeholder
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    functools.Placeholder: Positional Gaps in partial

    Learn Python functools.Placeholder to leave positional gaps in partial functions and build clearer reusable functional APIs.

    Ler mais

    Tempo de leitura: 5 minutos
    31/08/2026
    Developer coding and analyzing data with Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    itertools.pairwise: Compare Neighboring Values

    Learn Python itertools.pairwise to compare neighboring values, detect transitions, calculate deltas, and build clear lazy pipelines.

    Ler mais

    Tempo de leitura: 4 minutos
    31/08/2026
    A person typing on a laptop with a Python programming book visible, capturing technology and learning.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python types.new_class: Dynamic Classes

    Learn Python types.new_class for dynamic classes with metaclasses, prepared namespaces, inheritance, metadata, and safe factories.

    Ler mais

    Tempo de leitura: 3 minutos
    30/08/2026
    A close-up shot showcasing the intricate scales of a snake, highlighting texture and color.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    partialmethod: Build Specialized Methods in Python

    Learn Python partialmethod for specialized methods with correct binding, fewer wrappers, clear domain names, and safe introspection.

    Ler mais

    Tempo de leitura: 2 minutos
    30/08/2026