Type Checking & Static Analysis

Type checkers read your type hints without running your code and flag mismatches before they become runtime bugs — gradually, and only as strictly as you choose to enable.

Python's type hints (see the dedicated Type Hints topic) are, by themselves, inert at runtime — the interpreter does not enforce them. Static type checkers are the tools that give hints teeth: they read your annotated source without executing it and report where the types don't line up. Every checker discussed here — mypy and pyright for full type checking, plus Ruff's lighter type-adjacent lint rules — is a third-party tool you opt into; none is part of the standard library or the interpreter itself.

What Is Static Type Checking

Static type checking analyzes source code's type annotations without running the program, in contrast to Python's actual runtime behavior, which remains dynamically typed regardless of what a checker concludes. A type checker builds a model of what type each expression *should* be based on annotations and inference, then flags places where that model is inconsistent — passing a str where an int is annotated, calling a method that doesn't exist on the inferred type, or ignoring a possible None.

This is a genuinely separate axis from linting: a linter mostly cares about style and simple logical mistakes; a type checker specifically reasons about the *shape* of data flowing through your program, which requires actually understanding function signatures, class hierarchies, and generics.

Type Hints Recap

Type hints are optional annotations added with : for variables/parameters and -> for return types, introduced by PEP 484 and refined by many later PEPs (this topic assumes familiarity — see the dedicated Type Hints topic for the full syntax). What matters here is that hints are just metadata stored in __annotations__; they change nothing about runtime execution on their own. A type checker is what turns that metadata into an actual guarantee, at *check time*, not run time.

def add(a: int, b: int) -> int:
    return a + b

add("1", "2")  # runs at runtime! Python doesn't check hints itself.
               # a type checker (mypy/pyright) flags this as an error
               # before the program ever runs.

mypy (third-party)

mypy is the original and most widely referenced third-party Python type checker, developed with close involvement from the typing PEPs' own authors. It's a command-line tool (and editor plugin) that reads your .py files, resolves type hints, and reports mismatches.

mypy is written in Python itself, which historically made it slower on very large codebases than newer, compiled alternatives -- a factor in pyright's and Ruff's popularity for fast feedback loops.

$ pip install mypy
$ mypy mymodule.py
mymodule.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

pyright (third-party)

pyright is a third-party type checker originally built by Microsoft, distributed as a Node-based CLI tool and, more commonly for day-to-day use, as the engine behind the Pylance extension in VS Code. It's known for fast incremental checking (useful for live in-editor feedback) and, in many cases, stricter inference than mypy's defaults out of the box.

mypy and pyright can disagree on edge cases -- both implement the same typing PEPs, but each makes independent judgment calls where the spec leaves room for interpretation. Neither is "more correct" as a blanket rule; teams typically pick one as their standard.

$ npm install -g pyright   # or: pip install pyright (wraps the same tool)
$ pyright mymodule.py
mymodule.py:5:5 - error: Argument of type "str" is not assignable to
  parameter "a" of type "int"
1 error, 0 warnings, 0 informations

Ruff's Type-Adjacent Rules (third-party)

Ruff (see the Formatting & Linting topic) is a linter, not a full type checker — it doesn't do the deep, whole-program type inference mypy/pyright perform. But it includes fast, cheap rule sets that catch some typing-adjacent mistakes: incorrect use of typing constructs, outdated typing syntax (pyupgrade-style rules that suggest list[int] over List[int] on modern Python), and some flake8-annotations-style checks for missing hints on public functions. Ruff complements a real type checker; it doesn't replace one.

Gradual Typing

Python's typing system is explicitly gradual: you can annotate as much or as little of a codebase as you want, and unannotated code is treated as implicitly compatible with everything (effectively typed Any) rather than causing errors. This lets a team adopt type checking incrementally — annotate the modules that matter most (public APIs, complex business logic) first, and leave legacy or low-risk code unannotated until there's time to revisit it, rather than requiring a big-bang rewrite.

def legacy_helper(data):       # no annotations: checker treats as Any
    return data.get("value")

