Static Type Checking with mypy
Reviewed & published by Brayan K
mypy reads your type hints and checks your code without running it, catching whole classes of bugs before they reach production. Python stays dynamic at runtime, but mypy gives you the safety net of a typed language during development.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • How to run mypy and read its output
- • Gradual typing: adding annotations incrementally
- • Optional, Union, and list[int]
- • Function annotations and reveal_type
- • # type: ignore, Any, and strict mode
- • Generics with TypeVar and Protocol
- • How mypy catches bugs before runtime
🔎 1. What mypy Does
Run it from the command line on a file or whole project:
mypy app.py
mypy . # check the whole project🐛 2. Catching a Bug Before Runtime
With annotations, mypy spots a mismatch your program would otherwise crash on:
def add(a: int, b: int) -> int:
return a + b
result = add(2, "3") # mypy error!
# error: Argument 2 to "add" has incompatible type "str"; expected "int"Plain Python would only fail when this line executes; mypy flags it immediately.
🟢 Worked Example: watch the bug happen, then watch mypy catch it
Here is the single most common bug mypy saves you from: a function that can return None, handed straight to a function that cannot cope with None. Run it — the first line prints happily and the second one blows up.
# 🟢 WORKED EXAMPLE — the bug mypy catches and Python does not (until it is too late)
def find_price(catalogue: dict[str, int], name: str) -> int | None:
"""Look up an item. Gives back the price in pence, or None if it is not stocked."""
# dict.get() returns None for a missing key — that is exactly why the return
# type has to admit None. Writing "-> int" here would be a lie.
return catalogue.get(name)
def format_price(pence: int) -> str:
# This one promises to take an int. Hand it None and the division explodes.
return f"£{pence / 100:.2f}"
catalogue = {"apple": 50, "pear": 75} # prices in pence
print(format_price(find_price(catalogue, "apple"))) # £0.50 — happens to work
print(format_price(find_price(catalogue, "banana"))) # 💥 crashes: banana is not stocked
# ✅ What you see when you run it:
# £0.50
# TypeError: unsupported operand type(s) for /: 'NoneType' and 'int'
# (with a traceback above that line; the file paths depend on where you run it)Now save that same file as shop.py and run mypy shop.py on your own machine. It never executes a line, yet it reports:
shop.py:17: error: Argument 1 to "format_price" has incompatible type "int | None"; expected "int" [arg-type]
shop.py:18: error: Argument 1 to "format_price" has incompatible type "int | None"; expected "int" [arg-type]
Found 2 errors in 1 file (checked 1 source file)Read that carefully, because it teaches you how mypy thinks. It flags both lines — including the apple one that works perfectly today. mypy reasons about types, not values: it cannot know "apple" is in the dictionary, only that find_price is allowed to return None. Any call that could hand None to an int parameter is a bug waiting for the right input.
The fix is not to delete the annotation. It is to handle the None case, which also makes the program better:
# 🟢 THE FIX — check for None once, and mypy stops complaining
def find_price(catalogue: dict[str, int], name: str) -> int | None:
return catalogue.get(name)
def format_price(pence: int) -> str:
return f"£{pence / 100:.2f}"
def show_price(catalogue: dict[str, int], name: str) -> str:
price = find_price(catalogue, name) # price is "int | None" right now
if price is None:
return f"{name}: not stocked" # the None case is handled and returned
# Past that if, mypy knows price can only be an int. This is called narrowing.
return f"{name}: {format_price(price)}"
catalogue = {"apple": 50, "pear": 75}
print(show_price(catalogue, "apple"))
print(show_price(catalogue, "banana"))
# ✅ Expected output:
# apple: £0.50
# banana: not stockedRun mypy on that version and you get the line every Python developer wants to see:
Success: no issues found in 1 source fileNarrowing is the word for what the if did: inside a branch where None has been ruled out, mypy quietly upgrades the type from int | None to int. You get this for free from if x is None, isinstance() checks, and early returns.
🌱 3. Gradual Typing
# Fully typed function
def greet(name: str) -> str:
return "Hi " + name
# Untyped function: mypy treats its internals leniently
def legacy(data):
return data
# You can add types to legacy code later, piece by piece🧰 4. Common Type Annotations
from typing import Optional, Union
def find(ids: list[int]) -> Optional[int]:
# Optional[int] means int OR None
return ids[0] if ids else None
def parse(x: Union[int, str]) -> str:
# Union[int, str] accepts either type
return str(x)
# Modern syntax: int | None and int | str also work| Annotation | Means |
|---|---|
| list[int] | A list whose items are ints |
| Optional[int] | int or None (int | None) |
| Union[int, str] | Either an int or a str (int | str) |
🎯 Your Turn: annotate two functions
The bodies are written; only the annotations are missing. Fill in the four blanks using the modern pipe syntax (str | None), not Optional[str] — the self-check at the bottom prints the annotations back to you, and the two forms print differently.
# 🎯 YOUR TURN — fill in the blanks marked with ___
# 1) Takes a list of whole numbers, gives back their average.
# Averaging with / always produces a float, even for 4 / 2.
def average(scores: ___) -> ___: # 👉 list of ints in, float out
return sum(scores) / len(scores)
# 2) Takes a list of names, gives back the first one — but an empty
# list has no first item, so it returns None instead.
def first_name(names: ___) -> ___: # 👉 list of strs in; a str OR None out
return names[0] if names else None
# ---- self-check: Python records annotations on the function object ----
print("average :", average.__annotations__)
print("first_name:", first_name.__annotations__)
print("average([1, 2, 3]) ->", average([1, 2, 3]))
print("first_name([]) ->", first_name([]))
# ✅ Expected output when your annotations are right:
# average : {'scores': list[int], 'return': <class 'float'>}
# first_name: {'names': list[str], 'return': str | None}
# average([1, 2, 3]) -> 2.0
# first_name([]) -> NoneRemember that annotations do not change what your program does — Python records them and carries on. They exist so mypy (and the next human) can check your intentions. That is why the self-check above can print them straight back at you.
🔬 5. reveal_type
Ask mypy what type it inferred for an expression:
numbers = [1, 2, 3]
reveal_type(numbers) # mypy notes: list[int]
total = sum(numbers)
reveal_type(total) # mypy notes: int
# reveal_type is a mypy-only helper, not meant to run normally🤫 6. type: ignore, Any, and Strict Mode
from typing import Any
x: Any = get_dynamic() # opts out of checking for x
x.anything_goes() # mypy will not complain
risky = some_untyped() # type: ignore
# the comment silences mypy on that one lineRun the strictest checks before deployment:
mypy --strict app.py🧬 7. Generics and Protocols
Use TypeVar for generic functions that preserve types:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
reveal_type(first([1, 2, 3])) # int
reveal_type(first(["a", "b"])) # strUse Protocol for structural typing — "if it has these methods, it fits":
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def total_length(x: Sized) -> int:
return len(x)
# Any object with __len__ qualifies: list, str, dict, ...🏁 Mini-Challenge: type a small text module
No blanks and no bodies this time — just a brief. Write three functions, fully annotated (every parameter and every return type), using the pipe syntax for the one that can return nothing:
- word_count(text) — how many whitespace-separated words are in a string
- longest(words) — the longest word in a list, or None when the list is empty
- tally(words) — a dict mapping each word to how many times it appears
# 🏁 MINI-CHALLENGE — write and annotate three functions
# 1. word_count(text) -> number of whitespace-separated words
# hint: str.split() with no arguments splits on any run of whitespace
# 2. longest(words) -> the longest word, or None for an empty list
# hint: max(words, key=len) picks the longest; guard the empty case first
# 3. tally(words) -> {word: how many times it appears}
#
# Annotate every parameter and every return type. Use str | None, not Optional[str].
# your code here
# ---- self-check: leave this at the bottom ----
for fn in (word_count, longest, tally):
print(f"{fn.__name__}: {fn.__annotations__}")
sample = "the quick brown foxes jumped".split()
print("word_count:", word_count("the quick brown fox"))
print("longest :", longest(sample))
print("longest([]):", longest([]))
print("tally :", tally(["a", "b", "a"]))
# ✅ Expected output:
# word_count: {'text': <class 'str'>, 'return': <class 'int'>}
# longest: {'words': list[str], 'return': str | None}
# tally: {'words': list[str], 'return': dict[str, int]}
# word_count: 4
# longest : jumped
# longest([]): None
# tally : {'a': 2, 'b': 1}
#
# (Before you write anything the self-check stops with
# NameError: name 'word_count' is not defined — that is your to-do list.)When it runs clean, save it on your own machine and finish the job with mypy --strict yourfile.py. Strict mode is the setting that refuses to let an unannotated function slip through, so it is the honest test of whether you annotated everything.
🎉 Conclusion
With mypy you can:
✔ Catch type bugs before runtime
✔ Adopt types gradually across a codebase
✔ Express intent with Optional, Union, and generics
✔ Inspect inference with reveal_type
✔ Enforce a high bar with strict mode
Type hints turn Python into a safer language without giving up its flexibility.
📋 Quick Reference — mypy
| Syntax | What it does |
|---|---|
| mypy app.py | Type-check a file |
| Optional[int] | int or None |
| reveal_type(x) | Report inferred type |
| # type: ignore | Silence one line |
| mypy --strict . | Strictest checking |
🎉 Great work! You've completed this lesson.
You can now use mypy to catch type bugs early and keep large Python codebases reliable.
Congratulations! You've reached the end of the advanced Python track.
Practice quiz
What kind of tool is mypy?
- A static type checker
- A code formatter
- A test runner
- A runtime debugger
Answer: A static type checker. mypy analyzes your annotated code without running it, reporting type errors statically before runtime.
How do you run mypy on a file called app.py?
- pip check app.py
- mypy --run app.py
- mypy app.py
- python app.py --types
Answer: mypy app.py. You invoke the checker directly: 'mypy app.py' (or 'mypy .' for a whole project).
What does 'gradual typing' mean?
- You must type everything at once
- You can add type hints incrementally, mixing typed and untyped code
- Types are inferred at runtime
- Types are checked slowly
Answer: You can add type hints incrementally, mixing typed and untyped code. Gradual typing lets you annotate code piece by piece; unannotated parts are treated as dynamic.
Which annotation means 'an int or None'?
- int & None
- Optional[int]
- Maybe[int]
- Nullable[int]
Answer: Optional[int]. Optional[int] is shorthand for Union[int, None]; in modern syntax you can also write 'int | None'.
In modern Python, how do you annotate a list of integers?
- list[int]
- List(int)
- array[int]
- ints[]
Answer: list[int]. Since Python 3.9 you can write list[int] directly; older code used typing.List[int].
What does the special function reveal_type(x) do under mypy?
- Raises an error
- Prints the value of x at runtime
- Converts x to a string
- Makes mypy report the inferred static type of x
Answer: Makes mypy report the inferred static type of x. reveal_type is a mypy helper: during a check it reports the inferred type; it is not meant to run normally.
What does the comment '# type: ignore' do?
- Tells mypy to skip type errors on that line
- Disables Python at that line
- Deletes the line
- Forces a runtime check
Answer: Tells mypy to skip type errors on that line. '# type: ignore' suppresses mypy errors on that specific line, used sparingly for unavoidable cases.
What does the Any type signify to mypy?
- A value that must be a string
- A value compatible with every type, effectively opting out of checking
- An empty value
- A list of anything
Answer: A value compatible with every type, effectively opting out of checking. Any is compatible with everything; assigning Any disables type checking for that value, so use it sparingly.
What is TypeVar used for?
- Declaring environment variables
- Ignoring type errors
- Importing types faster
- Writing generic functions and classes that preserve type relationships
Answer: Writing generic functions and classes that preserve type relationships. TypeVar defines a type variable so generics like 'def first(x: list[T]) -> T' keep the element type.
What is the main benefit of running mypy in strict mode before deployment?
- It compiles Python to C
- It formats your code
- It catches type-related bugs at check time instead of in production
- It makes the program run faster
Answer: It catches type-related bugs at check time instead of in production. Strict mode flags untyped code and likely type errors early, catching bugs before they reach runtime.
Continue this course
- Previous: Building APIs with FastAPI
- Quick reference: Python cheat sheet