Handling wait_for Behaviour Changes in Python 3.12¶
asyncio.wait_for was reimplemented in Python 3.12 on top of asyncio.timeout. The signature did not change, and most code never notices, but three behaviours did. Measured on Python 3.10.20, 3.11.15, 3.12.13, 3.13.14 and 3.14.6 with the same script: on 3.10 and 3.11, wait_for wrapped the coroutine in a new task, so asyncio.current_task() inside it was a different object and a ContextVar set inside it was invisible to the caller afterwards; on 3.12 and later the coroutine ran in the caller's own task, and the variable it set stayed set. The per-call overhead fell from 14.6 µs on 3.10 and 10.8 µs on 3.11 to 3.7 µs on 3.12 and 2.3 µs on 3.14. And when the caller was cancelled while the wrapped coroutine caught CancelledError and returned a value, 3.10 and 3.11 raised CancelledError while 3.12 to 3.14 returned the value — the cancellation was lost. This guide shows how to find code that depends on any of these and make it behave the same on every version.
Prerequisites¶
- uv or another way to run the same script under several interpreters.
- How cancellation is delivered, from telling TimeoutError apart from CancelledError.
- The topic overview, Asyncio Across Python Versions.
1. Reproduce the differences on your interpreters¶
Before changing code, see the behaviour for yourself. This script checks task identity, context visibility and the timeout exception type:
import asyncio
import contextvars
import sys
var = contextvars.ContextVar("var", default="unset")
async def inner():
var.set("set inside")
await asyncio.sleep(0)
return asyncio.current_task()
async def main():
outer = asyncio.current_task()
task = await asyncio.wait_for(inner(), timeout=1)
print(sys.version.split()[0],
"same task:", task is outer,
"| var after:", var.get(),
"| asyncio.TimeoutError is TimeoutError:", asyncio.TimeoutError is TimeoutError)
asyncio.run(main())
Run it with uv run --python 3.11 script.py, then 3.12. Measured: 3.10 and 3.11 printed same task: False | var after: unset; 3.12, 3.13 and 3.14 printed same task: True | var after: set inside. The last column was False only on 3.10: since 3.11, asyncio.TimeoutError is an alias of the builtin TimeoutError, and on 3.10 except TimeoutError does not catch a wait_for timeout at all — a test asserting pytest.raises(TimeoutError) around wait_for failed on 3.10 and passed on 3.11.
Verify: you have the output of this script for the oldest and newest Python version you support.
2. Find code that relies on the separate task¶
Under 3.11 and earlier, the hidden task gave every wait_for call a private copy of the context. Code could, knowingly or not, depend on that isolation:
request_id = contextvars.ContextVar("request_id", default=None)
async def call_backend(payload):
request_id.set(payload["sub_request_id"]) # meant for this call only
...
async def handler(payload):
request_id.set(payload["id"])
await asyncio.wait_for(call_backend(payload), 5)
log.info("done", extra={"request_id": request_id.get()})
On 3.11 the log line carried the handler's ID; on 3.12 it carried the sub-request's ID, because call_backend now runs in the handler's context. The same applies to anything that inspects asyncio.current_task() — per-task registries, task names used in logs, or code that calls current_task().cancel() expecting to cancel only the wrapped work. Search for wait_for( and read the wrapped coroutine for ContextVar.set, current_task() and task-local state. Where isolation is wanted, make it explicit rather than relying on an implementation detail:
async def isolated(coro, timeout):
task = asyncio.create_task(coro) # new task, copied context
return await asyncio.wait_for(task, timeout) # same result on 3.10 to 3.14
create_task copies the current context, so changes made inside stay inside on every version. The alternative — and usually the better design — is for the coroutine not to mutate shared context at all, as discussed in why ContextVar changes do not flow back from tasks.
Verify: a test that sets a ContextVar inside a wait_for-wrapped coroutine and asserts the caller's value passes on both 3.11 and 3.12.
3. Stop losing cancellations that the wrapped code swallows¶
The subtle change is cancellation. Consider a coroutine that catches CancelledError to finish cleanly and return a partial result:
async def stubborn():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
await asyncio.sleep(0.05) # flush, then return instead of re-raising
return "late"
async def main():
t = asyncio.create_task(asyncio.wait_for(stubborn(), 5))
await asyncio.sleep(0.01)
t.cancel() # cancel the *caller*, not a timeout
print(await t)
Measured: on 3.10 and 3.11, await t raised CancelledError — the old wait_for re-raised cancellation itself after the inner task finished. On 3.12, 3.13 and 3.14 it printed late: the coroutine ran in the caller's task, swallowed the cancellation, and nothing re-raised it. A shutdown that cancels a worker can therefore find it still running, because the worker's loop treated "late" as a normal result. The fix is the rule that applies everywhere in asyncio — code that catches CancelledError must re-raise it:
async def well_behaved():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
await asyncio.sleep(0.05) # cleanup is fine
raise # but cancellation continues
Where you cannot change the wrapped code, check asyncio.current_task().cancelling() after the call returns (available since 3.11) and re-raise if it is non-zero. The general pattern is covered in understanding Task.cancelling and uncancel.
Verify: grep -n "except.*CancelledError" -A5 finds no handler that ends in return or pass without raise.
4. Catch the timeout the same way on every version¶
The timeout path did not change semantically, but the exception spelling matters on 3.10. If you still support 3.10, catch asyncio.TimeoutError, which is the builtin TimeoutError on 3.11 and later and the asyncio-specific class on 3.10:
try:
result = await asyncio.wait_for(fetch(), 2)
except asyncio.TimeoutError: # correct on 3.10 and later
result = None
Once your minimum is 3.11, write except TimeoutError and replace wait_for with asyncio.timeout, as covered in adopting TaskGroup and timeout when upgrading from 3.10. On 3.12 and later the two cost about the same — 2.3 µs for wait_for and 2.9 µs for async with asyncio.timeout per call on 3.14 — so the choice is about readability, not speed. A timeout that fires while the wrapped coroutine swallows the resulting cancellation returned the coroutine's value on every version tested, from 3.10 to 3.14, so that edge case needs no version-specific handling.
Verify: a test that forces a wait_for timeout passes on your oldest supported interpreter.
5. Pin the new behaviour in tests¶
Behaviour you depend on should be stated in a test, so the next interpreter upgrade cannot change it silently. These use pytest-asyncio in auto mode:
import asyncio
import contextvars
import pytest
var = contextvars.ContextVar("var", default="unset")
async def sets_var():
var.set("inside")
async def test_wait_for_context_is_not_leaked_by_our_helper():
await isolated(sets_var(), 1)
assert var.get() == "unset"
async def test_cancellation_is_not_swallowed():
task = asyncio.create_task(asyncio.wait_for(well_behaved(), 5))
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
Run the suite on every supported version, as in testing asyncio code across Python versions. Both tests passed on 3.10 through 3.14; the versions of them that relied on wait_for itself for isolation and for re-raising passed on 3.11 and failed on 3.12.
Verify: the tests run in CI against the oldest and newest supported Python.
Verification¶
Your wait_for calls behave the same on every supported version when:
- No wrapped coroutine depends on running in its own task, or the isolation is made explicit with
create_task. - Every
except CancelledErrorre-raises, so outer cancellation is never lost. - Timeouts are caught as
asyncio.TimeoutErrorwhile 3.10 is supported. - Tests pin the behaviour and run on the oldest and newest interpreter.
Diagnostic Hook: after a shutdown, log any task that is still running when it should have been cancelled, with task.get_coro() and task.cancelling(). A task with cancelling() > 0 that keeps running is the signature of a swallowed cancellation — the 3.12 wait_for change makes these appear in code that worked before.
Pitfalls & edge cases¶
- Context leaking out of
wait_for. Measured: a variable set inside stayed set on 3.12+. - Swallowed outer cancellation. Measured: 3.12+ returned
'late'where 3.11 raised. except TimeoutErroron 3.10. It does not catchasyncio.TimeoutErrorthere.- Assuming
wait_foris expensive. On 3.14 it cost 2.3 µs per call.
Frequently Asked Questions¶
What changed in asyncio.wait_for in Python 3.12?
It is now implemented with asyncio.timeout and runs the coroutine in the caller's task instead of a new one. Context variable changes made inside now persist, current_task() returns the caller, and per-call overhead fell from 10.8 µs on 3.11 to 3.7 µs on 3.12 in testing.
Why does my code ignore cancellation after upgrading to Python 3.12?
If a coroutine wrapped in wait_for catches CancelledError and returns, 3.12+ returns that value to the caller instead of raising CancelledError, as 3.11 did. Re-raise CancelledError after any cleanup.
Is asyncio.TimeoutError the same as TimeoutError?
Since Python 3.11, yes. On 3.10 it is a separate class, so except TimeoutError does not catch a wait_for timeout there.
Should I still use asyncio.wait_for?
On 3.11+ asyncio.timeout is clearer and composes better; on 3.12+ both cost about the same. wait_for remains fine where a single awaitable needs a deadline.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Replacing removed asyncio APIs — the other breaking changes from 3.10 to 3.14.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.