Files
python-patterns/patterns/async-boundaries.md
T
2026-06-01 21:42:05 +00:00

4.1 KiB

Async Boundaries

Keep sync and async APIs as separate, explicit surfaces when their semantics differ.

Why

Async stops being an implementation detail as soon as it changes:

  • how resources are acquired and released
  • whether methods must be awaited
  • which transport or session types are valid
  • how cancellation behaves
  • whether the caller needs an event loop

Trying to hide that behind one magical API usually makes things worse. The common failure mode is a fake sync wrapper over async internals: it breaks inside existing event loops, hides resource lifetime, and muddies cancellation.

Mature libraries usually accept the split and make it visible.

The pattern

  1. Expose separate sync and async entrypoints when semantics differ.
  2. Keep their shapes parallel where that helps learnability.
  3. Keep resource and transport types distinct.
  4. Make lifecycle visible with normal sync and async context management.
  5. Do not smuggle event-loop control into a sync-looking API.

When to use

Use this when:

  • your library owns network, database, filesystem, or other long-lived resources
  • sync and async variants need different transport or session implementations
  • callers need predictable lifetime control
  • the library must work in many runtime environments

When not to use

Do not split APIs just for symmetry.

Skip the split when:

  • async adds no meaningful semantic difference
  • the operation is trivial and one-shot
  • a separate async API would mostly duplicate noise

But if the alternative requires hidden asyncio.run(...), loop-detection tricks, or silent runtime switching, the split is probably the cleaner design.

Preferred shape

class Client:
    def get(self, url: str) -> Response:
        ...

class AsyncClient:
    async def get(self, url: str) -> Response:
        ...

Make the surfaces parallel enough to learn once, but distinct enough that their lifecycle stays honest.

Why this works

  • callers immediately know whether await is involved
  • transport types stay correct
  • connection pooling and cleanup remain visible
  • cancellation behavior is not hidden behind sync-looking calls

Counterexamples

Fake sync wrapper over async internals

def get(url: str) -> Response:
    return asyncio.run(_async_get(url))

This breaks in environments that already have an event loop and hides lifecycle costs.

One class with mode flags

client = Client(async_mode=True)

Now methods, cleanup, and caller expectations depend on ambient configuration instead of the type.

Shared transport types that are not really shared

If one path needs BaseTransport and the other needs AsyncBaseTransport, pretending they are interchangeable is lying to the caller.

Source signals

HTTPX

  • httpx/_client.py:594-661 defines Client as the sync entrypoint and documents that it can be shared between threads.
  • httpx/_client.py:639-660 types the sync constructor in sync-native terms, including BaseTransport.
  • httpx/_client.py:1275-1304 uses normal sync context management for lifecycle.
  • httpx/_client.py:1307-1375 defines AsyncClient separately and documents task-sharing semantics.
  • httpx/_client.py:1316-1318 shows async usage with async with and await.
  • httpx/_client.py:1353-1374 keeps the constructor parallel but switches to async-native types like AsyncBaseTransport.
  • httpx/_client.py:1445-1452 initializes AsyncHTTPTransport on the async path rather than pretending the sync transport is reusable.

SQLAlchemy

  • examples/asyncio/async_orm.py:15-18 imports async-specific engine and session primitives.
  • examples/asyncio/async_orm.py:61-67 creates an async_sessionmaker(...) and enters explicit async session and transaction scopes.
  • examples/asyncio/async_orm.py:78-104 uses async-native query and commit methods.
  • examples/inheritance/joined.py:16 imports the sync Session separately.
  • examples/inheritance/joined.py:93-120 shows the corresponding sync session lifetime and explicit commit boundary.

Bottom line

If sync and async usage have different semantics, give them different types.

Parallel APIs are good. Pretending the difference is not there is not.