def new_code(config: dict[str, int]) -> int:   # fully annotated, checked
    return config["timeout"]

# calling legacy_helper's result anywhere doesn't get flagged,
# because Any is compatible with everything -- by design

Type Coverage and Strictness Levels

Both mypy and pyright support graduated strictness settings, since "how strict should the checker be" is itself a project-level policy choice, not a fixed rule. mypy's --strict flag (or granular flags like --disallow-untyped-defs) turns on many checks that are off by default (e.g. requiring every function to be fully annotated); pyright has basic and strict type-checking modes configured per project. "Type coverage" describes what fraction of a codebase is meaningfully annotated and checked — a metric worth tracking as a codebase adopts typing incrementally, similar in spirit to test coverage.

# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true              # turn on the full strict rule set
warn_unused_ignores = true

# or, incrementally:
[tool.mypy]
disallow_untyped_defs = true   # require annotations on new functions
check_untyped_defs = true      # still type-check inside unannotated ones

Common Typing Patterns for Checkers

A few patterns come up constantly once a checker is enabled: X | None (or the older Optional[X]) for values that may be absent, which forces callers to narrow before using the value; Union/X | Y for values that may be one of several concrete types; and generics (list[int], a user-defined Generic class) so containers carry element-type information through the program instead of degrading to Any.

This narrowing behavior -- the checker tracking that inside the if block, user can no longer be None -- is a core reason type checkers catch real bugs, not just style nits.

def find_user(user_id: int) -> "User | None":
    ...

user = find_user(42)
print(user.name)          # checker error: user might be None
if user is not None:
    print(user.name)      # OK: checker has narrowed the type here

CI Integration

Type checking earns its keep when it runs automatically on every change, the same way tests do (see the CI/CD for Python topic) — a check that only runs when someone remembers to run it locally will drift out of use. A typical CI pipeline runs mypy . (or pyright) as a required step alongside linting and tests, failing the build on new type errors.

# .github/workflows/ci.yml (excerpt, third-party GitHub Actions)
- name: Type check
  run: mypy src/ --strict

Edge Cases: Any, # type: ignore, and Stubs

Any is the type checker's escape hatch — a value annotated (or inferred) as Any is compatible with everything, and using it disables checking for that value entirely. It's sometimes necessary (interfacing with untyped third-party code, deliberately dynamic code) but overusing it quietly defeats the purpose of typing the rest of the codebase. # type: ignore[code] suppresses a specific error on one line and should, like a lint # noqa, be scoped and justified rather than blanket. Third-party libraries without inline type hints are often supported via separate stub packages (types-requests, types-PyYAML, etc., typically from the typeshed project) that provide type information without touching the library's own source.

import json

data: dict = json.loads(raw)   # dict alone is effectively dict[Any, Any]

value = data["count"]  # type: ignore[index]  -- upstream lib returns Any here

Complete Example

Fully annotated with Optional narrowing handled explicitly; running mypy --strict or pyright on this file reports zero errors -- the None-safety is enforced statically, not just by convention.

from __future__ import annotations

from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str
    email: str | None = None


def find_user(users: list[User], user_id: int) -> User | None:
    for user in users:
        if user.id == user_id:
            return user
    return None


def greet(users: list[User], user_id: int) -> str:
    user = find_user(users, user_id)
    if user is None:
        return "Unknown user"
    return f"Hello, {user.name}!"

Quick Reference

Tool     Kind                     Notes
-------  -----------------------  ------------------------------------
mypy     full type checker        original, Python-based, widely used
pyright  full type checker        Microsoft; powers VS Code's Pylance
Ruff     linter, type-adjacent    fast, shallow typing-related rules only

Any             -- disables checking for that value; use sparingly
# type: ignore  -- suppress one line's error; scope to a specific code
stub packages   -- typeshed-style types-* packages for untyped libraries
gradual typing  -- annotate incrementally; unannotated code == Any

Key Takeaways

Type hints alone do nothing at runtime — mypy, pyright, and similar third-party checkers are what actually enforce them, and only at check time.

