Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~29 min· topic 3 of 9

Topic 10.3

Running a Type Checker: mypy and pyright

In one line

A type checker reads your code without running it and reports every place where the hints contradict each other. mypy and pyright are the two most used; you install one, point it at your files, read its messages, and run it in CI so mistakes cannot be merged.

Think of it like this

A spell checker for a document. It does not need the letter to be posted to find the typo; it reads the text and underlines what looks wrong. A type checker is a spell checker for the relationships between your values: it reads the code, follows each value through the program, and underlines the line where a str meets an int.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

mypy
A widely used type checker for Python.
pyright
A fast type checker from Microsoft; also the engine behind Pylance in VS Code.
Error code
The short name in square brackets after a mypy message, like [arg-type].
`reveal_type`
A pseudo-function the checker understands: it prints the type it has worked out.
Strict mode
A stricter setting that demands labels everywhere and flags careless Any.
`# type: ignore`
A comment that silences the checker on one line.
`cast`
A call that tells the checker to treat a value as a given type; Python does nothing at run time.
CI
Continuous integration: a server that runs your checks on every change.
Stub file (`.pyi`)
A file holding only type labels for a library that has none of its own.

Step by step

01Install and run

A checker is just another package. Install it in the same environment as your project (Topic 6.5) so it can see your dependencies, then give it files or folders. This session used mypy 2.4.0; the messages in current versions may be worded a little differently.

terminal
$ pip install mypy
mypy app.py
── expected output ──
app.py:9: error: Argument 1 to "add" has incompatible type "str"; expected "int" [arg-type]
app.py:9: error: Argument 2 to "add" has incompatible type "str"; expected "int" [arg-type]
app.py:11: error: Unsupported operand types for + ("None" and "int") [operator]
app.py:11: note: Left operand is of type "int | None"
app.py:12: note: Revealed type is "int"
Found 3 errors in 1 file (checked 1 source file)

02Read the report

Each line is file:line: severity: message [code]. Errors fail the run: the checker ends with a non-zero exit code (the number a program hands back when it finishes; 0 means success), which is what makes CI stop. note lines add detail to the error above them. The file that produced this report is below; match each message to its line.

app.pywhole filepython
def add(a: int, b: int) -> int:
    return a + b


def find_user(users: dict[str, int], name: str) -> int | None:
    return users.get(name)


total = add("2", "3")                          # line 9: two wrong arguments
user_id = find_user({"asha": 1}, "asha")
print(user_id + 1)                             # line 11: could be None
reveal_type(total)                             # line 12: ask the checker

03Fix by handling the case

The message for line 11 says the left operand is int | None. The honest fix is to handle the None (narrowing, Topic 10.2), not to silence the checker. Once you add the check, the report is clean for that line.

app.pywhole filepython
user_id = find_user({"asha": 1}, "asha")
if user_id is not None:
    print(user_id + 1)
else:
    print("no such user")

04Strict mode

By default mypy is happy with a function that has no labels, since it cannot judge what it does not know. --strict refuses unlabelled functions. In the second run below, the untyped double is reported, and so is the call to it from labelled code.

strict.pywhole filepython
def double(x):
    return x * 2


def triple(x: int) -> int:
    return x * 3


print(double("ab"), triple(2))
terminal
$ mypy strict.py
mypy --strict strict.py
── expected output ──
Success: no issues found in 1 source file
strict.py:1: error: Function is missing a type annotation [no-untyped-def]
strict.py:9: error: Call to untyped function "double" in typed context [no-untyped-call]
Found 2 errors in 1 file (checked 1 source file)

05Configure it once in pyproject.toml

Put the settings next to the rest of your project configuration (Topic 6.7) so every developer and CI run uses the same rules. Switch on strictness for new code and relax it for specific old modules with per-module overrides.

pyproject.tomlwhole filetoml
[tool.mypy]
python_version = "3.13"
strict = true

[[tool.mypy.overrides]]
module = "legacy.*"
ignore_errors = true

06Silencing narrowly, and in CI

If you must silence a line, name the error code and say why. And make the check part of the pipeline so that a broken label fails the build, just as a failing test would.

.github/workflows/typecheck.ymlwhole fileyaml
name: typecheck
on: [push, pull_request]
jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install mypy
      - run: mypy src/

07What a checker cannot see

A checker knows only what the labels say. It cannot know that a JSON file will contain a string where you hoped for a number, that an LLM reply will match your schema, or that a library without hints returns what you think. Labels at the edges of your program need run-time validation (Topic 10.7). Libraries that ship no hints are described by separate stub packages, e.g. types-requests.

