Overview

I put off adding type hints to my Python projects for years because the examples in the docs always looked like they were solving problems I didn't have. Then I inherited a 40,000-line codebase with no hints, no tests on half of it, and a bug that turned out to be a type confusion between two dicts with similar shapes. Adding hints to the critical paths would have caught it in a second.

This is what I'd tell someone adding types to an existing codebase, based on doing it three times now.

Why bother

Benefit When it shows up
Catch bugs before running tests Immediately on any nontrivial function
Better IDE autocomplete The moment you add the first annotation
Self-documenting signatures When reading unfamiliar code
Safe refactoring Renaming or changing signatures across files
Faster code review Reviewers stop asking "what type is this?"

The IDE benefit alone justifies the effort. Autocomplete on a typed dict is dramatically better than on an untyped one, and the difference compounds across a full day of coding.

The basics you'll use constantly

def greet(name: str) -> str:
    return f"Hello, {name}"

def process(items: list[str], counts: dict[str, int]) -> None:
    for item in items:
        counts[item] = counts.get(item, 0) + 1

Python 3.9+ has built-in generics for the standard collections. Before that, you had to import from typing:

# Python 3.8 and earlier
from typing import List, Dict, Optional

def process(items: List[str], counts: Dict[str, int]) -> None:
    ...

If you're on 3.9+, use the lowercase versions. They're the same thing and less typing.

Optional and None

# Python 3.10+
def find_user(user_id: int) -> User | None:
    ...

# Python 3.9 and earlier
from typing import Optional

def find_user(user_id: int) -> Optional[User]:
    ...

Optional[X] means "X or None." It does not mean "an optional argument." That naming has confused people since it was introduced, and the X | None syntax is much clearer.

For arguments with defaults, None is implied:

def connect(host: str, port: int = 5432, timeout: float | None = None):
    ...

Dicts with known keys: TypedDict

This is the feature that would have caught the bug I mentioned above. When you have a dict with a fixed set of keys and value types, TypedDict tells the type checker what to expect.

from typing import TypedDict

class UserDict(TypedDict):
    id: int
    name: str
    email: str

def process_user(user: UserDict) -> str:
    return user["name"].upper()

# mypy catches this
process_user({"id": 1, "name": "alice"})  # missing 'email'
process_user({"id": "1", "name": "alice", "email": "a@b.com"})  # id is str

Unlike dict[str, str], TypedDict knows which keys are required and what type each one holds. It's the single most useful type construct for code that passes around JSON-shaped dicts.

For optional keys, use total=False or the NotRequired marker:

class Config(TypedDict):
    host: str
    port: int
    debug: NotRequired[bool]

Literal types

from typing import Literal

def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    ...

set_log_level("info")     # ok
set_log_level("verbose")  # mypy error

Anywhere you'd otherwise accept a string and validate it at runtime, a Literal makes the constraint visible to the type checker. I use this for function arguments that accept a fixed set of strings — it turns runtime bugs into compile-time errors.

Running mypy

pip install mypy
mypy src/

For an existing codebase, running it on everything at once produces a wall of errors. Don't do that. Two approaches:

Gradual adoption

mypy --ignore-missing-imports --no-error-summary src/

Start by fixing only the errors in files you're actively working on. Add type hints as you touch functions. Over a few months, the typed portion grows naturally.

Strict mode for new code

[mypy]
strict = true
exclude = [
    "^legacy/",
    "^migrations/",
]

Strict mode on new code, nothing enforced on old code. Every new module gets full checking; the legacy directory is excluded. This avoids the "fix 4,000 errors before you can use the tool" problem.

The settings that matter

[mypy]
python_version = 3.12
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
warn_redundant_casts = true
warn_unused_ignores = true

[[mypy.overrides]]
module = ["requests.*", "yaml.*"]
ignore_missing_imports = true
Setting What it catches
disallow_untyped_defs Functions with no annotations at all
check_untyped_defs Still checks functions without annotations
warn_return_any Functions that return Any silently
no_implicit_optional Arguments defaulting to None without | None
warn_unused_ignores # type: ignore comments that no longer do anything

warn_unused_ignores is underrated. When you suppress a type error with # type: ignore, and later upgrade the library or fix the underlying issue, the suppression becomes dead code that hides future errors. This flag tells you when that happens.

Handling third-party libraries

Some libraries ship type stubs. Some have stubs maintained separately. Some have neither.

# Library ships types — just works
import requests

# Library has no types — install stubs
pip install types-PyYAML types-requests

# Neither exists — you write a stub or ignore
mypy --ignore-missing-imports

The typeshed repository maintains stubs for a huge number of libraries. Search for types-{library} on PyPI before writing your own.

For libraries you can't stub, a targeted ignore in the config is better than a global one:

[[mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true

Union types and narrowing

def process(value: int | str) -> str:
    if isinstance(value, int):
        return str(value * 2)
    return value.upper()  # mypy knows this is a str

mypy narrows the type inside the conditional. This works with isinstance, is None, in checks, and custom type guards.

from typing import TypeGuard

def is_string_list(value: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in value)

def process(items: list[object]) -> None:
    if is_string_list(items):
        # mypy knows items is list[str] here
        for item in items:
            print(item.upper())

TypeGuard is the explicit version of the narrowing that isinstance gives you for free. It's how you teach mypy about custom validation functions.

What people complain about

Complaint Reality
"It's too verbose" True for complex generics, fine for 90% of code
"It slows down development" Slower to write, faster to debug — net positive after a week
"Dynamic Python doesn't need types" You can ignore mypy entirely if you want; the annotations still improve your editor
"False positives are everywhere" Less true than it was five years ago, but yes for certain libraries
"The stdlib types are wrong" Occasionally, and you can override them

The false positive one is worth taking seriously. mypy is sometimes wrong, especially with complex generics or libraries with incomplete stubs. The correct response is a targeted # type: ignore with a comment explaining why, not disabling mypy for the file.

# type: ignore[arg-type]  # library stub is wrong; actual type is compatible
result = third_party_func(obj)

The bracket syntax restricts the ignore to a specific error code, which prevents it from hiding unrelated problems.

What I'd do on a new project

  1. Install mypy and Ruff (Ruff has type-aware linting rules too).
  2. Start with strict mode enabled in pyproject.toml.
  3. Add mypy to CI from the first commit.
  4. Annotate every function signature. Return types especially.
  5. Use TypedDict for JSON-shaped data. Use dataclasses or Pydantic for internal data.
  6. Add types incrementally to the annotations as you go — list and dict are fine to start; list[str] can come later.

The strict-from-day-one approach avoids the migration problem entirely. On an existing project, the strict-on-new-code approach gets you most of the benefit without the migration cost.