If you’re writing Python without type hints in 2026, you’re making life harder for everyone — including future you. I held out for a while. I liked Python’s flexibility, the duck typing, the “we’re all consenting adults here” philosophy. Then a production bug cost my team three days of debugging, and I changed my mind permanently.

I’m going to walk through how I’ve adopted type hints across production codebases, the tooling that makes it practical, and the patterns that actually matter versus the ones that are just academic noise.

The bug that converted me

Here’s the story. We had a data pipeline — nothing exotic, just pulling records from an API, transforming them, and writing to a database. One function accepted a user_id parameter. Sometimes it received an integer. Sometimes a string. The API returned strings, but somewhere in our transformation layer, someone had cast it to an int for a comparison, and that int propagated downstream.

For weeks this worked fine. The database column was VARCHAR, so it happily accepted both. Then we added a caching layer that used user_id as a dictionary key. And 1 != "1" when you’re hashing dictionary keys. Users started seeing other people’s cached data.

The function signature looked like this:

def get_user_profile(user_id, include_preferences=False):
    cache_key = f"profile:{user_id}"
    cached = cache.get(cache_key)
    if cached:
        return cached
    # ... fetch from DB

No type information anywhere. The user_id could be anything. If we’d had this instead:

def get_user_profile(user_id: str, include_preferences: bool = False) -> UserProfile:
    cache_key = f"profile:{user_id}"
    cached = cache.get(cache_key)
    if cached:
        return cached
    # ... fetch from DB

Mypy would have flagged every call site passing an int. Three days of debugging, a security-adjacent data leak, and a very uncomfortable incident review — all preventable with a single : str annotation.

That was my conversion moment. Maybe yours is different. But if you’ve worked on a Python codebase with more than a few thousand lines, you’ve hit something like this.


Starting incrementally — don’t boil the ocean

The biggest mistake I see teams make is trying to type-hint an entire codebase in one go. Don’t. You’ll burn out, the PR will be unreviewable, and you’ll introduce bugs in the process.

Here’s the approach that’s worked for me across three different production codebases:

Phase 1: New code only. Every new function gets type hints. Every new module gets a py.typed marker. No exceptions. This is a team norm, enforced in code review.

Phase 2: Public interfaces. Go through your most-imported modules and type the public functions. These are the ones other developers actually call. The internal helper that’s used in one place? Leave it for now.

Phase 3: Critical paths. Type-hint the code that handles money, authentication, data serialization — anything where a type confusion bug would be painful. You already know which modules keep you up at night.

Phase 4: The long tail. Gradually work through everything else. I’ve used mypy --strict reports to prioritize — it tells you exactly where the gaps are.

The key insight: type hints are documentation that the machine can verify. Even partial coverage is valuable. A function signature with types tells the next developer what you intended, even if mypy isn’t checking every call site yet.


Mypy vs Pyright — pick one and commit

There are two serious type checkers for Python: mypy and pyright. I’ve used both extensively, and here’s my honest take.

Mypy is the original. It’s mature, well-documented, and has the largest ecosystem of plugins. If you’re using Django, SQLAlchemy, or other frameworks with complex metaclass magic, mypy’s plugin system is a lifesaver. Configuration lives in mypy.ini or pyproject.toml:

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

Pyright is Microsoft’s entry, and it’s fast. Noticeably faster on large codebases. It’s what powers Pylance in VS Code, so if that’s your editor, you’re already running it. Pyright is stricter by default, which can be annoying when you’re starting out but catches more subtle issues.

{
  "reportMissingTypeStubs": "warning",
  "reportUnknownMemberType": "warning",
  "pythonVersion": "3.12",
  "typeCheckingMode": "standard"
}

My recommendation: if your team uses VS Code, go with pyright. The editor integration is seamless and the feedback loop is instant. If you’re in a mixed-editor environment or need framework plugins, mypy is the safer bet.

Either way, add it to CI. A type checker that only runs on your laptop is a type checker that gets ignored.

# In your CI pipeline
- name: Type check
  run: mypy src/ --config-file pyproject.toml

I’ve written about managing Python tooling with poetry vs pip — getting your dependency management sorted makes the type checking story much cleaner.


