Skip to content

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

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.

What asyncio.wait_for does, by Python version A grid of 5 rows by 5 columns. What asyncio.wait_for does, by Python version version runs coroutine in ContextVar set inside outer cancel, inner swallows cost per call 3.10 a new task lost CancelledError 14.6 us 3.11 a new task lost CancelledError 10.8 us 3.12 the caller's task kept value returned 3.7 us 3.13 the caller's task kept value returned 3.1 us 3.14 the caller's task kept value returned 2.3 us Measured with the same script under uv-managed CPython builds.

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.

Outer cancel when the wrapped coroutine swallows it A sequence of 6 messages between 4 participants. Outer cancel when the wrapped coroutine swallows it shutdown code caller task wait_for wrapped coroutine task.cancel() CancelledError at await except: return 'late' returns 'late' 3.12+: 'late' (cancel lost) 3.11: CancelledError The fix is in the coroutine: re-raise CancelledError.

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.

Is this wait_for call affected by 3.12? A decision on What does the wrapped coroutine do with 4 outcomes. Is this wait_for call affected by 3.12? What does the wrapped coroutine do? sets a ContextVar or reads current_task wrap in create_task, or stop mutating change now persists catches CancelledError, returns re-raise after cleanup cancel lost on 3.12+ caller catches TimeoutError, 3.10 supported catch asyncio.TimeoutError builtin is not caught on 3.10 none of these nothing to change 3x faster on 3.12+ Most calls fall in the last branch.

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 CancelledError re-raises, so outer cancellation is never lost.
  • Timeouts are caught as asyncio.TimeoutError while 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 TimeoutError on 3.10. It does not catch asyncio.TimeoutError there.
  • Assuming wait_for is 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.