What you'll learn
Quick Answer
Type hints are annotations such as def add(a: int, b: int) -> int. Python stores them and otherwise ignores them completely, so passing a string to an int parameter raises nothing at runtime. Their value comes from tools: mypy and your editor read the annotations and flag mismatches before you run the code. Use Optional or the pipe syntax for values that may be None, list[int] and dict[str, int] for containers, and TypedDict for dict shaped data.
Python does not check your type hints
Start with the fact that surprises most people who come to Python from Java or C++:
def add(a: int, b: int) -> int:
return a + b
print(add('Pu', 'ne')) # Pune
No exception. No warning. The annotation said int, the caller passed strings, and Python did what it always does, which is look for a __add__ method and use it. Type hints are not a runtime contract. They do not convert, validate or restrict anything.
What Python does is store them, so tools can read them later:
def greet(name: str) -> str:
return 'Namaste ' + name
print(greet.__annotations__)
# {'name': <class 'str'>, 'return': <class 'str'>}
That storage is the whole mechanism. A type checker such as mypy or the one built into your editor reads those annotations, follows the flow of values through your code, and tells you where the types cannot line up. That happens before you run anything, which is exactly when you want to hear about it.
The practical consequence is important for anyone building an API. If a request body arrives from the internet and your handler is annotated amount: int, that annotation gives you no protection at all. The JSON can contain the string "1000", or null, or a nested object, and your function will receive it. Validation is a separate job, done by an explicit check or by a library like pydantic that reads annotations and enforces them deliberately.
There is one common case where annotations do have runtime meaning, and it is worth knowing so you are not confused later: dataclass uses the class level annotations to decide what the fields are. Even there it only builds the fields, it does not validate their types.
The basics: parameters, returns and container types
The syntax is a colon after a parameter name and an arrow before the return type. Variables can be annotated too, though you usually only need that when the value alone is ambiguous:
def apply_discount(price: float, percent: float) -> float:
return round(price - price * percent / 100, 2)
city: str = 'Pune'
attempts: int = 0
Containers need to say what is inside them, because list on its own tells a checker almost nothing. Modern Python lets you subscript the built in types directly:
def total(marks: list[int]) -> int:
return sum(marks)
def roll_to_name(rows: dict[str, str]) -> list[str]:
return sorted(rows.values())
def midpoint(point: tuple[float, float]) -> float:
return (point[0] + point[1]) / 2
Version matters here. Subscripting the built in types in annotations, like list[int], works from Python 3.9 onwards. On older versions you either import the capitalised versions from typing as List[int], or add from __future__ import annotations at the top of the file, which makes Python store annotations as plain strings and never evaluate them. You will still meet the typing.List style in older codebases; it means the same thing.
A useful habit for beginners: annotate function signatures and leave local variables alone. The signature is the part other people read and the part a checker needs in order to follow types across your codebase. Annotating every local variable adds noise without adding information, because the checker can already infer total = 0 is an int.
For a function that returns nothing, the correct annotation is -> None. Writing no annotation at all is different: an unannotated function is invisible to a checker in its default mode, so it will not be inspected.
Optional, Union and the None that gets you
The single most valuable thing a type checker catches is a value that can be None being used as if it cannot. Optional[str] means "a str or None". The pipe syntax str | None means the same thing and works from Python 3.10 onwards:
from typing import Optional
def find_student(roll: str) -> Optional[str]:
students = {'21CS045': 'Asha'}
return students.get(roll) # returns None when missing
Once that return type is declared, the checker refuses to let you forget:
name = find_student('21CS099')
print(name.upper())
# mypy: Item "None" of "str | None" has no attribute "upper" [union-attr]
Recent mypy versions print the union with the pipe syntax even when you wrote Optional, and the exact wording of the message changes between releases, so match on the error code rather than the sentence. Run that code and you get AttributeError: 'NoneType' object has no attribute 'upper' at whatever hour your users find it. mypy tells you at your desk. This one check is worth more than every other annotation in a typical codebase, because "worked in testing, crashed on the record with a missing field" is the most common Python bug in production.
You satisfy the checker by narrowing: test for None and the checker understands that inside the if the value cannot be None.
name = find_student('21CS099')
if name is None:
raise ValueError('no such roll number')
print(name.upper()) # mypy is satisfied
Union[int, str], or int | str, means the value is one of several types, and the checker will only let you use operations valid for all of them until you narrow with isinstance. Use it sparingly. A function that accepts three unrelated types is usually two functions.
One historical trap: code that writes def f(x: int = None) is claiming x is an int while defaulting it to None. Older tools quietly treated that as Optional. Current mypy does not, and will flag it. Write x: Optional[int] = None.
TypedDict for dict shaped data
Real Python code passes dicts around constantly, especially anything touching JSON. Annotating that as dict[str, Any] tells a checker nothing useful. TypedDict lets you describe the specific keys and the type of each value:
from typing import TypedDict
class Payment(TypedDict):
order_id: str
amount_paise: int
captured: bool
def describe(p: Payment) -> str:
rupees = p['amount_paise'] / 100
return p['order_id'] + ': Rs ' + format(rupees, '.2f')
Now a typo is a checker error rather than a KeyError in production:
p: Payment = {'order_id': 'ord_1', 'amount_paise': 19900, 'captured': True}
print(p['amount_paisa'])
# mypy: TypedDict "Payment" has no key "amount_paisa"
By default every key is required, so a dict missing captured is an error. class Payment(TypedDict, total=False) makes them all optional, and NotRequired marks individual keys optional while the rest stay required. TypedDict itself arrived in Python 3.8 and NotRequired in 3.11; on older versions both come from the typing_extensions package.
Now the part that matters most. A TypedDict is still a plain dict at runtime. It is not a class, it has no methods, and it enforces nothing:
bad: Payment = {} # mypy complains
print(type(bad)) # <class 'dict'>
isinstance(bad, Payment) # TypeError: TypedDict does not support instance and class checks
So TypedDict is documentation your checker can verify, not a guard against bad input. If the dict came from a request body, a webhook or a file, you still need real validation. That is what pydantic and similar libraries are for: they use the same annotation syntax but actually check values and raise on mismatch.
from pydantic import BaseModel
class Payment(BaseModel):
order_id: str
amount_paise: int
Payment(order_id='ord_1', amount_paise='abc') # raises ValidationError
Running mypy, and how much typing is worth it
Annotations do nothing until something reads them. mypy is the standard tool and takes about a minute to start using:
pip install mypy
mypy app.py
mypy src/
By default mypy is deliberately gentle. It skips function bodies that have no annotations at all, which is what makes gradual typing possible: you can add hints to one module at a time without the whole project turning red. mypy --strict turns that off and demands annotations everywhere, which is the right setting for a new project and an unpleasant one to switch on for an old one.
A small config file keeps the command short and the noise low. Put this in mypy.ini at the project root:
[mypy]
warn_unused_ignores = True
warn_return_any = True
ignore_missing_imports = True
ignore_missing_imports is the setting most beginners actually need. Without it, importing a third party library that ships no type information produces an error about missing stubs, which has nothing to do with your code and buries the real findings.
When mypy is wrong, and it sometimes is, silence one line with a comment rather than deleting the annotation. Naming the error code keeps the silence narrow, so the line is still checked for everything else:
def fetch_rows() -> list[dict]:
return legacy_api.fetch() # type: ignore[no-any-return]
The code in the brackets has to be the one mypy actually reported on that line. no-any-return is the code for returning an Any from a function declared to return something specific, so it belongs on a return statement and nowhere else. Put it on a line that never produced that error and, with warn_unused_ignores switched on as above, mypy reports Unused "type: ignore" comment instead. Run mypy first, copy the code it prints, then add the comment.
How far should you take this? For a script you will run twice, skip it. For anything a second person will read, or any project you are putting on your CV, annotate the public functions of each module and leave the internals alone. The payoff is in the editor: with annotations present, autocomplete on your own objects works, renaming is safer, and mistakes surface as you type them.
The failure mode to avoid is annotating everything as Any to make the tool quiet. Any switches checking off for that value and everything it flows into, so a codebase full of it looks typed and catches nothing. If you genuinely do not know the type, object is more honest, because the checker will then force you to narrow before you use it.
