Testing asyncio Code Across Python Versions¶
Most asyncio version bugs are not exotic: a deprecation that became an error, a behaviour that moved between releases, a primitive used on a version that lacks it. They are cheap to catch and expensive to discover in production, provided the suite actually runs on every version you claim to support. With uv managing interpreters, running a small compatibility suite on CPython 3.10, 3.11, 3.12, 3.13 and 3.14 took 1.7 s of wall time for all five, including interpreter start-up — 3.10 reported 3 passed and 1 skipped (the TaskGroup test), every later version 4 passed. This guide builds that loop locally, adds tests that pin the behaviour differences that matter, and turns it into a CI matrix.
Prerequisites¶
- uv (
curl -LsSf https://astral.sh/uv/install.sh | sh) to fetch and run multiple CPython versions; tox or nox work the same way with their own interpreter discovery. - The version differences, from Asyncio Across Python Versions.
- Async test setup, from testing asyncio code with pytest-asyncio.
1. Run the suite on every interpreter locally¶
uv downloads standalone CPython builds on demand and runs a command with each, without touching the system Python:
uv python install 3.10 3.11 3.12 3.13 3.14
for v in 3.10 3.11 3.12 3.13 3.14; do
echo -n "$v: "
uv run --no-project -p "$v" --with pytest --with pytest-asyncio \
python -m pytest -q -p no:cacheprovider | tail -1
done
Output from the compatibility suite used for this guide:
3.10: 3 passed, 1 skipped in 0.01s
3.11: 4 passed in 0.01s
3.12: 4 passed in 0.01s
3.13: 4 passed in 0.01s
3.14: 4 passed in 0.00s
--no-project keeps uv from installing the current project into a shared environment; drop it and use uv run --python $v inside a project to test the installed package instead. For a full application suite the per-version time is the suite's own runtime, but the interpreter set-up is effectively free once cached.
Verify: every version you list in your package metadata appears in the loop and reports a result.
2. Fail on deprecations everywhere¶
The most valuable setting is turning DeprecationWarning into an error. Each asyncio removal in this period was announced by warnings at least a release earlier, so a strict run on 3.12 catches what breaks on 3.14:
# pytest.ini
[pytest]
filterwarnings =
error::DeprecationWarning
error::RuntimeWarning
ignore::DeprecationWarning:some_slow_to_update_dependency
The per-test equivalent is a module-level marker, pytestmark = pytest.mark.filterwarnings("error::DeprecationWarning"), which is what the compatibility suite here uses. Keep the ignore lines scoped to named third-party modules so your own code never hides behind them. The interplay with asyncio debug mode is covered in enabling asyncio debug mode in tests and CI.
Verify: add a call to asyncio.get_event_loop() at module level in a test file; the run fails on 3.12+.
3. Pin behaviour differences in explicit tests¶
Some differences are intended and permanent. Encode them as tests, so a refactor that accidentally depends on one version's behaviour fails loudly on the others:
import asyncio
import contextvars
import sys
import pytest
pytestmark = pytest.mark.filterwarnings("error::DeprecationWarning")
def test_wait_rejects_coroutines():
async def co():
return 1
async def main():
c = co()
try:
await asyncio.wait([c])
finally:
c.close()
expected = TypeError if sys.version_info >= (3, 11) else DeprecationWarning
with pytest.raises(expected):
asyncio.run(main())
def test_wait_for_context_visibility():
v = contextvars.ContextVar("v", default="-")
async def s():
v.set("x")
async def main():
await asyncio.wait_for(s(), 1)
return v.get()
assert asyncio.run(main()) == ("x" if sys.version_info >= (3, 12) else "-")
These are documentation that executes: the second test records that wait_for moved into the caller's task in 3.12, which the version probe measured directly. If your code depends on that, the test explains why the dependency exists; if it should not, the test is how you find out it does.
Verify: each test passes on every version, each for the reason the version check states.
4. Skip by capability, not by version¶
When a test needs a primitive that older versions lack, skip on the capability. It reads better and survives backports:
@pytest.mark.skipif(not hasattr(asyncio, "TaskGroup"), reason="TaskGroup is 3.11+")
def test_taskgroup_cancels_siblings():
done: list[int] = []
async def ok():
await asyncio.sleep(0.05)
done.append(1)
async def bad():
raise ValueError
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(ok())
tg.create_task(bad())
with pytest.raises(BaseExceptionGroup):
asyncio.run(main())
assert done == [] # the sibling was cancelled, not finished
That is the test that was skipped on 3.10 and passed elsewhere. Watch the skip count per version: a growing number of skips on the oldest version is the signal that the floor should rise, because those code paths are untested there. When the oldest version's skip list covers most of the async surface, it is time to drop it, as discussed in adopting TaskGroup and timeout when upgrading from 3.10.
Verify: the skip reason names the capability, and skips only occur on versions that genuinely lack it.
5. Wire it into CI as a matrix¶
In CI, run the matrix in parallel jobs so a failure on one version is visible at a glance:
# .github/workflows/compat.yml
name: compat
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv run -p ${{ matrix.python }} --with pytest --with pytest-asyncio python -m pytest -q
env:
PYTHONASYNCIODEBUG: "1"
fail-fast: false matters: without it, the first failing version cancels the rest, and you lose the information about which versions are affected. Add the next pre-release (for example 3.15-dev) as an allowed-to-fail entry once its alphas appear, so deprecations reach you a year before they matter. For Windows-specific loop behaviour, add an OS dimension as described in choosing proactor vs selector event loops on Windows.
Verify: a deliberately broken commit fails on exactly the versions it should, and the others still report.
Verification¶
Multi-version testing is in place when:
- Every supported version runs the suite, locally in seconds and in CI as a matrix.
DeprecationWarningis an error for your own modules on every version.- Known behaviour differences are pinned in version-aware tests.
- Skips are capability-based, and the skip count per version is visible.
Diagnostic Hook: report test counts per version — passed, skipped, failed — as a CI summary. A version whose skip count grows release after release is accumulating untested code; a pre-release entry that starts failing is a deprecation turning into a removal, with a year of notice.
Pitfalls & edge cases¶
- Testing only the newest version. Compatibility claims in package metadata are then untested.
- Version checks in tests that mirror version checks in code. If both are wrong the same way, the test passes; assert observable behaviour.
fail-fast: truein the matrix. It hides which versions are affected.- Shared environments between versions. Compiled wheels differ per version; let uv or tox create one environment per interpreter.
Frequently Asked Questions¶
How do I test Python code on several Python versions locally?
Use uv to install and run multiple interpreters: uv python install 3.10 3.11 3.12 3.13 3.14, then run pytest with uv run -p for each version. tox and nox provide the same with configuration files.
How do I catch asyncio deprecations before they break?
Run the test suite with DeprecationWarning turned into an error, for example with filterwarnings = error::DeprecationWarning in pytest.ini, on the newest supported version. asyncio removals in 3.11 to 3.14 were all preceded by warnings.
Should I skip tests by Python version or by feature?
By feature where possible, such as skipif(not hasattr(asyncio, "TaskGroup")). It documents what the test needs and keeps working with backports.
Why use fail-fast false in a CI version matrix?
So that one failing version does not cancel the others. Knowing exactly which versions fail is usually what tells you the cause.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Replacing get_event_loop deprecation warnings — the most common failure this matrix catches.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.