Python Type Hints Explained: Annotations, mypy and the Errors It Catches
Type hints let you say what kind of value a function expects and returns. Python ignores them at runtime, but a checker like mypy uses them to catch bugs before your code ever runs. We ran mypy on real examples to show what it finds.
- Type hints describe expected types, such as
def total(price: float) -> float. - Python does not check them at runtime. A type checker such as mypy or Pyright does.
- In our run, mypy found 9 errors in a short file, including a possible
Nonebug and a typo in a dictionary key. - Unannotated functions are not checked by default, so you can add hints gradually.
A function receives a string where it expected a number, and the program crashes in production, three calls away from where the mistake was made. Type hints exist to catch that class of bug earlier: in your editor, or in a CI check, before anyone runs the code.
We wrote a short file full of deliberate mistakes and checked it with mypy 2.4 on Python 3.13. Every error message below is copied from that run.
What are type hints?
Type hints, also called type annotations, are optional labels that describe the type of a variable, a parameter or a return value. They were introduced in Python 3.5 by PEP 484 and have been extended in almost every release since. They are written with a colon after a name and an arrow for the return type:
def apply_discount(price: float, percent: int) -> float:
return round(price * (1 - percent / 100), 2)
Anyone reading this knows what to pass and what comes back, without reading the body. Editors use the same information for autocomplete and inline warnings.
Hints are not enforced at runtime
This is the most important fact to understand. Python stores the annotations, but it does not check them when the code runs. Assigning the float result to a variable annotated as int ran without complaint. Passing the string "100" did fail, but only because multiplying a string by a float is impossible:
apply_discount("100", 10)
# TypeError: can't multiply sequence by non-int of type 'float'
That error came from the arithmetic, not from the hint. With a different body, the wrong type could have slipped through silently. Checking hints is the job of a separate tool. Libraries such as Pydantic do validate data against hints at runtime, which is useful at system boundaries like API inputs.
Basic annotations
| Hint | Meaning |
|---|---|
int, float, str, bool |
Simple built-in types |
list[str] |
A list of strings (Python 3.9+) |
dict[str, int] |
A dictionary from strings to integers |
tuple[int, str] |
A pair: an int then a str |
str | None |
A string or None (Python 3.10+) |
-> None |
The function returns nothing useful |
Older code uses List, Dict and Optional imported from typing. They still work, but the built-in forms are preferred in modern Python. Variables can be annotated too, as in total: int = 0, though most of the time the checker infers variable types for you.
Running mypy
Install it with pip install mypy and point it at a file or folder. Our file produced this summary:
$ mypy hints.py
hints.py:7: error: Incompatible types in assignment (expression has type "float", variable has type "int") [assignment]
hints.py:8: error: Argument 1 to "apply_discount" has incompatible type "str"; expected "float" [arg-type]
...
Found 9 errors in 1 file (checked 1 source file)
Each message gives the line, a plain description and an error code in brackets. The code is useful for silencing one specific check with a comment such as # type: ignore[arg-type], which is far safer than ignoring every check on that line. mypy exits with status 1 when it finds errors, so it can fail a CI pipeline just like a failing test.
Optional values and None
The most valuable thing a type checker does is remind you that something might be None:
def find_user(user_id: int) -> str | None:
return {1: "Asha"}.get(user_id)
name = find_user(2)
print(name.upper())
# error: Item "None" of "str | None" has no attribute "upper" [union-attr]
if name is not None:
print(name.upper()) # no error
At runtime, the first print would raise AttributeError for a missing user, which is exactly the kind of bug that hides until real data arrives. After the is not None check, mypy narrows the type to str and the error disappears. This narrowing also works with isinstance() and early returns.
Collections and inference
For an empty list or dictionary, the checker cannot guess what will go in it, so annotate it: orders: list[dict[str, float]] = []. For function parameters, prefer the most general type that works. Accepting Iterable[str] instead of list[str] lets callers pass a list, a tuple, a set or a generator, while still telling the checker that the items are strings.
TypedDict and Literal
Dictionaries from JSON often have a fixed set of keys. TypedDict describes them, and mypy then checks the keys:
class Order(TypedDict):
id: int
total: float
o: Order = {"id": 1, "totl": 9.5}
# error: Missing key "total" for TypedDict "Order" [typeddict-item]
# error: Extra key "totl" for TypedDict "Order" [typeddict-unknown-key]
A one-letter typo produced two clear errors. Literal restricts a value to a fixed set of options, like an enum for strings:
Status = Literal["paid", "pending"]
def set_status(s: Status) -> None: ...
set_status("refunded")
# error: Argument 1 to "set_status" has incompatible type "Literal['refunded']";
# expected "Literal['paid', 'pending']" [arg-type]
These two are where hints pay off fastest in real code, because typos in keys and status strings are common and tests do not always cover them.
Protocols: duck typing with checks
Python code often cares only that an object has the right methods, not what class it is. A Protocol describes that shape:
class Notifier(Protocol):
def send(self, msg: str) -> bool: ...
class Email:
def send(self, msg: str) -> bool: return True
class Broken:
def send(self, msg: int) -> bool: return True
alert(Email()) # fine, no inheritance needed
alert(Broken())
# error: Argument 1 to "alert" has incompatible type "Broken"; expected "Notifier"
# note: Expected: def send(self, msg: str) -> bool
# note: Got: def send(self, msg: int) -> bool
Email never mentions Notifier, yet it is accepted because its send method matches. This is structural typing, the same idea TypeScript uses, as our interface vs type guide shows. Protocols also make testing easier, because a simple fake object with the right methods satisfies the type, which pairs well with mocking in Python.
Generics
A generic function keeps the connection between input and output types:
T = TypeVar("T")
def first(items: Iterable[T]) -> T: ...
reveal_type(first([1, 2, 3])) # note: Revealed type is "int"
reveal_type(first(["a"])) # note: Revealed type is "str"
reveal_type() is a special helper that only the checker understands; it prints what mypy thinks a type is, which is handy when an error is confusing. Python 3.12 added a shorter syntax, def first[T](items: Iterable[T]) -> T, which means the same thing.
Callable: typing functions you pass around
def run(cb: Callable[[int], str]) -> str:
return cb(5)
run(lambda n: n * 2)
# error: Argument 1 to "run" has incompatible type "Callable[[int], int]";
# expected "Callable[[int], str]" [arg-type]
The callback returned a number where a string was required. Callbacks are a common source of mismatches, especially with lambda functions, whose types are inferred silently.
Gradual typing and strict mode
We left one deliberate mistake unflagged:
def untyped(x):
return x + 1
untyped("oops")
By default mypy treats an unannotated function as returning and accepting Any, so it did not report this call. That is intentional. It lets you add hints to an existing project a little at a time without a flood of errors. Running mypy --strict changed the result:
hints.py:64: error: Function is missing a type annotation [no-untyped-def]
hints.py:66: error: Call to untyped function "untyped" in typed context [no-untyped-call]
Strict mode is a good goal for new code and libraries. For older code, turn on individual strict options one at a time in your configuration.
Tip: Treat Any as a hole in your safety net. Every Any, whether written by you or inferred from an unannotated library, switches off checking for that value. Prefer object when you genuinely accept anything, since mypy then forces you to check the type before using it.
Adopting hints in an existing project
- Start at the edges. Annotate public functions, API handlers and data models first; they give the biggest return.
- Add mypy to CI with a config file, for example a
[tool.mypy]section inpyproject.toml, so new errors fail the build. - Use per-module settings to apply stricter rules to new packages while legacy ones catch up.
- Install type stubs for libraries that need them, such as
types-requests. - Model your data with TypedDict or dataclasses instead of loose dictionaries.
Which type checker?
| Tool | Notes |
|---|---|
| mypy | The original checker, widely used in CI, highly configurable |
| Pyright | Fast, powers VS Code’s Pylance extension |
| Newer Rust-based checkers | Very fast; check their maturity for your project |
Different checkers sometimes disagree on edge cases, so pick one for CI and let your editor use whichever it ships with. Only mypy was run for this article.
Final thoughts
Type hints are documentation that a tool can verify. They cost a few characters per function and catch real bugs: values that might be None, misspelt keys, wrong callback shapes and unexpected status strings. Start with the functions other people call, run mypy in CI, and tighten the settings as coverage grows.
Frequently asked questions
Does Python enforce type hints?
No. Python stores annotations but does not check them at runtime. A static type checker such as mypy or Pyright checks them, and libraries such as Pydantic can validate data at runtime.
What is mypy?
mypy is a static type checker for Python. It reads your type hints and reports mismatches, such as passing a string where a number is expected, without running the code.
What is the difference between Optional[str] and str | None?
They mean the same thing: a string or None. str | None is the newer syntax, available from Python 3.10.
Do type hints make Python faster?
No. Standard Python ignores them at runtime, so performance is unchanged. Their value is catching bugs and improving editor support.
Why does mypy not report errors in my function?
Functions without annotations are treated as Any and are not checked by default. Add annotations or run mypy with –strict or –check-untyped-defs.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.