The type hints that actually matter

Not all type hints are created equal. Some give you massive bang for your buck. Others are ceremony. Here’s where to focus.

Function signatures — always. This is non-negotiable. Every function should declare its parameter types and return type.

def calculate_discount(
    price: Decimal,
    discount_percent: float,
    max_discount: Decimal | None = None,
) -> Decimal:
    discount = price * Decimal(str(discount_percent)) / 100
    if max_discount is not None:
        discount = min(discount, max_discount)
    return price - discount

TypedDict for dictionary structures. If you’re passing dictionaries around (and in Python, you probably are), TypedDict turns runtime mysteries into compile-time checks:

from typing import TypedDict

class OrderSummary(TypedDict):
    order_id: str
    total: Decimal
    item_count: int
    status: str

Protocols for duck typing. This is where Python’s type system gets genuinely elegant. Instead of forcing inheritance, you can define structural types:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    def to_dict(self) -> dict[str, Any]: ...
    def from_dict(cls, data: dict[str, Any]) -> "Serializable": ...

Any class that implements to_dict and from_dict satisfies this protocol — no inheritance required. This plays beautifully with Python’s duck typing philosophy while still giving you static checking. If you’ve explored Python’s advanced features, protocols will feel like a natural extension.

Literal types for constrained strings. Instead of accepting any string where only specific values are valid:

from typing import Literal

def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]) -> None:
    logging.getLogger().setLevel(level)

Generics — worth learning, easy to overdo

Generics are powerful but I see people reach for them too early. You need them when you’re writing container types or utility functions that genuinely work across types. You don’t need them for your business logic.

Here’s a legitimate use case:

from typing import TypeVar, Generic

T = TypeVar("T")

class Result(Generic[T]):
    def __init__(self, value: T | None = None, error: str | None = None):
        self.value = value
        self.error = error

    @property
    def is_ok(self) -> bool:
        return self.error is None

def parse_config(raw: str) -> Result[dict[str, Any]]:
    try:
        return Result(value=json.loads(raw))
    except json.JSONDecodeError as e:
        return Result(error=str(e))

The newer type statement syntax in Python 3.12+ cleans this up:

type Result[T] = T | ErrorInfo

But don’t go writing Generic[T, U, V] monstrosities for your CRUD endpoints. If your type signature is harder to read than the function body, you’ve gone too far.


Pydantic — runtime validation where it counts

Static type checking catches bugs before your code runs. But Python is still a dynamic language, and data coming from the outside world — API requests, config files, database rows, message queues — doesn’t care about your type annotations.

This is where Pydantic earns its place. It bridges the gap between static types and runtime reality.

from pydantic import BaseModel, Field, field_validator
from decimal import Decimal

class CreateOrderRequest(BaseModel):
    customer_id: str
    items: list[OrderItem]
    discount_code: str | None = None
    total: Decimal = Field(gt=0)

    @field_validator("customer_id")
    @classmethod
    def validate_customer_id(cls, v: str) -> str:
        if not v.startswith("cust_"):
            raise ValueError("Invalid customer ID format")
        return v

Pydantic v2 is significantly faster than v1 — the Rust-based core makes validation overhead negligible for most use cases. I use it at every boundary: API endpoints, queue consumers, config loading, anything where data enters my system from somewhere I don’t control.

The pattern I’ve settled on: Pydantic models at the edges, plain dataclasses or typed dicts internally. Pydantic does validation and coercion. Internal code trusts that the data is already clean because the boundary enforced it.

# At the API boundary — validate everything
@app.post("/orders")
async def create_order(request: CreateOrderRequest) -> OrderResponse:
    # request is already validated by Pydantic
    order = await order_service.create(
        customer_id=request.customer_id,
        items=request.items,
    )
    return OrderResponse.from_order(order)

If you’re doing async programming with FastAPI or similar frameworks, Pydantic integration is essentially free — it’s already baked in.


Dealing with legacy code and third-party libraries

Real codebases aren’t greenfield. You’ve got legacy modules with zero type information and third-party libraries that may or may not ship type stubs. Here’s how I handle it.

