Asyncio Across Python Versions¶
asyncio has changed more between Python 3.10 and 3.14 than in any comparable stretch since it was introduced. Structured concurrency arrived (TaskGroup, asyncio.timeout, exception groups), long-deprecated APIs were removed, the event loop policy system was deprecated, and 3.14 added real introspection of running tasks. For a codebase that spans several Python versions — a library supporting the last four releases, a service fleet mid-upgrade — the question is not "what is new" but "what behaves differently on the version I am running". To answer it concretely, the same probe script was run on CPython 3.10.20, 3.11.15, 3.12.13, 3.13.14 and 3.14.4: it calls get_event_loop() with no loop, passes coroutines to asyncio.wait, checks whether wait_for runs in the caller's task, and tests for each new primitive. The results, summarised below, are the backbone of this section.
The upgrade risks cluster in three places. Loop acquisition — asyncio.get_event_loop() outside a running loop went from silently creating a loop (3.10–3.11) to a DeprecationWarning (3.12–3.13) to a RuntimeError (3.14). Implicit task creation — asyncio.wait() with coroutines went from a DeprecationWarning in 3.10 to a TypeError from 3.11. And policies — set_event_loop_policy became deprecated in 3.14, slated for removal in 3.16. Everything else is additive. The parent section, Asyncio Fundamentals & Event Loop Architecture, covers the loop model these changes sit on.
Scope of this section:
- A per-version map of removals, deprecations and new primitives, measured rather than recalled.
- Replacing
get_event_loop()patterns before 3.14 turns their warnings into errors. - Moving off event loop policies to
loop_factory. - Adopting
TaskGroupandasyncio.timeout()when the minimum version reaches 3.11. - Inspecting running services with the 3.14 call-graph tools.
- Testing one codebase across several Python versions.
Architectural principles¶
- Upgrade the floor, not just the ceiling. New primitives only help once the minimum supported version has them. A library supporting 3.10 cannot use
TaskGroupwithout a fallback; a service pinned to one version can use everything at once. - Treat deprecation warnings as errors in CI. Every removal in this period was preceded by at least one release of
DeprecationWarning. A suite that runs with-W error::DeprecationWarningfinds the 3.14 breakage on 3.12. - Create loops in one place. Nearly every version-specific failure involves code that acquires a loop implicitly. Code that always uses
asyncio.run()orasyncio.Runnerat the entry point andget_running_loop()everywhere else is unaffected by the whole loop-acquisition saga. - Prefer behaviour checks to version checks.
hasattr(asyncio, "TaskGroup")survives backports and alternative implementations;sys.version_info >= (3, 11)does not tell you what a patched runtime supports. - Run the suite on every version you claim. The probe results below differ in ways no changelog skim reliably reveals, such as
wait_formoving into the caller's task in 3.12.
Execution model: what actually changed underneath¶
Most of the period's changes are new APIs, but three change how existing code executes, and those are the ones that bite during an upgrade.
Loop acquisition became explicit. In 3.10, calling asyncio.get_event_loop() from the main thread with no loop set quietly created one through the policy and stored it as the current loop. Code written as loop = asyncio.get_event_loop(); loop.run_until_complete(main()) relied on that. 3.12 and 3.13 emit a DeprecationWarning in that situation; 3.14 raises RuntimeError: There is no current event loop in thread 'MainThread'. Inside a running coroutine, get_event_loop() still returns the running loop on every version — which is why the breakage appears at entry points and in module-level code, not in handlers. The replacement patterns are in replacing get_event_loop deprecation warnings.
wait_for moved into the caller's task. Before 3.12, asyncio.wait_for(coro, t) wrapped the coroutine in a new task, so context-variable changes inside it were invisible to the caller and cancellation semantics had edge cases. From 3.12 it is implemented on top of asyncio.timeout() and runs the coroutine in the caller's task. Measured: a ContextVar.set() inside the awaited coroutine was invisible afterwards on 3.10 and 3.11 and visible on 3.12 through 3.14. Code that accidentally depended on the isolation changes behaviour silently.
Policies stopped being the extension point. Event loop policies decided which loop class asyncio.run() and get_event_loop() produced. 3.12 added loop_factory to asyncio.run(), 3.11's Runner already had it, and 3.14 deprecated the whole policy API — set_event_loop_policy, get_event_loop_policy, DefaultEventLoopPolicy and the Windows policy classes — for removal in 3.16. Selecting uvloop or a Windows selector loop through a policy now warns.
Pattern catalogue¶
Acquire loops only at the entry point¶
When to use: always, and urgently before running on 3.14.
# before: breaks on 3.14 when no loop is set
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
# after: works on 3.7 through 3.14
asyncio.run(main())
# inside coroutines, on every version
loop = asyncio.get_running_loop()
Trade-off: asyncio.run() closes the loop afterwards, so code that ran several run_until_complete calls on one loop needs asyncio.Runner (3.11+) instead, as covered in reusing one loop across calls with asyncio.Runner.
Choose the loop with loop_factory, not a policy¶
When to use: anywhere a policy selects uvloop or the Windows selector loop.
import asyncio
import sys
def make_loop():
if sys.platform != "win32":
try:
import uvloop
return uvloop.new_event_loop()
except ImportError:
pass
return asyncio.new_event_loop()
if sys.version_info >= (3, 12):
asyncio.run(main(), loop_factory=make_loop)
else:
with asyncio.Runner(loop_factory=make_loop) as runner: # 3.11
runner.run(main())
Trade-off: on 3.10 neither exists, so a 3.10-supporting codebase keeps the policy branch behind a version check until 3.10 is dropped. The full migration is in migrating off event loop policies in Python 3.14.
Feature-detect new primitives¶
When to use: libraries whose supported range straddles the version that introduced a primitive.
import asyncio
if hasattr(asyncio, "timeout"):
timeout = asyncio.timeout # 3.11+
else:
from async_timeout import timeout # pip install async-timeout
if hasattr(asyncio, "TaskGroup"):
TaskGroup = asyncio.TaskGroup
else:
from taskgroup import TaskGroup # pip install taskgroup (backport)
Trade-off: backports reproduce the API but not every semantic detail — exception groups need the exceptiongroup backport and except* is syntax that 3.10 cannot parse at all. When a primitive changes how errors propagate, raising the minimum version is often cheaper than maintaining two behaviours. See adopting TaskGroup and timeout when upgrading from 3.10.
Wrap coroutines before passing them to wait()¶
When to use: any asyncio.wait() call written before 3.11.
done, pending = await asyncio.wait(
[asyncio.create_task(c) for c in coros], # tasks, never bare coroutines
return_when=asyncio.FIRST_COMPLETED,
)
Trade-off: none — this was always the correct usage, and holding the tasks is what lets you cancel the pending ones. The full behaviour is in using asyncio.wait with FIRST_COMPLETED and FIRST_EXCEPTION.
Use the 3.14 introspection tools where available¶
When to use: diagnosing stuck services on 3.14.
def dump_tasks() -> None:
for task in asyncio.all_tasks():
if hasattr(asyncio, "print_call_graph"):
asyncio.print_call_graph(task) # 3.14: full await chain
else:
task.print_stack() # earlier: outermost frame only
Trade-off: the external python -m asyncio ps/pstree commands read another process's memory, so they need ptrace rights — under Linux's default Yama setting (ptrace_scope=1) a non-parent reader fails with a misleading Failed to find the PyRuntime section error until the target opts in or the reader has CAP_SYS_PTRACE. Keep the in-process dump for environments where you cannot grant that. Details in inspecting running tasks with asyncio ps and pstree.
Version-by-version checklist¶
What to do at each step, assuming the codebase already runs cleanly on the version before it.
Moving to 3.11. The release where structured concurrency became available, and where asyncio.wait() started rejecting coroutines. Search for asyncio.wait( and make sure every argument is a task. Search for @asyncio.coroutine and generator-based coroutines (yield from inside functions used as coroutines); the decorator was removed in 3.11 and such code no longer imports. This is also the version where asyncio.timeout() makes async-timeout unnecessary and TaskGroup replaces most hand-rolled gather() plus cancellation logic. Do not rush the rewrite: gather() still works, and the semantic difference — a TaskGroup cancels siblings on the first failure and raises an ExceptionGroup — changes error handling at every call site, which deserves its own review rather than a mechanical replace.
Moving to 3.12. Run the suite with -W error::DeprecationWarning. The get_event_loop() warnings appear here, in module-level code, thread entry points and test fixtures; fix them now, because 3.14 turns them into errors. Two behaviour changes to check deliberately: wait_for now runs in the caller's task, and the eager task factory exists, which tempts people to turn it on globally. Eager tasks change ordering — a task's first step runs inside create_task() — so enable them only after measuring, as discussed in using the eager task factory in Python 3.12.
Moving to 3.13. Mostly additive. Queue.shutdown() replaces sentinel values for stopping consumers, and as_completed becomes an async iterator that yields your own tasks. The quiet change is executor sizing from process CPU affinity; check to_thread-heavy paths under load. If you run free-threaded builds experimentally, 3.13 is the first release where that is possible at all, but asyncio itself is not the bottleneck it removes.
Moving to 3.14. The breaking release for old patterns. get_event_loop() without a current loop raises; every policy API warns; asyncio.iscoroutinefunction warns in favour of inspect.iscoroutinefunction. In return you get capture_call_graph, print_call_graph, the ps and pstree commands, and a token that works as a context manager for context variables. If your CI ran with warnings as errors on 3.12 and 3.13, the 3.14 upgrade is a non-event; if it did not, expect a day of fixing entry points.
Resource boundaries¶
Version changes also moved some limits and defaults that affect capacity:
| Area | Change | Effect on capacity |
|---|---|---|
| Default executor size | min(32, cpu + 4); 3.13+ counts os.process_cpu_count() |
containers with CPU affinity get smaller pools |
shutdown_default_executor |
3.12+ waits at most 300 s by default | shutdown no longer hangs forever on stuck threads |
| Eager tasks | 3.12+ opt-in factory | fewer loop iterations for tasks that finish synchronously |
Queue.shutdown |
3.13+ | consumers can be released without sentinel items |
as_completed |
3.13+ async iteration yields original tasks | result-to-input mapping without wrappers |
| Thread context inheritance | 3.14 sys.flags.thread_inherit_context |
on by default only in free-threaded builds |
The executor change is the one most likely to show up as a performance difference: a container pinned to four CPUs on a 64-core host gets a pool of 8 threads on 3.13+ where 3.12 computed 32 from the host's count. If to_thread latency rises after an upgrade, check the pool size first, as described in sizing the default thread pool executor.
Integrated production example¶
A compatibility shim that a service or library imports instead of reaching into asyncio directly. It centralises every version-dependent choice, so upgrading the floor later means deleting branches from one file:
"""asyncio_compat: one place for every version-dependent asyncio choice."""
import asyncio
import sys
import warnings
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
T = TypeVar("T")
PY311 = sys.version_info >= (3, 11)
PY312 = sys.version_info >= (3, 12)
# --- structured concurrency --------------------------------------------------
if hasattr(asyncio, "TaskGroup"):
TaskGroup = asyncio.TaskGroup
timeout = asyncio.timeout
else: # 3.10 backports
from taskgroup import TaskGroup # type: ignore[no-redef]
from async_timeout import timeout # type: ignore[no-redef]
# --- loop selection -----------------------------------------------------------
def _default_factory() -> asyncio.AbstractEventLoop:
if sys.platform != "win32":
try:
import uvloop
return uvloop.new_event_loop()
except ImportError:
pass
return asyncio.new_event_loop()
def run(main: Callable[[], Awaitable[T]], *, debug: bool | None = None,
loop_factory=_default_factory) -> T:
"""asyncio.run with a loop factory on every supported version."""
if PY312:
return asyncio.run(main(), debug=debug, loop_factory=loop_factory)
if PY311:
with asyncio.Runner(debug=debug, loop_factory=loop_factory) as runner:
return runner.run(main())
loop = loop_factory() # 3.10: manage the loop by hand
try:
asyncio.set_event_loop(loop)
if debug is not None:
loop.set_debug(debug)
return loop.run_until_complete(main())
finally:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
asyncio.set_event_loop(None)
loop.close()
# --- introspection ------------------------------------------------------------
def describe_task(task: asyncio.Task) -> list[str]:
"""Innermost-first await chain, using the best API this version offers."""
if hasattr(asyncio, "capture_call_graph"): # 3.14
graph = asyncio.capture_call_graph(task)
return [f"{e.frame.f_code.co_name}:{e.frame.f_lineno}" for e in graph.call_stack] if graph else []
chain, coro = [], task.get_coro()
while coro is not None: # earlier: walk cr_await by hand
frame = getattr(coro, "cr_frame", None)
if frame is not None:
chain.append(f"{frame.f_code.co_name}:{frame.f_lineno}")
coro = getattr(coro, "cr_await", None)
return list(reversed(chain))
# --- guard rails --------------------------------------------------------------
def enable_strict_warnings() -> None:
"""Call from tests: every asyncio deprecation becomes an error on every version."""
warnings.filterwarnings("error", category=DeprecationWarning, module="asyncio")
Diagnostic Hook: log sys.version, the loop class from type(asyncio.get_running_loop()) and whether the eager factory is installed at startup, and export them as labels on a process-info metric. When behaviour differs between two replicas, the first check is whether they are on the same interpreter and loop, and having it in metrics answers that without a shell.
Diagnostic hook callout¶
During an upgrade, watch three signals:
- Deprecation warnings in staging logs. Run with
PYTHONWARNINGS=default::DeprecationWarning(or-X dev) and countasynciowarnings by message. Each distinct message is a migration task; the count should go to zero before the floor is raised. RuntimeError: There is no current event loopafter moving to 3.14. It comes from module-level or thread code callingget_event_loop(); the traceback names the line.- Latency of
to_thread-heavy paths after moving to 3.13+, because of the executor sizing change. Compare executor queue wait before and after.
Alert thresholds are simple: any asyncio DeprecationWarning in CI fails the build, and any RuntimeError mentioning the event loop in production is a release blocker.
Failure modes¶
| Failure mode | Root cause | Detection | Fix |
|---|---|---|---|
RuntimeError: There is no current event loop on 3.14 |
get_event_loop() with no loop set |
traceback at startup or in a thread | asyncio.run() at entry, get_running_loop() inside |
TypeError: Passing coroutines is forbidden |
asyncio.wait() with coroutines on 3.11+ |
immediate error on upgrade | wrap with create_task |
Policy DeprecationWarning on 3.14 |
set_event_loop_policy for uvloop or Windows |
warnings in logs, errors in strict CI | loop_factory on asyncio.run or Runner |
ContextVar set inside wait_for now visible |
wait_for runs in the caller's task from 3.12 |
behaviour differs between versions | do not rely on either; return values explicitly |
Slower to_thread after upgrade |
3.13+ sizes the pool from process CPU affinity | executor queue wait rises | set the default executor explicitly |
except* syntax error |
code shared with 3.10 | import fails on 3.10 | raise the floor to 3.11 or keep separate modules |
asyncio.iscoroutinefunction warning |
deprecated in 3.14 | DeprecationWarning |
inspect.iscoroutinefunction |
Frequently Asked Questions¶
What changed in asyncio between Python 3.10 and 3.14?
TaskGroup, asyncio.timeout, Runner and exception groups arrived in 3.11; eager tasks and loop_factory on asyncio.run in 3.12; Queue.shutdown and async-iterable as_completed in 3.13; call-graph introspection and the deprecation of event loop policies in 3.14. get_event_loop without a running loop went from silently creating one to a RuntimeError in 3.14.
Why does asyncio.get_event_loop() raise RuntimeError on Python 3.14?
Because it no longer creates a loop implicitly when none is set. It warned about this in 3.12 and 3.13. Use asyncio.run() or asyncio.Runner at the entry point and asyncio.get_running_loop() inside coroutines.
Are asyncio event loop policies deprecated?
Yes. In Python 3.14, set_event_loop_policy, get_event_loop_policy and the policy classes emit DeprecationWarning and are slated for removal in 3.16. Pass a loop_factory to asyncio.run (3.12+) or asyncio.Runner (3.11+) instead.
Can I use asyncio.TaskGroup on Python 3.10?
Not from the standard library; it was added in 3.11. The taskgroup and async-timeout packages backport TaskGroup and timeout, but except* syntax is unavailable on 3.10, so raising the minimum version is often the simpler path.
Did asyncio.wait_for change behaviour?
Yes. From Python 3.12 it is implemented with asyncio.timeout and runs the coroutine in the caller's task rather than a new one. In testing, a ContextVar set inside the awaited coroutine was invisible to the caller on 3.10 and 3.11 and visible from 3.12.
Related¶
- Replacing get_event_loop deprecation warnings — the most common upgrade breakage, fixed per pattern.
- Migrating off event loop policies in Python 3.14 — uvloop, Windows and custom loops via loop_factory.
- Adopting TaskGroup and timeout when upgrading from 3.10 — rewriting gather and wait_for code.
- Testing asyncio code across Python versions — one suite, five interpreters.
- Asyncio Fundamentals & Event Loop Architecture — the parent section.