Type Checking with mypy
In Type Hints you learned to write hints such as list[int] and str | None. Hints on their own change nothing: Python ignores them when it runs. Their value appears when a type checker reads your code without running it and points out lines that will crash or misbehave. Many companies run a type checker on every change, next to the tests, and treat its errors like failing tests.
This lesson uses mypy, the original Python type checker, but the ideas work the same in pyright (which powers VS Code's Python support) and ty, a newer checker from the makers of uv and ruff. You will run mypy, fix the errors it finds, model real data more precisely with TypedDict, Literal and Final, and switch on strict mode for new code.
Running mypy
Install it as a development tool (uv add --dev mypy, or python -m pip install mypy in a virtual environment). Here is a file with three bugs. Python would only hit them when the bad line actually runs, perhaps months later, on a customer's data:
def find_phone(contacts: dict[str, str], name: str) -> str | None:
return contacts.get(name)
def sms_header(contacts: dict[str, str], name: str) -> str:
phone = find_phone(contacts, name)
return "To: +91 " + phone.replace(" ", "")
def total_fees(fees: list[int]) -> int:
return sum(fees) / len(fees)
print(sms_header({"Isha": "99990 54321"}, "Isha"))
print(total_fees("1200"))mypy contacts.pycontacts.py:7: error: Item "None" of "str | None" has no attribute "replace" [union-attr] contacts.py:11: error: Incompatible return value type (got "float", expected "int") [return-value] contacts.py:15: error: Argument 1 to "total_fees" has incompatible type "str"; expected "list[int]" [arg-type] Found 3 errors in 1 file (checked 1 source file)
Each error gives the file, the line, a message and an error code in square brackets. Read them as questions: "What if find_phone() returns None?" "/ always gives a float, so is int really the right return type?" "Why is a string being passed where a list is expected?" Every one is a real bug, found without running anything.
Narrowing: proving a value is safe
The first error is the most common one you will see. phone has the type str | None, so calling a string method on it is not safe. You fix it by narrowing the type: an if check after which the checker knows which type remains.
def find_phone(contacts: dict[str, str], name: str) -> str | None:
return contacts.get(name)
def sms_header(contacts: dict[str, str], name: str) -> str:
phone = find_phone(contacts, name)
if phone is None:
raise KeyError(f"no phone number saved for {name}")
return "To: +91 " + phone.replace(" ", "") # phone is a str here
def average_fee(fees: list[int]) -> float:
return sum(fees) / len(fees)
print(sms_header({"Isha": "99990 54321"}, "Isha"))
print(average_fee([1200, 1500, 900]))To: +91 9999054321 1200.0
mypy now reports Success: no issues found. The checker understands is None, is not None, isinstance(), truthiness checks such as if phone:, early returns and raise. To see what the checker thinks a type is at some line, write reveal_type(phone); mypy prints a note such as Revealed type is "str". Remove these lines afterwards.
Precise types for real data
Data from JSON files and web APIs usually arrives as dictionaries. dict[str, Any] tells the checker nothing about the keys. A TypedDict describes the exact keys and the type of each value. Add Literal for a value that may only be one of a few fixed strings, and Final for a constant that must never be reassigned:
from typing import Final, Literal, NotRequired, TypedDict
GST_RATE: Final = 18
type Status = Literal["placed", "packed", "shipped", "delivered"]
class Order(TypedDict):
id: int
customer: str
amount: int
status: Status
coupon: NotRequired[str]
def with_gst(order: Order) -> float:
return order["amount"] * (100 + GST_RATE) / 100
raw: Order = {"id": 101, "customer": "Rohan", "amount": 1299, "status": "placed"}
print(with_gst(raw))
print(type(raw).__name__, Order.__required_keys__ == {"id", "customer", "amount", "status"})
print(raw.get("coupon", "no coupon"))1532.82 dict True no coupon
At runtime an Order is a plain dict, so it costs nothing and works with json.loads() directly. NotRequired marks a key that may be missing. The checker, however, now catches typos and wrong values. Adding these three lines to the file:
bad: Order = {"id": 102, "customer": "Ananya", "amount": 499, "status": "dispatched"}
raw["amout"] = 1500
GST_RATE = 12orders.py:24: error: Incompatible types (expression has type "Literal['dispatched']", TypedDict item "status" has type "Literal['placed', 'packed', 'shipped', 'delivered']") [typeddict-item] orders.py:25: error: TypedDict "Order" has no key "amout" [typeddict-unknown-key] orders.py:25: note: Did you mean "amount"? orders.py:26: error: Cannot assign to final name "GST_RATE" [misc] Found 3 errors in 1 file (checked 1 source file)
A TypedDict only describes data; it does not check data arriving from outside at runtime. For that, validate the values yourself (see Secure Coding Basics) or use a validation library.
Making sure every case is handled
Literal types combine well with match and assert_never(). If someone later adds "netbanking" to the Payment type but forgets to handle it, the checker reports the forgotten case:
from typing import Literal, assert_never
type Payment = Literal["upi", "card", "cash", "netbanking"]
def convenience_fee(method: Payment, amount: int) -> float:
match method:
case "upi" | "cash":
return 0
case "card":
return round(amount * 0.02, 2)
case _:
assert_never(method)pay.py:13: error: Argument 1 to "assert_never" has incompatible type "Literal['netbanking']"; expected "Never" [arg-type]
If the code ran anyway with "netbanking", assert_never() would raise AssertionError: Expected code to be unreachable, but got: 'netbanking' instead of quietly returning a wrong fee.
Configuring mypy and strict mode
Put the settings in pyproject.toml, so every developer and the build server use the same ones:
[tool.mypy]
python_version = "3.14"
files = ["src", "tests"]
strict = true
[[tool.mypy.overrides]]
module = ["old_reports.*"]
strict = falseNow a plain mypy command checks the listed folders. One surprise first: by default, mypy skips functions without any hints. This old function passes the default check, but --strict reports it:
import json
def load_rates(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def price_with_gst(price: float, rate) -> float:
return price * (100 + rate) / 100legacy.py:4: error: Function is missing a type annotation [no-untyped-def] legacy.py:9: error: Function is missing a type annotation for one or more parameters [no-untyped-def] legacy.py:10: error: Returning Any from function declared to return "float" [no-any-return] Found 3 errors in 1 file (checked 1 source file)
Strict mode turns on a group of extra checks: every function must be annotated, Any may not leak out silently, and unused # type: ignore comments are reported. Use it for new projects from day one. For a large old codebase, add types gradually: start with default settings, annotate the most important modules, then make strict mode apply to one package at a time with overrides, as above.
Escape hatches, used carefully
Sometimes you know more than the checker. Three tools exist for that, and each one hides possible bugs, so use them rarely and leave a comment:
# type: ignore[arg-type]at the end of a line silences one error code on that line. Always include the code in brackets.typing.cast(str, value)tells the checker "treat this as astr". It does nothing at runtime.Anyswitches checking off for a value completely.
Third-party libraries without hints are another source of Any. Many libraries ship their own hints; for others, install a stub package, such as types-requests, which contains only the type information.
Common mistakes
- Running the checker once and never again. Add it to your pre-push checks (see Project Workflow with uv & ruff), or new errors creep back.
- Silencing errors instead of fixing them. Most
union-attrerrors are realNonebugs; add a check. - Thinking a passing check means correct code. The checker proves types fit, not that the logic is right. You still need tests.
- Expecting
TypedDictorLiteralto validate JSON at runtime. They only inform the checker. - Forgetting that unannotated functions are skipped. Without strict mode or
check_untyped_defs = true, mypy ignores their bodies.
Key points
- A type checker reads code without running it and reports type errors with a line number and an error code.
- Narrow
X | Nonewithis Nonechecks,isinstance()or early returns before using the value. TypedDictdescribes dictionary keys,Literalfixes allowed values,Finalprotects constants.assert_never()in the lastcasemakes the checker report unhandledLiteralor enum values.- Configure mypy in
[tool.mypy]; usestrict = truefor new code and add types to old code gradually. - Use
# type: ignore[code],cast()andAnyrarely, and never as a habit.
Exercise
Run mypy in your head (or for real) on this function and list every problem it would report. Then rewrite it with correct hints, a Literal type for the seat class, and a narrowing check, so that mypy in strict mode reports no errors.
FARES = {"sleeper": 450, "3ac": 1150, "2ac": 1650}
def ticket_price(seat_class, passengers, discount=None):
price = FARES.get(seat_class) * passengers
if discount:
price = price - price * discount / 100
return priceShow solution
Strict mode first reports the missing annotations. Once hints are added, it reports two more bugs: FARES.get() may return None, which cannot be multiplied, and price starts as an int but is later given a float. One correct version:
from typing import Literal
type SeatClass = Literal["sleeper", "3ac", "2ac"]
FARES: dict[SeatClass, int] = {"sleeper": 450, "3ac": 1150, "2ac": 1650}
def ticket_price(
seat_class: SeatClass, passengers: int, discount: float | None = None
) -> float:
fare = FARES.get(seat_class)
if fare is None:
raise ValueError(f"unknown seat class: {seat_class}")
price: float = fare * passengers
if discount is not None:
price -= price * discount / 100
return price
print(ticket_price("3ac", 2))
print(ticket_price("sleeper", 4, discount=10))2300 1620.0
The Literal type lets the checker reject a call such as ticket_price("first", 1). Writing if discount is not None instead of if discount also treats a discount of 0 correctly and makes the intent clear.
Quick Quiz
1. What does mypy do with your program?
Type checkers analyse code statically; Python itself still ignores hints when running.
2. phone has type str | None. Which line lets the checker accept phone.upper() afterwards?
After the early return, only str remains, so the type is narrowed.
3. What is a TypedDict at runtime?
TypedDict only informs the checker; the object is an ordinary dictionary.
4. Why does mypy report nothing for an old file full of unannotated functions?
Use --strict or check_untyped_defs = true to check those bodies too.
Learn Python with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