Try it yourself

  1. 1

    Reveal what the editor thinks

    In VS Code with the Python extension, hover over a variable: the type shown is the checker's belief. Hover over the result of dict.get and you will see int | None. Hovering is reveal_type without typing it.

  2. 2

    Run the run-time reveal_type

    In the first example, call typing.reveal_type("hello") and typing.reveal_type([1, 2]). The run-time version tells you the class (str, list), not the item type: the program only knows what a value *is*, not what a hint *promised*.

  3. 3

    On your computer

    Install mypy in a virtual environment, save the app.py of this topic, run mypy app.py and compare your report with the one shown. Then fix the three problems one by one.

Code & diagrams

What stays the same at run time New tab

reveal_type from typing (3.11+) prints the real type to the error stream. cast and TYPE_CHECKING show that checker-only tools leave no mark when running.

Sign in to run this example in your browser.

Expected output

TYPE_CHECKING at run time: False
Runtime type is 'int'
value = 42
cast returned: 'not an int' str
A second file the checker rejectspython

mypy reports these without running the file. Protocols, TypedDict and Literal are the subject of Topics 10.5 and 10.6.

from typing import Protocol, TypedDict, Literal, assert_never


class Quacker(Protocol):
    def quack(self) -> str: ...


class Duck:
    def quack(self) -> str:
        return "Quack"


class Dog:
    def bark(self) -> str:
        return "Woof"


def make_noise(animal: Quacker) -> str:
    return animal.quack()


make_noise(Duck())
make_noise(Dog())                     # line 23


class Movie(TypedDict):
    title: str
    year: int


m: Movie = {"title": "Up", "year": "2009"}      # line 31

Role = Literal["user", "assistant"]


def label(role: Role) -> str:
    if role == "user":
        return "You"
    elif role == "assistant":
        return "AI"
    else:
        assert_never(role)


label("system")                       # line 45

Output

more.py:23: error: Argument 1 to "make_noise" has incompatible type "Dog"; expected "Quacker"  [arg-type]
more.py:31: error: Incompatible types (expression has type "str", TypedDict item "year" has type "int")  [typeddict-item]
more.py:45: error: Argument 1 to "label" has incompatible type "Literal['system']"; expected "Literal['user', 'assistant']"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)
Where checking happensdiagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Silence everything with a bare ignore

A new developer hides an error with a bare comment instead of fixing it.

app.pywhole filepython
total = add("2", "3")   # type: ignore
terminal
$ mypy app.py
── what you'll see ──
Success: no issues found in 1 source file

Myth vs fact

Myth

If mypy passes, my program works.

Fact

It only proves that the labelled parts agree with each other. Logic errors, wrong data from outside, and unlabelled code are not covered.

Myth

I need mypy running in the browser to use hints here.

Fact

Not for learning. This site shows checker sessions as output; to try one you install a checker on your own computer.

Myth

mypy and pyright always agree.

Fact

They agree on the basics but differ on edge cases, speed and how aggressively they infer. Pick one for CI and keep the editor's checker consistent with it.

Myth

A type checker slows the program down.

Fact

It runs before or beside the program, never inside it. The running program is unaffected.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Checkers are static analysers with their own inference engines, so the same code can produce slightly different results between mypy and pyright (for example in how they infer the type of an empty list or narrow an isinstance chain). Pin the checker's version in your lock file, or an upgrade will fail your build overnight.

  • ▸

    Adopt on an old codebase gradually: run with default settings, fix or baseline the errors, then turn strict on package by package using per-module overrides. Tighten the disallow_untyped_defs rule first; it gives the best return.

  • ▸

    Third-party packages without hints are Any to a checker. Many publish a py.typed marker or a types-<name> stub package. Prefer libraries that ship their own hints (Pydantic, httpx, attrs do).

  • ▸

    Fast checkers written in Rust, such as Astral's ty, are arriving, aiming at much faster runs on large projects. The hints you write today work with all of them.

  • ▸

    --strict in mypy is a bundle of about a dozen flags (disallow_untyped_defs, disallow_any_generics, warn_unused_ignores, warn_return_any, no_implicit_reexport and more). It is not a single idea, so a project can adopt the flags one at a time. For big codebases use mypy's daemon (dmypy run -- src/) or rely on its incremental cache (the .mypy_cache folder; keep it in CI's cache) so reruns take seconds. Pyright has no equivalent bundle name: it has modes basic, standard (the default) and strict, and uses its own comment # pyright: ignore[rule].

  • ▸

    Use typing.assert_type(x, int) in tests of your own typing code: the checker fails if the inferred type is not exactly int, and at run time it just returns x. Use if TYPE_CHECKING: imports to break import cycles that exist only for hints, and quote the names ("Decimal") because the import does not happen when the program runs. cast and # type: ignore both lie to the checker, but cast lies about one expression's type while an ignore mutes every error on a line; prefer a real isinstance narrowing, which both satisfies the checker and is verified at run time.

