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¶
- uv, or several Python versions installed side by side.
- The
get_event_loopchanges, covered in depth in replacing get_event_loop deprecation warnings. - The topic overview, Asyncio Across Python Versions.
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.
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.
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.
Verification¶
Your code is clear of removed asyncio APIs when:
- The probe prints
okor 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 inasyncio.waitor 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_watcherwarned on 3.12 and was gone on 3.14. - Swapping to
inspect.iscoroutinefunctionblindly. It returnedFalsefor legacy-marked functions. - Trusting a type checker for attribute availability. mypy 2.4.0 missed
asyncio.TaskGroupon 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.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Handling wait_for behaviour changes in Python 3.12 — a change that does not raise.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.