Writing Type-Safe Python with Pydantic and mypy: What Actually Catches Bugs in Practice

By James Nguyen Updated September 24, 2026
Writing Type-Safe Python with Pydantic and mypy: What Actually Catches Bugs in Practice

I spent years treating Python's dynamic typing as a feature rather than a liability, until a production bug that a type checker would have caught in seconds instead cost me an afternoon of debugging a silent data corruption issue traced back to a function that quietly accepted a string where it expected an integer. Here's how I actually combine Pydantic and mypy now, and where each one earns its place.

Why These Two Tools Together, Not Either Alone

mypy checks your code statically, before it ever runs, catching type mismatches in function signatures and variable assignments across your codebase. Pydantic validates data at runtime, specifically at the boundaries where untrusted input enters your system, an API request body, a config file, a database row. I use mypy to keep internal code honest and Pydantic to guard the edges where data actually comes from outside your control, and treating them as overlapping rather than complementary is where I see most teams get the combination wrong.

Setting Up mypy Without It Becoming a Full-Time Job

Running mypy in strict mode on a large, previously untyped codebase produces an overwhelming wall of errors that discourages anyone from actually fixing them. I start with a more permissive configuration, gradually tightening specific settings, disallow_untyped_defs first, then no_implicit_optional, then the stricter checks, module by module rather than trying to achieve full strictness across the entire codebase in one pass.

# mypy.ini - a starting configuration that doesn't punish you immediately
[mypy]
python_version = 3.12
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = False

[mypy-app.core.*]
disallow_untyped_defs = True
strict = True

Pydantic Models as the Actual Contract

Rather than validating incoming data with a pile of manual if-checks, I define a Pydantic model describing exactly what a valid request should look like, and let the library handle validation, coercion, and error messages consistently across every endpoint.

from pydantic import BaseModel, Field, field_validator

class CreateUserRequest(BaseModel):
    email: str
    age: int = Field(gt=0, lt=150)
    referral_code: str | None = None

    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("not a valid email")
        return v

Where This Actually Caught a Real Bug

A refactor that changed a function's return type from a list to a generator broke a downstream loop that called len() on the result, a mistake that would have surfaced as a confusing runtime error in production. mypy flagged the mismatch immediately during the pull request, before it ever reached a code review, let alone deployment, because the function's return type annotation no longer matched how it was actually being consumed.

The Gap Between the Two: Data That Passes Validation But Is Still Wrong

Pydantic confirms a field is a valid integer within range; it doesn't know that a specific integer combined with another field represents a business-logic-invalid state. I've started adding model-level validators for these cross-field checks specifically, using Pydantic's model_validator rather than relying purely on individual field constraints.

from pydantic import model_validator

class DateRange(BaseModel):
    start: date
    end: date

    @model_validator(mode="after")
    def check_order(self) -> "DateRange":
        if self.end < self.start:
            raise ValueError("end date must be after start date")
        return self

Generic Types and Where mypy Still Struggles

Generic containers and complex union types are where I still hit real friction, particularly with third-party libraries that ship incomplete or missing type stubs. I've learned to accept type: ignore comments as a pragmatic escape hatch for genuinely untyped dependencies rather than fighting mypy into submission on code I don't control.

Integrating Both Into CI

Running mypy as a required CI check, alongside a test suite that specifically exercises Pydantic validation edge cases, invalid emails, out-of-range ages, missing required fields, catches a meaningfully different category of bugs than either tool running in isolation, and I've made both a required gate before merging to main rather than an optional, ignorable warning.

Where I've Decided Not to Bother

For genuinely short-lived scripts, one-off data migrations, quick exploratory notebooks, I don't bother with either tool, since the setup overhead outweighs any benefit for code that runs once and gets deleted. Reserving strict typing for code that will actually be maintained and modified by other people over time has kept this from feeling like unnecessary ceremony applied indiscriminately.

The Actual Payoff

Six months into applying this combination consistently across a mid-sized production codebase, the category of bug that used to slip through to production, wrong types passed between functions, malformed data accepted at API boundaries, has genuinely dropped, not disappeared entirely, but caught early enough in development that it stopped costing real debugging time in production incidents.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles