Skip to content

Supporting Several Python Versions in Async Libraries

An application can pick one Python version; a library has to run on every version its users have. For asyncio code that means spanning the 3.11 line where TaskGroup, asyncio.timeout and except* arrived, the 3.12 wait_for rewrite and the 3.14 removals. Measured with a small library built for this guide: a _compat module that imported TaskGroup and timeout from asyncio on 3.11 and later and from the taskgroup backport on 3.10, with the backports declared under python_version < '3.11' markers, installed three extra packages on 3.10 and none on 3.12, and its test suite passed on 3.10.20, 3.11.15, 3.12.13, 3.13.14 and 3.14.6 — each run taking 0.5–1.7 s with uv run, including environment setup. The same wheel was refused on 3.9 by its requires-python. Two traps showed up along the way: a test catching TimeoutError from asyncio.wait_for failed on 3.10 only, and mypy 2.4.0 passed a module using asyncio.TaskGroup against a 3.10 target that pyright 1.1.414 correctly rejected. This guide sets up a library so that every supported version is exercised and none is assumed.

Prerequisites

1. Put every version difference in one compat module

Scattering sys.version_info checks through a library makes the next version bump a search-and-edit across files. Collect them in one private module and import from it everywhere else:

# src/asyncfetch/_compat.py
import sys

if sys.version_info >= (3, 11):
    from asyncio import TaskGroup, timeout
    BaseExceptionGroup = BaseExceptionGroup
else:
    from exceptiongroup import BaseExceptionGroup
    from taskgroup import TaskGroup, timeout
# src/asyncfetch/__init__.py
import asyncio
from ._compat import TaskGroup, timeout


async def fetch_all(ids, budget=1.0):
    async with timeout(budget):
        async with TaskGroup() as tg:
            tasks = [tg.create_task(fetch(i)) for i in ids]
    return [t.result() for t in tasks]

The library code is written against the newest API, and the compat module makes it true on older versions. When 3.10 support ends, the else branch is deleted and nothing else changes. The backports matter for behaviour, not just names: on 3.10, taskgroup.timeout raised the builtin TimeoutError — the same class the stdlib version raises on 3.11 and later — so one except TimeoutError in the library and its tests covered every version.

Verify: grep -rn "version_info" src/ matches only the compat module.

2. Declare backports with environment markers

Backports should be installed only where they are needed. Express that in the package metadata rather than in documentation:

[project]
name = "asyncfetch"
requires-python = ">=3.10"
dependencies = [
    "taskgroup>=0.2; python_version < '3.11'",
    "exceptiongroup>=1.2; python_version < '3.11'",
]

Measured with the built wheel: installing it into a 3.10 environment pulled in taskgroup, exceptiongroup and typing-extensions; installing it into 3.12 installed only the library itself; installing it into 3.9 failed during resolution, with uv reporting that the requirements were unsatisfiable because of Requires-Python: >=3.10. The markers are copied into the wheel's metadata as Requires-Dist: taskgroup>=0.2; python_version < '3.11', so every installer applies them, not only uv. Keep requires-python accurate — it is what stops users on an unsupported interpreter from installing a release that would fail at import.

Verify: unzip -p dist/*.whl '*/METADATA' | grep Requires shows Requires-Python and each backport with its marker.

The same wheel installed on four interpreters A grid of 4 rows by 3 columns. The same wheel installed on four interpreters interpreter installed alongside the library tests 3.9 refused: requires-python >=3.10 not run 3.10 taskgroup, exceptiongroup, typing-extensions 3 passed 3.11 nothing 3 passed 3.12-3.14 nothing 3 passed Environment markers keep backports off interpreters that do not need them.

3. Use version checks that type checkers understand

Feature detection with hasattr reads naturally, but type checkers cannot evaluate it, so they analyse both branches on every target. Measured on the same two-branch module checked against a 3.13 target: written with if sys.version_info >= (3, 11):, pyright and mypy skipped the 3.10 branch and reported nothing about it; written with if hasattr(asyncio, "TaskGroup"):, both analysed the backport branch, and mypy also reported Name "TaskGroup" already defined because the name was bound twice. Use sys.version_info comparisons for anything that differs by version:

import sys

if sys.version_info >= (3, 12):
    from inspect import markcoroutinefunction
else:
    def markcoroutinefunction(func):        # no-op fallback for 3.10 and 3.11
        return func

Then run the type checker once per supported version, not once in total. Checked against --pythonversion 3.10, pyright flagged a module that used asyncio.TaskGroup directly ("TaskGroup is not a known attribute of module asyncio"); mypy 2.4.0 with --python-version 3.10 reported no issue in the same file. Both caught except* against a 3.10 target, as did ruff with --target-version py310, which reported it as invalid-syntax. Since checkers differ on what they catch, the test matrix in the next step is the final authority.

