Skip to content

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

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.

The same compatibility suite on five interpreters 5 horizontal bars comparing 3.10 with the others. The same compatibility suite on five interpreters 3.10 3 passed, 1 skipped 3.11 4 passed 3.12 4 passed 3.13 4 passed 3.14 4 passed All five runs together took 1.7 s wall time with uv-managed interpreters. Multi-version testing is cheap enough to run before every push, not just in CI.

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.

Behaviour differences worth pinning in tests A grid of 4 rows by 4 columns. Behaviour differences worth pinning in tests behaviour before after changed in wait() with coroutines DeprecationWarning TypeError 3.11 ContextVar set inside wait_for invisible to caller visible 3.12 get_event_loop(), no loop creates or warns RuntimeError 3.14 TaskGroup available no yes 3.11 A version-aware assertion turns a changelog line into something CI checks on every run.

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.

From local loop to CI matrix A flow of 4 stages. From local loop to CI matrix uv python install all supported versions local loop warnings as errors pin differences version-aware tests CI matrix plus next pre-release The same command runs locally and in CI, so a red matrix cell is always reproducible.

Verification

Multi-version testing is in place when:

  • Every supported version runs the suite, locally in seconds and in CI as a matrix.
  • DeprecationWarning is 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: true in 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.