Skip to content

Replacing Removed asyncio APIs

Old asyncio code rarely fails on a new Python because of a subtle behaviour change; it fails at import or first call because an API it used is gone. Probing the same list of names on Python 3.10.20, 3.11.15, 3.12.13, 3.13.14 and 3.14.6 gave a precise timeline: the loop= parameter on asyncio.Lock, Queue and friends raised TypeError from 3.10; @asyncio.coroutine raised AttributeError from 3.11, as did passing bare coroutines to asyncio.wait (TypeError); asyncio.get_event_loop() with no running loop warned on 3.12 and 3.13 and raised RuntimeError on 3.14; child watchers such as get_child_watcher and ThreadedChildWatcher warned on 3.12 and were gone in 3.14; and on 3.14 asyncio.iscoroutinefunction and the event loop policy functions emit DeprecationWarning. This guide lists each removal, the replacement, and how to find every use before an upgrade finds it for you.

Prerequisites

1. Probe the APIs your code uses on each interpreter

Documentation tells you when something was deprecated; a probe tells you what happens on the interpreters you actually run. This one tries each name and records the exception or warning:

import asyncio
import sys
import warnings


def probe(name, fn):
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        try:
            fn()
            outcome = "ok"
        except Exception as exc:
            outcome = type(exc).__name__
    if caught:
        outcome += " + " + caught[0].category.__name__
    print(f"{sys.version.split()[0]:8} {name:36} {outcome}")


probe("asyncio.coroutine", lambda: asyncio.coroutine)
probe("asyncio.Lock(loop=None)", lambda: asyncio.Lock(loop=None))
probe("asyncio.get_event_loop()", lambda: asyncio.get_event_loop())
probe("asyncio.get_child_watcher()", lambda: asyncio.get_child_watcher())
probe("asyncio.iscoroutinefunction", lambda: asyncio.iscoroutinefunction(print))
probe("asyncio.get_event_loop_policy()", lambda: asyncio.get_event_loop_policy())

Run it with uv run --python 3.14 probe.py and the other versions you support, adding the names your code base uses. The results below are from that probe; any name that prints ok on your oldest version and an error on your newest is a migration item.

Verify: the probe runs on your oldest and newest supported interpreters and every non-ok line is on your list.

Removed and deprecated asyncio APIs, as probed A grid of 7 rows by 5 columns. Removed and deprecated asyncio APIs, as probed API 3.10 3.11 3.12-3.13 3.14 loop= on Lock, Queue, Event TypeError TypeError TypeError TypeError @asyncio.coroutine ok AttributeError AttributeError AttributeError asyncio.wait([coroutine]) warns TypeError TypeError TypeError get_event_loop(), no loop ok ok DeprecationWarning RuntimeError get_child_watcher() ok ok DeprecationWarning AttributeError asyncio.iscoroutinefunction ok ok ok DeprecationWarning get/set_event_loop_policy ok ok ok DeprecationWarning CPython 3.10.20, 3.11.15, 3.12.13, 3.13.14 and 3.14.6.

2. Remove loop= arguments and generator-based coroutines

The two oldest removals affect code written before Python 3.7. Synchronisation primitives and queues stopped accepting loop= in 3.10 — they now bind to the running loop the first time they are used:

# before: fails with TypeError on 3.10 and later
lock = asyncio.Lock(loop=loop)
queue = asyncio.Queue(maxsize=100, loop=loop)

# after: create them anywhere; they attach to the loop that first uses them
lock = asyncio.Lock()
queue = asyncio.Queue(maxsize=100)

The decorator-based coroutine style was removed in 3.11, where the attribute asyncio.coroutine no longer exists:

# before: AttributeError on import, 3.11 and later
@asyncio.coroutine
def fetch(url):
    response = yield from session.get(url)
    return response

# after
async def fetch(url):
    response = await session.get(url)
    return response

Because both fail loudly — at import for the decorator, at construction for loop= — they are found by simply importing every module under the new interpreter: python -c "import pkgutil, mypkg; [__import__(m.name) for m in pkgutil.walk_packages(mypkg.__path__, 'mypkg.')]" reaches every module, including ones no test touches.

Verify: grep -rn "loop=\|asyncio.coroutine\|yield from" src/ returns nothing related to asyncio.

3. Wrap coroutines before passing them to asyncio.wait

asyncio.wait used to accept coroutine objects and wrap them in tasks silently. That warned on 3.10 and raised TypeError from 3.11, measured on every later version:

# before: TypeError on 3.11 and later
done, pending = await asyncio.wait([fetch(a), fetch(b)], timeout=5)

# after: create the tasks yourself, so you can also cancel the pending ones
tasks = [asyncio.create_task(fetch(a)), asyncio.create_task(fetch(b))]
done, pending = await asyncio.wait(tasks, timeout=5)
for task in pending:
    task.cancel()

The old form hid a real problem: with coroutines passed in, the caller had no references to the tasks wait created, so it could not compare them with the returned sets or cancel the stragglers. The same rule — tasks in, tasks out — applies to asyncio.as_completed. On 3.11 and later, many of these call sites read better as a TaskGroup or asyncio.timeout, as covered in using asyncio.wait with FIRST_COMPLETED and FIRST_EXCEPTION.

