Skip to content
← Back to explainers

Python explainer

Bugs That Pass a Strict Type Check

About 4 min to read. Allow extra time to work through examples.

See a bug that passes mypy in strict mode and still crashes. Learn which other mistakes mypy cannot catch, and what catches each one.

This article uses Python. You can make it your practice mode to browse related content across the site.

You have now submitted two exercises, and mypy checked both. This function passes mypy in strict mode, with every option the site adds, and it still crashes:

import json


def load_name(raw: str) -> str:
    data = json.loads(raw)
    name: str = data["user"]["name"]
    return name


name = load_name('{"user": {"name": 42}}')
print(name.upper())
AttributeError: 'int' object has no attribute 'upper'

The annotation says load_name returns a str. Here it returns the integer 42. The crash happens in the caller, one step away from the real mistake.

mypy does not check Any values

json.loads returns Any, the type mypy gives a value it knows nothing about. When you assign an Any value to name: str, mypy accepts the assignment and treats name as a str. It cannot know what the JSON text will hold when the program runs.

mypy reads your code without running it, and checks that the code agrees with its annotations. It never sees the data your program receives. It cannot tell whether your logic does what you meant.

Fix the bug by checking the data where it enters your program:

import json


def load_name(raw: str) -> str:
    data = json.loads(raw)
    if not isinstance(data, dict):
        raise ValueError("expected a JSON object")
    user = data.get("user")
    if not isinstance(user, dict):
        raise ValueError("expected a user object")
    name = user.get("name")
    if not isinstance(name, str):
        raise ValueError("expected a string name")
    return name

Bad input now fails at once, with a message that says what is wrong. After each isinstance check, mypy knows the real type, so it can check the code that follows.

Mistakes mypy cannot catch

These mistakes all pass a strict type check:

  • Trusting data from outside your program. JSON, files, environment variables and network responses arrive as Any or as plain strings. Check them where they arrive, as above.
  • A wrong cast. cast(int, raw) tells mypy to treat raw as an int. When the program runs, cast returns raw unchanged, so cast(int, "42") is still the string "42". Use an isinstance check instead.
  • An ignore comment over a real bug. # type: ignore[arg-type] hides the error on its line and leaves the bug in place.
  • A misspelled override. If a subclass defines handel instead of handle, mypy sees a new method, and callers still get the parent's handle. Put @override, from typing, on each method that you mean to override. mypy then reports any of them that has no parent method of that name.
  • Wrong values of the right type. mypy cannot tell total - 1 from total + 1. Tests catch these.

A bug where every type is right

This function has correct annotations, and mypy accepts it:

def add_tag(tag: str, tags: list[str] = []) -> list[str]:
    tags.append(tag)
    return tags


print(add_tag("first"))   # ['first']
print(add_tag("second"))  # ['first', 'second']

Python creates the default list once, when it defines the function. Every call that leaves out tags adds to that same list. Use None as the default, and make a new list inside the function:

def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

A test that calls add_tag twice catches this bug. mypy cannot.

Check your understanding Your function passes mypy and its tests, then crashes in production on a payload with a missing field. What should you add? Show answer

Add a check where the payload enters your program. mypy never sees the payload, and your tests cover only the payloads you thought of. Check each field you use, raise an error that names the missing field, and add the failing payload to your tests.

Each check catches different bugs

mypy finds code that uses a value in a way its type does not allow, including in branches your tests never run. Tests check what the code does on the inputs you chose. Checks where data enters your program reject bad data. Use all three, because each one catches bugs that the other two miss.

Going further

  • --disallow-any-explicit rejects Any written in your own annotations. The site does not turn it on, but some projects do.
  • --enable-error-code explicit-override makes mypy require @override on every method that overrides a parent method.
  • Ruff's B006 rule reports mutable default arguments.
  • Libraries such as Pydantic check JSON against a class you define, in place of a chain of isinstance checks.

Read more in mypy's optional error codes and the typing module documentation.

step 5 of 13

Getting Started with Tests and Type Checks

Up next Choose Between mypy, Pyright and ty