Python Advanced Patterns: asyncio + Type Hints
This guide combines two modern Python topics that show up together a lot in real codebases: asyncio (concurrency without threads) and static type hints with mypy. We'll focus on the patterns the language has settled on as of Python 3.11–3.12, noting which features need which minimum version.
Why both at once?
Async code is concurrent code, and concurrent code is harder to read. Type hints (especially Awaitable, Coroutine, generic TypedDict) make async APIs self-documenting and catch a whole class of "I forgot to await that" bugs at type-check time, before you ever run the program.
Part 1 — asyncio
The event loop in one paragraph
Python's asyncio model is cooperative single-threaded concurrency: one OS thread runs a loop that picks one ready coroutine, runs it until it hits an await, then picks the next ready coroutine. Coroutines never pre-empt each other. That means asyncio is fantastic for I/O-bound work (network, disk, subprocesses) and useless for CPU-bound work — for the latter, use threads, processes, or native code.
Hello, async
The modern entrypoint: asyncio.run()
import asyncio
async def greet(name: str) -> None:
await asyncio.sleep(0.5)
print(f"hello, {name}")
async def main() -> None:
await greet("alex")
if __name__ == "__main__":
asyncio.run(main())
A few rules that catch beginners:
- An
async deffunction returns a coroutine object, not a result. Callinggreet("alex")on its own does nothing — you mustawaitit or schedule it. asyncio.run()creates the loop, runs the coroutine, and tears the loop down. Use it exactly once per program, at the top.- You cannot call
asyncio.run()from inside another running event loop (e.g. inside Jupyter — useawaitdirectly there).
Running things concurrently: gather vs TaskGroup
asyncio.gather() schedules multiple coroutines and waits for them all. It has been the workhorse for years, but it has rough edges around exception handling — if one task fails, the others keep running, and their results are lost unless you set return_exceptions=True.
Python 3.11 added asyncio.TaskGroup, which fixes those rough edges. If you're on 3.11+, prefer TaskGroup.
gather (still useful pre-3.11)
import asyncio
async def fetch(n: int) -> int:
await asyncio.sleep(0.2)
return n * n
async def main() -> None:
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
print(results) # [1, 4, 9]
asyncio.run(main())
TaskGroup (3.11+): structured concurrency with proper cancellation
import asyncio
async def fetch(n: int) -> int:
await asyncio.sleep(0.2)
if n == 2:
raise ValueError("no twos allowed")
return n * n
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch(1))
t2 = tg.create_task(fetch(2))
t3 = tg.create_task(fetch(3))
# All tasks finished here. If any raised, *all* siblings are cancelled,
# and the errors come back as an ExceptionGroup.
print(t1.result(), t2.result(), t3.result())
except* ValueError as eg:
for err in eg.exceptions:
print("caught:", err)
asyncio.run(main())
Note except* — that's the new "except-star" syntax for unpacking exception groups, also Python 3.11+. If you've never seen it before, it's a focused tool: it lets you catch and re-raise a subset of exceptions from an ExceptionGroup by type while letting the others propagate.
Fire-and-forget: the pitfall
asyncio.create_task() schedules a coroutine but does not wait for it. The classic mistake is not keeping a reference to the task:
Don't do this
# BUG: task may be garbage-collected mid-flight because no strong reference is held.
async def main() -> None:
asyncio.create_task(slow_background_thing())
await asyncio.sleep(10)
Always store the reference (in a set you manage, or on self), and ideally await it before the program exits:
Do this instead
background_tasks: set[asyncio.Task] = set()
async def main() -> None:
task = asyncio.create_task(slow_background_thing())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
await asyncio.sleep(10)
# On shutdown, await outstanding tasks so they can clean up.
await asyncio.gather(*background_tasks, return_exceptions=True)
Cancellation
Tasks can be cancelled by calling task.cancel(). Inside the task, that delivers asyncio.CancelledError the next time it hits an await. You should let it propagate so cleanup runs — never swallow CancelledError without re-raising:
Cooperative cancellation done right
async def worker() -> None:
try:
while True:
await asyncio.sleep(1)
do_work()
except asyncio.CancelledError:
# Cleanup here.
print("shutting down cleanly")
raise # always re-raise
async with, async for
Async context managers (__aenter__/__aexit__) and async iterators (__aiter__/__anext__) are how libraries expose resources whose acquisition/release does I/O. You'll meet them in aiohttp, asyncpg, etc.
aiohttp example with both
import asyncio
import aiohttp
async def fetch(url: str) -> int:
async with aiohttp.ClientSession() as session: # async with
async with session.get(url) as resp:
return resp.status
async def main() -> None:
urls = ["https://example.com", "https://example.org"]
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(u)) for u in urls]
for t in tasks:
print(t.result())
asyncio.run(main())
Calling blocking code from async
If a function is not async (e.g. requests.get, blocking file I/O, a CPU-bound function), calling it directly will block the whole event loop. Hand it off to a thread:
asyncio.to_thread (3.9+)
import asyncio
import requests # synchronous library
async def fetch_blocking(url: str) -> int:
# Runs in a default thread-pool executor, doesn't block the loop.
resp = await asyncio.to_thread(requests.get, url, timeout=10)
return resp.status_code
Part 2 — Type Hints & mypy
Type hints were introduced in PEP 484 for Python 3.5. They started as a static documentation tool and have since become a serious checker, with mypy as the most-used implementation. The hints themselves are stored in __annotations__ and are not enforced at runtime — Python doesn't check them; mypy does.
The modern flavour: PEP 585 + PEP 604
Pre-3.9 code looks like this:
Old (still works, but verbose)
from typing import List, Dict, Optional, Union
def lookup(ids: List[int], cache: Dict[int, str]) -> Optional[str]:
...
def fetch(url: Union[str, bytes]) -> None:
...
On Python 3.9+ you can use builtins as generics (PEP 585), and on 3.10+ you can use | for unions (PEP 604):
Modern equivalent
def lookup(ids: list[int], cache: dict[int, str]) -> str | None:
...
def fetch(url: str | bytes) -> None:
...
Both forms are valid; mix and match if you must, but settle on the modern form for new code.
TypedDict for dict shapes
Many APIs (especially JSON) pass dicts with fixed keys. TypedDict (PEP 589) gives them types:
TypedDict
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str | None # nullable field
class UserWithOptionalAvatar(User, total=False):
avatar_url: str # may or may not be present
def render(u: User) -> str:
return f"{u['name']} <{u['email']}>"
TypedDicts are excellent for typing JSON deserialisation. For full-blown data classes consider @dataclass or Pydantic instead.
Protocol — structural subtyping ("duck typing for type checkers")
Protocol (PEP 544) lets you say "I accept anything with this shape" without forcing the caller to inherit. This is essential for the duck-typed APIs Python is full of:
Protocol example
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def safely(thing: SupportsClose) -> None:
try:
do_stuff(thing)
finally:
thing.close()
# Anything with a .close() method works — no inheritance needed.
safely(open("data.txt")) # file objects
safely(some_db_connection) # any DB connection with .close()
Final, Literal, NewType
Useful primitives
from typing import Final, Literal, NewType
MAX_RETRIES: Final = 3 # mypy refuses re-assignment
Method = Literal["GET", "POST", "PUT", "DELETE"]
def request(url: str, method: Method = "GET") -> bytes:
...
UserId = NewType("UserId", int) # nominal alias — int at runtime, distinct type to mypy
def load(uid: UserId) -> User:
...
load(42) # mypy error: int is not UserId
load(UserId(42)) # OK
Generics
Before Python 3.12, generics used TypeVar:
Classic generic with TypeVar
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
Python 3.12 added PEP 695, a much cleaner generic syntax — no more TypeVar imports:
3.12+ generic syntax (PEP 695)
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
# Same for functions
def first[T](items: list[T]) -> T:
return items[0]
Typing async code
The right hint for an async def function is whatever it returns — the async wrapping is implicit:
Annotate the return type, not the coroutine
from collections.abc import Awaitable
async def fetch(url: str) -> bytes: # returns bytes, not Coroutine[..., bytes]
...
def schedule(coro: Awaitable[bytes]) -> None: # caller wants something awaitable
...
Running mypy
Install and run
python -m pip install mypy # Quick check mypy myproject/ # Recommended for new code: strict mode (turns on a bundle of strict flags) mypy --strict myproject/ # Individually useful flags if --strict is too much at first mypy --disallow-untyped-defs --warn-unused-ignores myproject/
The --strict flag is the gold standard for new projects — it forces every function to be annotated, complains about implicit Any, and warns about dead type: ignore comments. For existing untyped code, start with no flags and ratchet up.
reveal_type — your debugging friend
Drop reveal_type(x) anywhere; mypy will print what type it inferred for x at that point. At runtime reveal_type is provided by mypy and ignored (or you need to import from typing on 3.11+).
Use reveal_type while writing
from typing import reveal_type # 3.11+, or just write it and mypy injects it
def example(x: list[int] | None) -> None:
if x is None:
return
reveal_type(x) # mypy: note: Revealed type is "builtins.list[builtins.int]"
Putting It Together: Typed Async Fetch
A small worked example that uses everything above:
typed_fetch.py
"""Fetch a batch of URLs concurrently and return their statuses, fully typed."""
from __future__ import annotations
import asyncio
from typing import TypedDict
import aiohttp
class FetchResult(TypedDict):
url: str
status: int
bytes: int
async def fetch_one(session: aiohttp.ClientSession, url: str) -> FetchResult:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
body = await resp.read()
return {"url": url, "status": resp.status, "bytes": len(body)}
async def fetch_all(urls: list[str]) -> list[FetchResult]:
results: list[FetchResult] = []
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch_one(session, u)) for u in urls]
# All tasks have completed (or the TaskGroup raised). Collect.
for t in tasks:
results.append(t.result())
return results
def main() -> None:
urls: list[str] = [
"https://example.com",
"https://example.org",
"https://example.net",
]
out = asyncio.run(fetch_all(urls))
for r in out:
print(f"{r['status']} {r['bytes']:7d} {r['url']}")
if __name__ == "__main__":
main()
Run mypy --strict typed_fetch.py — if you have it set up correctly, it returns zero errors. Then run python typed_fetch.py. You've now got concurrent I/O, structured concurrency with automatic cancellation on failure, and full static typing in fewer than 40 lines.
Further Reading
- PEP 484 — Type hints (the original)
- PEP 544 — Protocols (structural subtyping)
- PEP 585 — Type hinting generics in standard collections
- PEP 604 — Allow writing union types as
X | Y - PEP 654 — Exception Groups and
except* - PEP 695 — Type Parameter Syntax (the new 3.12 generic syntax)
- asyncio — official docs
- mypy documentation