For your own legacy code, use # type: ignore sparingly and with comments explaining why:

result = legacy_function(data)  # type: ignore[no-untyped-call]  # TODO: type legacy_module

The comment matters. Bare # type: ignore is a code smell. Targeted ignores with a TODO give you a breadcrumb trail for future cleanup.

For third-party libraries, check typeshed first — it ships stubs for hundreds of popular packages. If your library isn’t covered, look for a types-* package on PyPI:

pip install types-requests types-redis types-PyYAML

If neither exists, you can write your own stub files. I usually only stub the functions I actually call:

# stubs/some_library/__init__.pyi
def process(data: bytes, encoding: str = ...) -> str: ...
def connect(host: str, port: int, timeout: float = ...) -> Connection: ...

Put your stubs in a stubs/ directory and point mypy at it:

[tool.mypy]
mypy_path = "stubs"

Patterns I’ve learned to avoid

After typing several production codebases, I’ve developed opinions about what not to do.

Don’t use Any as an escape hatch. Every Any in your codebase is a hole in your type safety. It propagates — a function returning Any infects every caller. If you genuinely don’t know the type, use object instead. It’s the actual top type in Python and forces you to narrow before using the value.

Don’t over-annotate local variables. Mypy and pyright both do type inference. This is noise:

# Don't do this
name: str = "Andrew"
count: int = 0
items: list[str] = []

# Do this — the types are obvious
name = "Andrew"
count = 0
items: list[str] = []  # this one's worth annotating — empty list needs it

Annotate locals only when the type isn’t obvious from the assignment, or when you need to declare a type before assignment.

Don’t fight the type checker with casts. If you find yourself writing cast() frequently, your data model probably needs rethinking. Casts are assertions to the type checker that you know better — and sometimes you’re wrong.

Don’t ignore None. The billion-dollar mistake applies to Python too. If a function can return None, say so. If it can’t, don’t annotate it as Optional. Be precise:

# Be honest about None
def find_user(user_id: str) -> User | None:
    ...

# Force callers to handle it
user = find_user("abc")
if user is None:
    raise NotFoundError("User not found")
# mypy now knows user is User, not User | None

This connects to something I explored in Python decorators — properly typed decorators are notoriously tricky, but ParamSpec and Concatenate from typing have made it much more tractable.


Making it stick — CI, pre-commit, and team culture

Tooling without enforcement is just suggestions. Here’s the setup I use on every project now.

Pre-commit hooks for fast feedback:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.13.0
    hooks:
      - id: mypy
        additional_dependencies: [pydantic, types-requests]
        args: [--config-file, pyproject.toml]

CI as the gate. The pre-commit hook catches most issues locally, but CI is the source of truth. No PR merges with type errors. Period.

Gradual strictness. Start with basic checking and ratchet up over time. I track the mypy error count and make sure it only goes down:

# In CI — fail if error count increases
mypy src/ --config-file pyproject.toml 2>&1 | tail -1 | grep -q "Success" || exit 1

Team buy-in matters more than tooling. I’ve seen teams with perfect mypy configs where half the developers just slap # type: ignore on everything. The fix isn’t more tooling — it’s pairing sessions where you show people the bugs that type checking catches. Show, don’t tell.


The payoff

Six months after adopting strict type checking on my main project, our bug rate for type-confusion issues dropped to essentially zero. Code reviews got faster because reviewers could understand function contracts at a glance. Refactoring became dramatically less scary — rename a field in a TypedDict and mypy tells you every place that needs updating.

The initial investment is real. You’ll spend time learning the type system’s quirks, arguing about whether to use dict or TypedDict, and wrestling with generic types that don’t quite express what you mean. But the compound returns are enormous.

Python’s type system isn’t perfect. It’s bolted on, it has gaps, and some patterns — like dynamic programming techniques with heavily dynamic data structures — are genuinely hard to type well. But for the vast majority of production code, type hints plus a good checker plus Pydantic at the boundaries gives you something remarkable: Python’s expressiveness with a safety net that actually catches you when you fall.

Start with your next function. Add the types. Run mypy. Fix what it finds. Then do it again tomorrow. That’s the whole strategy.