Python type hints change nothing at runtime — they are a description for humans and for tooling. The benefit comes from checking inside your editor and in the pipeline: errors surface before the program runs.

Basics

# Variables and functions
name: str = "web01"
counter: int = 0
active: bool = False
values: list[int] = []
mapping: dict[str, int] = {}

def greet(name: str, loud: bool = False) -> str:
    return f"Hello {name}" if not loud else f"HELLO {name.upper()}!"

def total(numbers: list[float]) -> float:
    return sum(numbers)

def find_server(hostname: str) -> dict[str, str] | None:
    "Returns None if the server does not exist."
    ...

from typing import Optional, Union, Any

# Both forms are equivalent (the pipe form is preferred from 3.10):
def a(x: Optional[str]) -> None: ...        # older
def b(x: str | None) -> None: ...           # 3.10+, clearer

# Since 3.9 the builtin generics work directly:
#   list[int]  instead of  List[int]
#   dict[str, int]  instead of  Dict[str, int]
#   tuple[int, str]  instead of  Tuple[int, str]
#   set[str]  instead of  Set[str]

The tools that do the checking

# 1. Static checking - without running the program
pip install mypy
mypy app.py
mypy --strict app.py
mypy app.py --ignore-missing-imports

# 2. Faster checker, great for CI
pip install pyright
pyright app.py

# 3. Editor integration - where the daily benefit comes from
#   VS Code: Pylance (bundles pyright)
#   Neovim: pyright or mypy via LSP
#   -> errors appear WHILE you type, not in production

def process(numbers: list[int]) -> int:
    return sum(numbers)

process([1, 2, 3])        # ok
process(["a", "b"])       # error: list[str] is not list[int]
process("123")            # error: str is not list[int]
#   All three variants run in plain Python until the crash
#   - or worse, until they silently return a wrong result.

More advanced types

from collections.abc import Iterable, Callable, Iterator, Sequence
from typing import Protocol, TypedDict, Literal, TypeVar

# Input: "I accept anything iterable" - not just a list
def mean(values: Iterable[float]) -> float:
    values = list(values)
    return sum(values) / len(values)

mean([1.0, 2.0])
mean({1.0, 2.0})                       # a set works too
mean(x for x in range(5))              # generator - no memory cost

# Output: "I return an iterator" - important for large data
def read_lines(path: str) -> Iterator[str]:
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.rstrip("\n")

def apply_all(values: list[int], f: Callable[[int], int]) -> list[int]:
    return [f(v) for v in values]

# Structured dictionaries instead of dict[str, object]
class Server(TypedDict):
    name: str
    ip: str
    port: int

def connect(server: Server) -> None:
    print(server["name"], server["ip"], server["port"])
#   Typos in the keys "name"/"ip"/"port" are reported!

Mode = Literal["read", "write", "append"]

def open_file(path: str, mode: Mode = "read") -> None: ...
open_file("x.txt", "write")        # ok
open_file("x.txt", "write!")       # error - not in the Literal

# Describe behaviour instead of inheriting from a base class
class Closeable(Protocol):
    def close(self) -> None: ...

def cleanup(objects: list[Closeable]) -> None:
    for o in objects:
        o.close()
#   Any class with a matching close() method fits - without
#   inheriting from Closeable. Very practical in real code.

T = TypeVar("T")

def first(values: Sequence[T]) -> T | None:
    return values[0] if values else None
#   mypy infers: first([1,2]) -> int | None
#                first(["a"]) -> str | None

What type hints do not do

  • No runtime checking. Python does not enforce annotations. process("123") runs fine unless a static checker is involved.
  • No type conversion. values: list[int] = input() — the annotation converts nothing. A frequent beginner misconception.
  • No guarantee for external data. A JSON response is unknown at runtime. That needs real validation.
  • No performance gain. Annotations do not make programs faster.

Runtime validation where it matters

# Pydantic is the standard for data from outside (APIs, files, forms)
from pydantic import BaseModel, Field, field_validator

class Server(BaseModel):
    name: str = Field(min_length=1, max_length=64)
    ip: str
    port: int = Field(ge=1, le=65535)

    @field_validator("ip")
    @classmethod
    def ip_must_be_valid(cls, v: str) -> str:
        import ipaddress
        try:
            ipaddress.ip_address(v)         # real validation now!
        except ValueError as exc:
            raise ValueError(f"invalid IP: {v}") from exc
        return v

raw = '{"name": "web01", "ip": "10.0.0.11", "port": 8080}'
server = Server.model_validate_json(raw)
print(server.name, server.ip, server.port)

# For data from your own program, mypy is enough.
# For data from outside, real validation is mandatory.

Adopting them in an existing project

  1. Not everything at once. Running mypy on a grown project produces hundreds of findings. Adopt incrementally.
  2. Exclude what does not fit yet:
    # mypy.ini
    [mypy]
    python_version = 3.11
    warn_unused_ignores = True
    
    [mypy-legacy_module.*]
    ignore_errors = True
    #   New modules checked strictly right away, old ones later.
  3. Handle suppressions with a reason. # type: ignore without a comment hides a bug. With warn_unused_ignores = True, mypy tells you when a suppression is no longer needed.
  4. Put it in the pipeline. Without enforcement, annotations rot and become misleading.
  5. Do not flee into Any. If everything is Any, the benefit is gone. object or a Protocol is usually the better answer.

Related: Python for beginners, Python functions and modules, Python, Python-Typannotationen (DE).