mypy and pyright both implement the same typing PEPs but can disagree on edge cases; pick one as a project's standard.

Python's typing is gradual by design — annotate incrementally, and unannotated code is treated as Any, not an error.

Strictness is configurable (--strict, per-flag settings) and is a project-level policy decision, tightened as type coverage grows.

Wire type checking into CI the same way as tests — a check nobody runs automatically stops being trustworthy.

def process(data: dict) -> Any: return data["result"] def process(data: dict[str, object]) -> str: result = data["result"] assert isinstance(result, str) return result

Returning Any silently disables type checking for every caller of process; being specific about the return type (and validating at the boundary) keeps the guarantee flowing through the codebase.

user = find_user(42) print(user.name) # find_user returns User | None user = find_user(42) if user is not None: print(user.name)

Accessing an attribute on a possibly-None value is exactly what type checkers exist to catch -- narrowing with an explicit None check (or raising) resolves the flagged error and the real bug.

value = risky_call() # type: ignore value = risky_call() # type: ignore[return-value] -- upstream stub is wrong, tracked in issue #123

An unscoped, unexplained type: ignore suppresses every error on that line (including future ones introduced by mistake) with no record of why -- scope it to a specific error code and note the reason.

Does the Python interpreter enforce type hints at runtime by default?

No. Type hints are inert metadata (stored in __annotations__) unless something reads and acts on them -- a static type checker like mypy or pyright at check time, or a library that explicitly inspects annotations at runtime (e.g. some validation libraries).

Are mypy and pyright part of the Python standard library?

No, both are third-party tools installed separately (pip install mypy, or pyright via pip/npm) and run as a distinct step outside the interpreter.

What does "gradual typing" mean in Python's type system?

You can annotate as much or as little of a codebase as you choose; unannotated code is treated as implicitly compatible with everything (Any) rather than as an error, allowing incremental adoption instead of an all-or-nothing rewrite.

What's the difference in purpose between a type checker like mypy and a linter like Ruff, given both can report typing-related issues?

A full type checker performs deep type inference across the whole program (tracking flow-sensitive narrowing, generics, overloads); a linter's typing-related rules are shallow, fast pattern checks -- Ruff complements a real type checker but does not replace whole-program type inference.

Why might a project enable mypy's --strict flag only gradually, module by module?

Strict mode requires full annotation coverage and disallows several previously-tolerated patterns; flipping it on for an entire existing codebase at once often produces an overwhelming number of errors, so teams often tighten strictness module by module as each is brought up to standard.

What is a stub package (e.g. types-requests) for?

It supplies type information for a third-party library that doesn't ship its own inline type hints, without modifying the library's source -- often sourced from or compatible with the typeshed project.

What would mypy or pyright report for this call, and why does the code still run if you execute it with plain python?

The type checker reports an error: list[str] is not assignable to the annotated parameter list[int]. It still runs under plain python because type hints are not enforced at runtime -- sum(['1','2','3']) actually raises a TypeError at runtime (str + int isn't supported), but that's a runtime error, unrelated to and independent of the static type error the checker reports before execution.

Write a function safe_div(a: int, b: int) -> float | None that returns None instead of raising on division by zero, in a way that passes a strict type checker with no Any or type: ignore.

def safe_div(a: int, b: int) -> float | None: if b == 0: return None return a / b

The return type float | None matches both return paths exactly, so a strict checker accepts it with no suppressions needed -- callers are then required to handle the None case explicitly, exactly the guarantee a type checker is meant to provide.

Write a minimal pyproject.toml [tool.mypy] section that targets Python 3.12 and requires every function definition to be annotated, without turning on full --strict mode.

[tool.mypy] python_version = "3.12" disallow_untyped_defs = true disallow_untyped_defs enforces annotation coverage on function definitions specifically, which is a common incremental step short of full strict mode -- illustrating that strictness is configurable at a fine grain, not just all-or-nothing.

def total(items: list[int]) -> int:
    return sum(items)

total(["1", "2", "3"])