Skip to content

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.

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 = 0
ratio: float = 0.5
names: list[str] = []
greet(42) # runs fine, produces nonsense; mypy reports the error

Their 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.

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 type
unique: set[str] = set()
# Python 3.10+
def find(key: str) -> User | None: ...
def parse(x: str | bytes) -> int: ...
# Equivalent, all versions
from typing import Optional, Union
def 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”.

from collections.abc import Callable, Iterable, Iterator, Sequence, Mapping
from typing import Any, Literal, Final, TypeAlias
handler: Callable[[str, int], bool] # (str, int) -> bool
factory: Callable[[], list[str]] # no arguments
anything: Callable[..., int] # any signature, returns int
def total(values: Iterable[float]) -> float: ... # accept the widest thing that works
def first(items: Sequence[str]) -> str: ... # needs indexing and len
Mode = Literal["r", "w", "a"] # only these exact values
MAX: Final = 100 # must not be reassigned
data: Any # opts out of checking entirely

collections.abc is where the abstract types live; importing them from typing is deprecated.

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"}) # OK
send({"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.

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 matches

Use @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.

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 equivalent
from 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=.

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 wrong

Type comments (# type: int) are the pre-3.0-annotation style; you will see them in old code but should not write new ones.

Terminal window
python3 -m pip install mypy
python3 -m mypy src/
python3 -m mypy --strict src/
pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = ["untyped_lib.*"]
ignore_missing_imports = true

Adopt 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.

asyncio gives concurrency without threads: a single thread runs an event loop that switches between tasks whenever one of them waits on I/O.

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 happened

await 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 2s

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 order
a: start
b: start
c: start
a: done
b: done
c: 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 later
await 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 concurrency
async with sem:
await fetch(url)
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
@asynccontextmanager
async def connection(url: str):
conn = await connect(url)
try:
yield conn
finally:
await conn.close()

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 asyncio
import 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.

  • Annotations are metadata; only mypy or Pyright enforce them.
  • Use built-in generics (list[int]) and X | None on 3.9+/3.10+; typing.List and Optional are legacy.
  • TypedDict types dict shapes, Protocol types by structure, PEP 695 (3.12+) makes generics concise.
  • Accept broad types (Iterable), return precise ones (list[str]).
  • async def returns a coroutine that does nothing until awaited; asyncio.run is the entry point.
  • Sequential awaits are sequential — use gather or TaskGroup for 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_thread is the escape hatch.