Verify: the type checker runs in CI once for the oldest and once for the newest supported version.

What each tool caught against a Python 3.10 target A grid of 3 rows by 4 columns. What each tool caught against a Python 3.10 target problem ruff 0.16 mypy 2.4 pyright 1.1.414 except* in 3.10 code caught caught caught asyncio.TaskGroup on 3.10 not checked missed caught hasattr branch for other version not checked analysed both analysed both Use sys.version_info so checkers can prune branches; test on every version anyway.

4. Test the behaviour that differs, on every version

Run the full suite on each interpreter. With uv, the backports can be requested with the same markers, so one command line works everywhere:

for v in 3.10 3.11 3.12 3.13 3.14; do
    uv run --no-project --python "$v" \
        --with pytest \
        --with "taskgroup; python_version<'3.11'" \
        --with "exceptiongroup; python_version<'3.11'" \
        python -m pytest -q
done

Measured: three tests passed on all five versions, with wall times of 1.66 s on 3.10 — which installed the backports on first use — and 0.53–0.70 s on the others. The suite needs at least one test for each behaviour that changed between versions, because those are where a library silently differs. One such test caught a real bug on the way: asserting pytest.raises(TimeoutError) around asyncio.wait_for failed on 3.10, where wait_for raises asyncio.TimeoutError, a different class, and passed on 3.11 and later, where the two are the same. Code paths that bypass the compat module are exactly where this kind of difference survives, which the per-version detail in handling wait_for behaviour changes in Python 3.12 makes concrete.

Verify: CI runs this matrix, and a test exists for each behaviour difference the library relies on.

A library release across Python versions A flow of 5 stages. A library release across Python versions Compat module one place for differences Markers + requires-python backports only where needed Type-check twice oldest and newest target Test every version uv run --python X.Y Drop a version delete branch and marker The cost of support is concentrated, so it can be removed in one change.

5. Drop old versions deliberately

Supporting a version has a cost: every new asyncio feature needs a fallback. Decide in advance when you will drop a version — commonly when it reaches end of life, which for 3.10 is October 2026 — and make the drop a single change:

# _compat.py after dropping 3.10: the backports and their branch are gone
from asyncio import TaskGroup, timeout

BaseExceptionGroup = BaseExceptionGroup

Raise requires-python, delete the markers and the else branch, remove the version from the CI matrix, and release it as a minor version with a changelog entry. Users on the old interpreter keep resolving the last compatible release, because installers respect requires-python when choosing a version — as the 3.9 install in step 2 showed when it was refused rather than installed broken. Once the floor is 3.11, the library can use except* and asyncio.timeout directly, as in adopting TaskGroup and timeout when upgrading from 3.10.

Verify: after the change, grep -rn "version_info\|python_version" src/ pyproject.toml lists only checks for versions you still support.

Verification

An async library supports several Python versions reliably when:

  • All version differences live in one compat module.
  • Backports are declared with environment markers, and requires-python matches the oldest tested version.
  • Type checking runs against the oldest and newest targets, using sys.version_info checks.
  • The test suite runs on every supported interpreter, with tests for each behaviour difference.

Diagnostic Hook: include sys.version and the library's own version in error reports, and group incoming bug reports by Python version. A cluster of failures on one interpreter points at a compat branch or a missing behaviour test; failures spread across versions point at the library itself.

Pitfalls & edge cases

  • except TimeoutError around wait_for on 3.10. Measured: the test failed there only.
  • hasattr feature detection. Type checkers analyse both branches.
  • Trusting one type checker. mypy 2.4.0 passed asyncio.TaskGroup on a 3.10 target.
  • Unconditional backports. They add dependencies for users who do not need them.

Frequently Asked Questions

How do I use TaskGroup in a library that supports Python 3.10?

Import it from asyncio on 3.11+ and from the taskgroup backport on 3.10, in one compat module, and declare the backport with python_version < '3.11' in your dependencies. On 3.10 the backport's timeout raised the builtin TimeoutError, matching 3.11+.

Should I use sys.version_info or hasattr to detect features?

sys.version_info for differences tied to a version: type checkers evaluate it and skip the inactive branch. hasattr forces them to analyse both, which produced errors in testing.

How do I test an async library on several Python versions?

Run pytest under each interpreter with uv run --python X.Y, passing backports with the same markers; the suite ran on 3.10 to 3.14 in 0.5 to 1.7 s per version in testing.

What happens to users on a Python version I have dropped?

If requires-python is raised in the new release, installers keep choosing the last compatible release for them; a 3.9 install of a >=3.10 package was refused rather than installed broken.