66.1 mypy: Configuration, Running, and Reading Errors
Right, so you’ve decided to stop flying blind and let a static type checker yell at you about your code before it explodes in production. Good choice. mypy is the grumpy, pedantic, and ultimately brilliant old guard of Python type checking. It’s not here to be your friend; it’s here to be right. Let’s get it configured and learn how to interpret its particular brand of tough love.
First things first, you don’t just run mypy on a random file and call it a day. You need a configuration file to tell it how to behave. This is non-negotiable. Without it, you’re just getting generic, often useless, advice. The file is called mypy.ini or pyproject.toml (the modern, cooler choice). We’ll use pyproject.toml because it’s where the industry is headed.
The Essential pyproject.toml
Create this file in your project’s root. This isn’t just a suggestion; it’s the command center.
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
strict_optional = true # This is a big one. Never disable it.
show_error_codes = true # Crucial for silencing specific errors.
[[tool.mypy.overrides]]
module = "django.*"
ignore_missing_imports = true
Let’s break this down because these options matter. python_version tells mypy which syntax to use (e.g., | for unions). disallow_untyped_defs is the nuclear option: it forces you to type every function. It’s annoying until it saves you from a four-hour debug session, then you’ll name your firstborn after it. strict_optional is what finally makes None handling actually work correctly. The overrides section is our admission that the real world is messy. Django’s magic strings-for-models system makes mypy’s head explode, so we politely tell it to look the other way for those modules.
Running mypy and Reading the Output
Now, the moment of truth. Run mypy . in your terminal from the project root. Brace yourself.
You’ll see two types of errors: the obvious “you’re an idiot” errors and the “what in the holy hell are you talking about” errors. The key is the error code at the end of each line, thanks to show_error_codes = true.
your_module/services.py:42: error: Argument 1 to "send_email" has incompatible type "str | None"; expected "str" [arg-type]
This is mypy being crystal clear and helpful. You passed a None or a potential None where a straight str was expected. The fix is to handle the None case before this line. The [arg-type] code is the category.
Now, the frustrating one:
your_module/legacy_code.py:12: error: "SomeOpaqueClass" has no attribute "questionable_method" [attr-defined]
This usually means one of two things: 1) You’re wrong, and the method doesn’t exist (thanks, mypy!), or 2) The library you’re using doesn’t have type hints. For case (2), you have options. You can write a stub (a .pyi file) or, more commonly, silence it if you know what you’re doing. This is where the error codes earn their keep.
# Let's say you KNOW for a fact this exists due to runtime magic.
result = some_obj.questionable_method() # type: ignore[attr-defined]
The # type: ignore[attr-defined] comment is a targeted scalpel. It ignores only this specific error on this specific line. Never, ever use a bare # type: ignore; that’s a sledgehammer that will hide real bugs and make you look like a hack in code review.
The Dance of Iterative Strictness
Trying to add mypy to an existing large codebase with disallow_untyped_defs = true is like trying to take a sip from a firehose. You will drown in errors. The professional approach is incremental strictness.
Use a mypy.ini section to gradually enforce rules per module:
[mypy]
strict = false # Global laxness
[mypy-my_clean_new_module.*]
disallow_untyped_defs = true
strict_optional = true
This config keeps the old garbage code running without errors while enforcing strict rules on all new code you write in my_clean_new_module. This is how you migrate a real-world project without losing your mind or your job.
The Union of Doom and Narrowing
You’ll write a function that returns about six different types of things and mypy will promptly lose its mind. This is a feature, not a bug. It’s telling you your design is probably too complex.
def get_customer_data(user_id: int) -> dict | list | None | CustomerObj:
# ... some mess of logic
data = get_customer_data(42)
data.update({}) # error: "object" has no attribute "update" [attr-defined]
Mypy’s right. What if it returns a list? list.update() exists, but dict.update() is different. Or what if it returns None? The solution isn’t to ignore the error; it’s to refactor the return type to be more specific or to use proper type narrowing.
if isinstance(data, dict):
data.update({}) # Mypy now knows it's a dict here. Magic!
elif data is None:
... # handle the None case
This is the power of static analysis: it forces you to handle the edge cases you thought would never happen… until they did, at 3 AM on a Saturday.