Typing and Async
Two features that changed how modern Python is written: static type hints, which the interpreter ignores but tools enforce, and async/await, which lets one thread juggle thousands of waiting I/O operations.
Type hints
Section titled “Type hints”Annotations describe the types you intend. Python does not check them at runtime — they are metadata, verified by external tools such as mypy or Pyright.
def greet(name: str, times: int = 1) -> str: return f"Hello, {name}! " * times
count: int = 0ratio: float = 0.5names: list[str] = []greet(42) # runs fine, produces nonsense; mypy reports the errorTheir value is in editors (accurate autocomplete and refactoring), in review (the signature documents the contract), and in catching a class of bug before it ships.
Built-in generics (3.9+)
Section titled “Built-in generics (3.9+)”Since Python 3.9 the built-in collections are subscriptable directly. The old typing.List, typing.Dict, and friends are deprecated.
scores: dict[str, int] = {}matrix: list[list[float]] = []pair: tuple[str, int] = ("a", 1)row: tuple[int, ...] = (1, 2, 3) # variable length, one typeunique: set[str] = set()Optional, Union, and |
Section titled “Optional, Union, and |”# Python 3.10+def find(key: str) -> User | None: ...def parse(x: str | bytes) -> int: ...
# Equivalent, all versionsfrom typing import Optional, Uniondef find(key: str) -> Optional[User]: ...def parse(x: Union[str, bytes]) -> int: ...Optional[X] means exactly X | None — it means “may be None”, not “may be omitted”.
Callable and other essentials
Section titled “Callable and other essentials”from collections.abc import Callable, Iterable, Iterator, Sequence, Mappingfrom typing import Any, Literal, Final, TypeAlias
handler: Callable[[str, int], bool] # (str, int) -> boolfactory: Callable[[], list[str]] # no argumentsanything: Callable[..., int] # any signature, returns int
def total(values: Iterable[float]) -> float: ... # accept the widest thing that worksdef first(items: Sequence[str]) -> str: ... # needs indexing and len
Mode = Literal["r", "w", "a"] # only these exact valuesMAX: Final = 100 # must not be reassigneddata: Any # opts out of checking entirelycollections.abc is where the abstract types live; importing them from typing is deprecated.
TypedDict
Section titled “TypedDict”Describes the shape of a dict with known string keys — the usual case for JSON payloads.
from typing import TypedDict, NotRequired
class User(TypedDict): id: int name: str email: NotRequired[str] # 3.11+; may be absent
def send(user: User) -> None: print(user["name"])
send({"id": 1, "name": "Ada"}) # OKsend({"id": 1}) # mypy: missing key "name"send({"id": 1, "name": "Ada", "x": 2}) # mypy: unexpected key "x"At runtime it is a plain dict — no validation, no overhead. When you want a real object with methods, use a dataclass instead.
Protocol — structural typing
Section titled “Protocol — structural typing”A Protocol types by shape: anything with the right methods matches, with no inheritance and no registration. This is duck typing made checkable.
from typing import Protocol
class Closeable(Protocol): def close(self) -> None: ...
def cleanup(resource: Closeable) -> None: resource.close()
class Connection: # does NOT inherit from Closeable def close(self) -> None: print("closed")
cleanup(Connection()) # accepted — the shape matchesUse @runtime_checkable if you also need isinstance against it (it checks method names only, not signatures).
Protocols are how you type third-party objects you cannot modify, and how you decouple a function from a concrete class.
Generics
Section titled “Generics”Python 3.12 introduced the concise PEP 695 syntax.
# Python 3.12+def first[T](items: list[T]) -> T: return items[0]
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()
type Matrix = list[list[float]] # a type alias statement, 3.12+# Pre-3.12 equivalentfrom typing import TypeVar, Generic
T = TypeVar("T")
def first(items: list[T]) -> T: return items[0]
class Stack(Generic[T]): ...first([1, 2, 3]) is inferred as int; first(["a"]) as str. Constrain a TypeVar with TypeVar("T", int, str) or bound it with bound=.
Practical details
Section titled “Practical details”Annotate a self-returning method and forward references correctly:
from __future__ import annotations # makes ALL annotations lazy strings
class Node: def __init__(self, parent: Node | None = None) -> None: # works thanks to the import self.parent = parent
def clone(self) -> "Node": # quoting also works, without the import return Node(self.parent)Without from __future__ import annotations, a name used before it is defined must be quoted.
Silence a checker only where you must, and say why:
value = legacy_api() # type: ignore[no-any-return] # third-party stubs are wrongType comments (# type: int) are the pre-3.0-annotation style; you will see them in old code but should not write new ones.
python3 -m pip install mypypython3 -m mypy src/python3 -m mypy --strict src/[tool.mypy]python_version = "3.12"strict = truewarn_unused_ignores = true
[[tool.mypy.overrides]]module = ["untyped_lib.*"]ignore_missing_imports = trueAdopt it gradually: unannotated code is simply not checked, so you can annotate module by module. --strict turns on every check including “no untyped definitions”; start without it on an existing codebase.
Pyright (and its editor form, Pylance) is the main alternative — faster, stricter inference, and the engine behind most VS Code Python tooling. Ruff can also enforce annotation-related lint rules.
async/await
Section titled “async/await”asyncio gives concurrency without threads: a single thread runs an event loop that switches between tasks whenever one of them waits on I/O.
Coroutines
Section titled “Coroutines”async def defines a coroutine function. Calling it returns a coroutine object and runs nothing; it must be awaited or scheduled.
import asyncio
async def fetch(name: str) -> str: print(f"{name}: start") await asyncio.sleep(1) # yields control back to the loop print(f"{name}: done") return name.upper()
asyncio.run(fetch("a"))asyncio.run() creates an event loop, runs the coroutine to completion, and closes the loop. It is the single entry point from synchronous code — one per program.
fetch("a") # RuntimeWarning: coroutine was never awaited — nothing happenedawait suspends the current coroutine until the awaited thing completes, letting the loop run other tasks meanwhile. You may only await inside async def.
async def main() -> None: result = await fetch("a") print(result)Awaiting sequentially is still sequential:
async def slow() -> None: await fetch("a") # 1 second await fetch("b") # then another second — total 2sRunning things concurrently
Section titled “Running things concurrently”asyncio.gather schedules everything at once and waits for all of it:
async def main() -> None: results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c")) print(results) # => ['A', 'B', 'C'] — in argument ordera: startb: startc: starta: doneb: donec: done['A', 'B', 'C']Three seconds of sleeping compressed into one. By default gather propagates the first exception; return_exceptions=True collects them as results instead.
results = await asyncio.gather(*tasks, return_exceptions=True)for r in results: if isinstance(r, Exception): ...TaskGroup (Python 3.11+) is the preferred structured alternative — it cancels siblings when one task fails and raises an ExceptionGroup:
async def main() -> None: async with asyncio.TaskGroup() as tg: t1 = tg.create_task(fetch("a")) t2 = tg.create_task(fetch("b")) # all tasks are complete here print(t1.result(), t2.result())Other tools:
task = asyncio.create_task(fetch("a")) # schedule now, await laterawait asyncio.sleep(0.1)value = await task
await asyncio.wait_for(fetch("a"), timeout=0.5) # TimeoutError on overrun
async with asyncio.timeout(5): # 3.11+, covers a whole block await do_several_things()
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
sem = asyncio.Semaphore(10) # bound the concurrencyasync with sem: await fetch(url)async iteration and context managers
Section titled “async iteration and context managers”async def stream(n: int): for i in range(n): await asyncio.sleep(0.1) yield i # an async generator
async def main() -> None: async for value in stream(3): print(value)
async with open_connection() as conn: # needs __aenter__/__aexit__ await conn.send("hi")contextlib.asynccontextmanager builds one from an async generator, exactly like its synchronous counterpart.
from contextlib import asynccontextmanager
@asynccontextmanagerasync def connection(url: str): conn = await connect(url) try: yield conn finally: await conn.close()When async actually helps
Section titled “When async actually helps”Async is a solution to I/O-bound waiting, not to slow computation.
| Workload | Best tool | Why |
|---|---|---|
| Many network calls, DB queries, file I/O | asyncio |
Tasks wait, not compute; one thread handles thousands |
| CPU-heavy work (parsing, maths, image processing) | multiprocessing / ProcessPoolExecutor |
Sidesteps the GIL by using separate processes |
| A few blocking calls in otherwise sync code | ThreadPoolExecutor |
Simpler; no rewrite required |
| A plain script, one request at a time | Synchronous code | Async adds complexity for zero gain |
The GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time, so threads do not speed up CPU-bound work — they do help with blocking I/O, because the lock is released while waiting. Async achieves the same overlap without thread overhead, and with explicit suspension points that eliminate most data races.
A realistic shape — fetch many URLs with bounded concurrency:
import asyncioimport httpx # pip install httpx
async def fetch_one(client: httpx.AsyncClient, sem: asyncio.Semaphore, url: str) -> int: async with sem: response = await client.get(url, timeout=10) return response.status_code
async def main(urls: list[str]) -> None: sem = asyncio.Semaphore(20) async with httpx.AsyncClient() as client: codes = await asyncio.gather( *(fetch_one(client, sem, u) for u in urls), return_exceptions=True, ) print(codes)
asyncio.run(main(["https://example.com"] * 50))Twenty requests are in flight at any moment; the rest queue on the semaphore. Synchronously this would take fifty round trips end to end.
Key points
Section titled “Key points”- Annotations are metadata; only mypy or Pyright enforce them.
- Use built-in generics (
list[int]) andX | Noneon 3.9+/3.10+;typing.ListandOptionalare legacy. TypedDicttypes dict shapes,Protocoltypes by structure, PEP 695 (3.12+) makes generics concise.- Accept broad types (
Iterable), return precise ones (list[str]). async defreturns a coroutine that does nothing until awaited;asyncio.runis the entry point.- Sequential
awaits are sequential — usegatherorTaskGroupfor real concurrency. - Async wins on I/O-bound waiting; use processes for CPU-bound work.
- Any blocking call inside a coroutine stalls the whole loop —
asyncio.to_threadis the escape hatch.