Remember this

  1. 1

    The two big checkers are mypy (the original, written in Python, from the Python community) and pyright (written by Microsoft; its engine also powers the Pylance extension in VS Code, so you may already be using it without knowing). There is also a newer generation of faster checkers, such as Astral's ty, still maturing. All of them read the same hints; they differ in speed, strictness and the exact messages.

  2. 2

    Using one is three steps. First install it into your project (pip install mypy, or uv add --dev mypy; Topics 6.5 and 6.6). Then run it on your files in the terminal (mypy app.py or mypy src/). Then read the report. Each line has the file, the line number and a message. mypy also adds an error code in square brackets, such as [arg-type], that you can search for or use to silence one kind of error precisely.

  3. 3

    A checker reports mistakes you did not run into yet. In the example of this topic it finds three problems in a program of a dozen lines: two arguments of the wrong type and one None that was never handled. None of them has been triggered by running the code. That is the whole value: bugs that were waiting for the right input get reported on day one.

  4. 4

    **reveal_type(x)** is the checker's "show me" button: write it where you are unsure, run the checker, and it prints what it thinks the type of x is. It is not meant to stay in your code.

  5. 5

    Python 3.11 added a separate typing.reveal_type function that works when the program actually runs. It prints the real type of the value to the error output (the channel Python uses for messages that are not normal results) and hands the value back unchanged.

  6. 6

    Checkers have a strictness dial. By default mypy mostly skips functions that have no hints at all. --strict turns on a bundle of stricter rules: every function must be labelled, Any leaking in is flagged, and so on. A common path is to start lenient on an old codebase and tighten one module at a time.

  7. 7

    When the checker is wrong, or you are certain about something it cannot know, add a comment # type: ignore[arg-type] on that line to silence that specific error. Silence narrowly, with the error code, and add a reason; a bare # type: ignore hides everything on the line, including bugs you add later. typing.cast(T, x) tells the checker "treat this as T". It converts nothing and has no effect when the program runs. Both are promises you are making that nobody verifies.

  8. 8

    Finally, make it automatic: run the checker in your editor, in a pre-commit hook (a script that runs before each commit, Topic 11.8) and in CI (a server that runs your checks on every change; see the CI/CD course, /cicd). A checker that people must remember to run is a checker that stops being run. Hints are most valuable in teams where the build fails when they are broken.

Explain it without notes

01

What does a type checker do and what does it not do?

02

What is reveal_type for? How is the typing.reveal_type function different from the checker's one?

03

When is # type: ignore acceptable, and how should you write it?

04

Why run a checker in CI as well as in the editor?

05

How do cast, # type: ignore and an isinstance check differ when you need to convince the checker of a type?

Practice

01

Using mypy on your computer, write a function def half(n: int) -> float: return n / 2 and call half("4"). Say which error code mypy gives.

02

Make a short program that prints the real type of 3 + 4.5 using typing.reveal_type.

03

Write the three lines of a pyproject.toml section that turn on mypy strict mode for Python 3.13.

Trade-offs

  • ↔

    Strictness versus friction. Strict mode catches the most but makes quick scripts and old code noisy. Use it for new, long-lived code and relax it elsewhere.

  • ↔

    mypy versus pyright. mypy is the long-standing community tool with many plugins; pyright is faster and powers VS Code. Teams commonly pick whichever matches their editor and CI.

  • ↔

    Ignores are debt. Every silenced error is a bug the checker can no longer find. Keep them rare, specific and commented.

Done when you can

  • Done when you can install a checker and run it on a file.

  • Done when you can read a mypy line: file, line, message and error code.

  • Done when you can use reveal_type to see an inferred type.

  • Done when you can write a narrow # type: ignore[code] with a reason.

  • Done when you can explain what a checker proves and what it cannot.

  • Done when you can sketch a CI job that runs the checker.