I read your PR from yesterday. The user lookup service. I found four bugs in it before I ran a single test.
Four bugs? The tests all pass. I ran them locally.
The tests pass. The bugs are not the kind tests catch — not until production hits a path those tests do not cover. Here is the function:
def get_user_display(user_id, include_email=False):
user = fetch_user(user_id)
if include_email:
return user["name"] + " — " + user["email"]
return user["name"]Three questions. What type is user_id? What does fetch_user return? What happens when the user is not found?
Those are obvious from context. user_id is an int, fetch_user returns a dict, and… I did not add a None case.
Right. Python will not tell you any of this at runtime. It will run with a string user_id, call fetch_user with that string, and fail three levels away from the mistake. Annotations are the contract you write so that mypy can check this before the code runs:
def get_user_display(user_id: int, include_email: bool = False) -> str:
user = fetch_user(user_id)
...Annotations mean nothing to the Python interpreter. They mean everything to mypy.
So mypy reads annotations and checks whether the code is consistent with them. Without running the code.
Exactly. The four bugs: a caller passing user_id as a string from a URL parameter. fetch_user returning None when the user is not found — the annotation said it could not be None. include_email being called with the string "true" from a query parameter. And a concatenation that fails at runtime if user["email"] is None. All four are type errors. None appeared in tests because every test used the happy path.
All four are the same mistake: a value with the wrong type reaching a place that expects a different type. And the tests only covered the case where everything was correct.
That is the most common category of production bug in dynamically typed languages. Not logic errors. Type errors. mypy catches this entire category before the code runs. The annotation vocabulary for 80% of cases:
def find_user(user_id: int) -> dict | None: # can return None
...
def parse_value(raw: str) -> int | float: # one of several types
...
def log_event(event: str) -> None: # no return value
...Python 3.10+ uses | syntax. Earlier versions use Optional[int] and Union[int, float] from typing.
What does the workflow look like? I cannot annotate two hundred functions today. Can mypy run on partially annotated code?
By default mypy skips unannotated functions. Add annotations function by function and mypy reports errors only in the annotated parts. Start with public function signatures — the ones that cross module boundaries. Those are where the type mismatches cause real bugs.
And # type: ignore suppresses errors on specific lines? I have already found cases where I know what type it is but mypy cannot infer it.
Use it deliberately. # type: ignore on a line silences all mypy errors for that line. A better pattern is # type: ignore[arg-type] with the specific error code — you are acknowledging a known issue, not blanket-suppressing. Treat any # type: ignore comment as technical debt. It should have a companion ticket.
The workflow: annotate function signatures starting at module boundaries, run mypy, fix errors in order of severity, use # type: ignore sparingly with specific error codes, and gradually tighten the config as coverage improves. The annotations are contracts. mypy verifies that callers honor them.
That is the senior engineer workflow for introducing mypy to a legacy codebase. Tomorrow is anti-patterns — bugs that mypy cannot catch because they are semantically valid Python that behaves incorrectly. You will leave tomorrow's session auditing your own recent PRs.
mypy is a static type checker that implements PEP 484 (type hints) and PEP 526 (variable annotations). It operates on the abstract syntax tree, not the runtime, and uses a constraint-solving algorithm to infer and check types across function boundaries.
The gradual typing model. Python's type system is gradual, not strict. Every unannotated value has type Any, which is compatible with every other type in both directions. This means mypy can run on partially annotated code without errors in unannotated sections — it simply skips those sections. As you add annotations, mypy gains the information it needs to check cross-function boundaries. The --disallow-untyped-defs flag makes mypy require annotations on all new function definitions, which is how teams enforce gradual adoption.
Optional and the None problem. In Python, any function can return None by falling off the end of its body without an explicit return. mypy models this as a None type. When a function is annotated -> str, mypy verifies that every return path in the function body returns a str and not None. When a function is annotated -> str | None, mypy flags every caller that uses the return value as definitely a str without first checking for None. This check eliminates an entire class of AttributeError: 'NoneType' object has no attribute... errors.
Type narrowing and control flow. mypy tracks type information through control flow. After if user is not None:, mypy knows that user has its non-None type inside the branch. After isinstance(x, str):, mypy knows x is a str inside the branch. This is called type narrowing. It means you do not need to cast — you check, and mypy updates its type information automatically. The assert statement also narrows types: assert isinstance(x, int) is a common pattern for narrowing after a general-purpose deserialization function.
Stubs and the py.typed marker. Third-party libraries without type annotations are treated as returning Any. Libraries that want to participate in type checking include a py.typed marker file in their package, which tells mypy to read type information from the library's source. Libraries that ship separate stubs (.pyi files) on PyPI as types-* packages provide type information without modifying the library itself. When neither is available, Any propagates through every call into that library, which is mypy's "I do not know" type — it does not cause errors but it also does not provide safety.
Sign up to write and run code in this lesson.
I read your PR from yesterday. The user lookup service. I found four bugs in it before I ran a single test.
Four bugs? The tests all pass. I ran them locally.
The tests pass. The bugs are not the kind tests catch — not until production hits a path those tests do not cover. Here is the function:
def get_user_display(user_id, include_email=False):
user = fetch_user(user_id)
if include_email:
return user["name"] + " — " + user["email"]
return user["name"]Three questions. What type is user_id? What does fetch_user return? What happens when the user is not found?
Those are obvious from context. user_id is an int, fetch_user returns a dict, and… I did not add a None case.
Right. Python will not tell you any of this at runtime. It will run with a string user_id, call fetch_user with that string, and fail three levels away from the mistake. Annotations are the contract you write so that mypy can check this before the code runs:
def get_user_display(user_id: int, include_email: bool = False) -> str:
user = fetch_user(user_id)
...Annotations mean nothing to the Python interpreter. They mean everything to mypy.
So mypy reads annotations and checks whether the code is consistent with them. Without running the code.
Exactly. The four bugs: a caller passing user_id as a string from a URL parameter. fetch_user returning None when the user is not found — the annotation said it could not be None. include_email being called with the string "true" from a query parameter. And a concatenation that fails at runtime if user["email"] is None. All four are type errors. None appeared in tests because every test used the happy path.
All four are the same mistake: a value with the wrong type reaching a place that expects a different type. And the tests only covered the case where everything was correct.
That is the most common category of production bug in dynamically typed languages. Not logic errors. Type errors. mypy catches this entire category before the code runs. The annotation vocabulary for 80% of cases:
def find_user(user_id: int) -> dict | None: # can return None
...
def parse_value(raw: str) -> int | float: # one of several types
...
def log_event(event: str) -> None: # no return value
...Python 3.10+ uses | syntax. Earlier versions use Optional[int] and Union[int, float] from typing.
What does the workflow look like? I cannot annotate two hundred functions today. Can mypy run on partially annotated code?
By default mypy skips unannotated functions. Add annotations function by function and mypy reports errors only in the annotated parts. Start with public function signatures — the ones that cross module boundaries. Those are where the type mismatches cause real bugs.
And # type: ignore suppresses errors on specific lines? I have already found cases where I know what type it is but mypy cannot infer it.
Use it deliberately. # type: ignore on a line silences all mypy errors for that line. A better pattern is # type: ignore[arg-type] with the specific error code — you are acknowledging a known issue, not blanket-suppressing. Treat any # type: ignore comment as technical debt. It should have a companion ticket.
The workflow: annotate function signatures starting at module boundaries, run mypy, fix errors in order of severity, use # type: ignore sparingly with specific error codes, and gradually tighten the config as coverage improves. The annotations are contracts. mypy verifies that callers honor them.
That is the senior engineer workflow for introducing mypy to a legacy codebase. Tomorrow is anti-patterns — bugs that mypy cannot catch because they are semantically valid Python that behaves incorrectly. You will leave tomorrow's session auditing your own recent PRs.
mypy is a static type checker that implements PEP 484 (type hints) and PEP 526 (variable annotations). It operates on the abstract syntax tree, not the runtime, and uses a constraint-solving algorithm to infer and check types across function boundaries.
The gradual typing model. Python's type system is gradual, not strict. Every unannotated value has type Any, which is compatible with every other type in both directions. This means mypy can run on partially annotated code without errors in unannotated sections — it simply skips those sections. As you add annotations, mypy gains the information it needs to check cross-function boundaries. The --disallow-untyped-defs flag makes mypy require annotations on all new function definitions, which is how teams enforce gradual adoption.
Optional and the None problem. In Python, any function can return None by falling off the end of its body without an explicit return. mypy models this as a None type. When a function is annotated -> str, mypy verifies that every return path in the function body returns a str and not None. When a function is annotated -> str | None, mypy flags every caller that uses the return value as definitely a str without first checking for None. This check eliminates an entire class of AttributeError: 'NoneType' object has no attribute... errors.
Type narrowing and control flow. mypy tracks type information through control flow. After if user is not None:, mypy knows that user has its non-None type inside the branch. After isinstance(x, str):, mypy knows x is a str inside the branch. This is called type narrowing. It means you do not need to cast — you check, and mypy updates its type information automatically. The assert statement also narrows types: assert isinstance(x, int) is a common pattern for narrowing after a general-purpose deserialization function.
Stubs and the py.typed marker. Third-party libraries without type annotations are treated as returning Any. Libraries that want to participate in type checking include a py.typed marker file in their package, which tells mypy to read type information from the library's source. Libraries that ship separate stubs (.pyi files) on PyPI as types-* packages provide type information without modifying the library itself. When neither is available, Any propagates through every call into that library, which is mypy's "I do not know" type — it does not cause errors but it also does not provide safety.