Verify: every asyncio.wait( call receives tasks or futures, and pending tasks are cancelled or awaited.

4. Drop child watchers and replace asyncio.iscoroutinefunction

Child watchers were the Unix mechanism asyncio used to learn that a subprocess had exited. Since 3.8 the default has worked without configuration, and in 3.14 the whole API — get_child_watcher, set_child_watcher, ThreadedChildWatcher, PidfdChildWatcher and the rest — is gone. Code that set a watcher to make subprocesses work from a non-main thread can delete those lines:

# before: AttributeError on 3.14
watcher = asyncio.ThreadedChildWatcher()
asyncio.set_child_watcher(watcher)

# after: nothing; create_subprocess_exec works from any thread's loop
proc = await asyncio.create_subprocess_exec("git", "status")
await proc.wait()

asyncio.iscoroutinefunction warns on 3.14 in favour of inspect.iscoroutinefunction, and they are not quite the same. Measured on 3.11, 3.12 and 3.14: both returned True for an async def function, a functools.partial of one and an AsyncMock. For a plain function carrying asyncio's legacy _is_coroutine marker — what @asyncio.coroutine used to set, and what some frameworks set by hand — the asyncio version returned True and the inspect version False. If you mark sync functions that return awaitables, switch to inspect.markcoroutinefunction, available since 3.12, which both checks recognise:

import inspect

def handler(request):                    # returns an awaitable
    return process(request)

inspect.markcoroutinefunction(handler)
assert inspect.iscoroutinefunction(handler)

Verify: running your test suite with -W error::DeprecationWarning on 3.14 reports no iscoroutinefunction or child-watcher warnings.

Which check recognises which callable A grid of 5 rows by 3 columns. Which check recognises which callable callable asyncio.iscoroutinefunction inspect.iscoroutinefunction async def True True functools.partial(async def) True True unittest.mock.AsyncMock() True True sync fn, legacy _is_coroutine marker True False sync fn, inspect.markcoroutinefunction True True Measured on 3.11, 3.12 and 3.14; the asyncio function warns on 3.14.

5. Find the rest before the next release removes them

The next removals are already announced as deprecation warnings. Run your tests under the newest interpreter with asyncio's deprecations as errors, so a new one fails CI rather than appearing as a log line nobody reads. Filter on the message, not the module:

[tool.pytest.ini_options]
filterwarnings = [
    "error:.*asyncio.*:DeprecationWarning",
]

The obvious filter, error::DeprecationWarning:asyncio, matched nothing in testing: asyncio raises these warnings with a stack level that attributes them to your module, the caller, so a module filter naming asyncio never fires — two tests calling deprecated functions passed with it and failed with the message filter. The messages themselves are explicit, for example "'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead". On 3.14 this catches get_event_loop_policy, set_event_loop_policy and AbstractEventLoopPolicy, which are slated for removal in 3.16 and are replaced by asyncio.run(..., loop_factory=...) as shown in migrating off event loop policies in Python 3.14. Static tools help with the syntax side: given --target-version py310, ruff reported except* as invalid-syntax ("syntax was added in Python 3.11"), and mypy with --python-version 3.10 reported "Exception groups: requires Python 3.11 or newer". For attributes the tools differed: pyright 1.1.414 with --pythonversion 3.10 flagged asyncio.TaskGroup as "not a known attribute of module asyncio", while mypy 2.4.0 with --python-version 3.10 passed the same file — so a type checker run is not proof that a name exists on your oldest version. The probe from step 1 and a real test run are.

Verify: CI runs the suite on the newest Python with asyncio deprecations as errors.

Clearing removed APIs before an upgrade A flow of 5 stages. Clearing removed APIs before an upgrade Probe each name, each version Import every module catches decorator removals Fix call sites loop=, wait(), watchers Swap to inspect markcoroutinefunction Warnings as errors catches the next removals Removals fail loudly; deprecations do not, until they become removals.

Verification

Your code is clear of removed asyncio APIs when:

  • The probe prints ok or an intended replacement for every API you use, on every supported version.
  • Every module imports under the newest interpreter.
  • No loop=, @asyncio.coroutine, bare coroutines in asyncio.wait or child watchers remain.
  • asyncio DeprecationWarnings are errors in CI on the newest Python.

Diagnostic Hook: at service start-up, log sys.version and run with PYTHONWARNINGS=default::DeprecationWarning in staging. A deprecation that appears only in staging logs is the warning you have one release to act on; a staging deploy on a new interpreter that fails at import is the removal you missed.

Pitfalls & edge cases

  • Assuming deprecated means present. get_child_watcher warned on 3.12 and was gone on 3.14.
  • Swapping to inspect.iscoroutinefunction blindly. It returned False for legacy-marked functions.
  • Trusting a type checker for attribute availability. mypy 2.4.0 missed asyncio.TaskGroup on a 3.10 target.
  • Leaving pending tasks from asyncio.wait. Cancel or await them.

Frequently Asked Questions

What replaced @asyncio.coroutine?

async def with await instead of yield from. The decorator was removed in Python 3.11, where referencing asyncio.coroutine raises AttributeError.

Why does asyncio.Lock(loop=loop) raise TypeError?

The loop parameter was removed from asyncio primitives and queues in Python 3.10. Create them without it; they bind to the loop that first uses them.

What happened to asyncio child watchers?

They were deprecated in 3.12 and removed in 3.14. The default subprocess support works from any thread without configuration, so delete the watcher setup.

Is inspect.iscoroutinefunction a drop-in replacement?

For async def functions, partials and AsyncMock, yes. Sync functions marked with asyncio's legacy _is_coroutine attribute are not recognised; mark them with inspect.markcoroutinefunction (3.12+) instead.