Python Type Hints
Learn Python type hints: annotating variables, functions, and classes, using the typing module, and checking types with mypy.
Type hints let you attach expected type information to variables, function parameters, and return values. Python does not enforce them at runtime — they are metadata consumed by editors, linters, and type-checkers such as mypy to catch bugs before you run a single line.
This chapter covers:
- Why type hints matter and when to use them
- Annotating variables and functions
- Built-in types and the
typingmodule (List,Dict,Optional,Union,Tuple,Any,Callable) - Modern syntax (Python 3.10+)
- Annotating classes and
self - Generics and type aliases
- Static analysis with mypy
- Common gotchas
Why Type Hints?
Python is dynamically typed: a variable can hold any value of any type. That flexibility is powerful, but it makes large codebases harder to navigate — you cannot know the type of a function's argument just by reading the call site.
Type hints solve this without giving up Python's dynamism:
- Editors surface errors immediately. VS Code, PyCharm, and others underline type mismatches as you type.
- Refactoring becomes safer. Change a function signature and the type-checker tells you every call site that breaks.
- Code is self-documenting.
def greet(name: str) -> strcommunicates the contract without a docstring. - Libraries become easier to use. Typed libraries expose autocomplete for every attribute and method.
Type hints were introduced in Python 3.5 via PEP 484. The syntax has been refined in every major release since. The examples below note the minimum Python version where the syntax first became available.
Annotating Variables
Add a colon after the variable name followed by the type:
name: str = "Alice"
age: int = 30
price: float = 9.99
is_active: bool = TrueYou can also declare a variable's type without assigning a value yet. This is called a forward declaration and is useful inside classes or at module level:
user_id: int # declared but not yet assigned
user_id = 42Type annotations on module-level variables do not affect runtime behavior — they are stored in the module's __annotations__ dictionary but otherwise ignored by the interpreter.
Annotating Functions
Place annotations on parameters (after the colon) and on the return value (after -> before the colon that ends the signature):
def add(a: int, b: int) -> int:
return a + b
def greet(name: str) -> str:
return f"Hello, {name}!"
def send_email(to: str, subject: str, body: str) -> None:
print(f"Sending '{subject}' to {to}")
result: int = add(3, 5)
message: str = greet("Alice")-> None means the function has no meaningful return value (it returns None implicitly). Omitting the return annotation is also valid, but explicit -> None makes the intent clear.
Default Parameters
Default values go after the annotation:
def connect(host: str, port: int = 8080, secure: bool = False) -> None:
print(f"Connecting to {host}:{port} (secure={secure})")
connect("example.com") # uses defaults
connect("example.com", 443, True)*args and **kwargs
Annotate the element type, not the collection type:
def total(*prices: float) -> float:
return sum(prices)
def create_user(**fields: str) -> dict:
return fields
print(round(total(9.99, 4.50, 12.00), 2)) # 26.49
print(create_user(name="Bob", role="admin"))*prices: float means each positional argument is a float; at runtime prices is still a regular tuple of floats. Similarly, **fields: str means each keyword argument's value is a str.
The typing Module
For anything beyond the basic built-in types, import from the typing module (Python 3.5+). Starting with Python 3.9, many typing types were merged directly into the built-in equivalents (see Modern Syntax below).
List, Tuple, Set, Dict
from typing import List, Tuple, Set, Dict
def first_names(users: List[str]) -> str:
return users[0] if users else ""
def dimensions() -> Tuple[int, int, int]:
return (1920, 1080, 32)
def unique_tags(items: List[str]) -> Set[str]:
return set(items)
def word_count(text: str) -> Dict[str, int]:
counts: Dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
print(first_names(["Alice", "Bob"])) # Alice
print(dimensions()) # (1920, 1080, 32)
print(unique_tags(["py", "web", "py"])) # {'py', 'web'}
print(word_count("one two one")) # {'one': 2, 'two': 1}Optional
Optional[X] is shorthand for Union[X, None]. Use it whenever a value may be absent:
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
db = {1: "Alice", 2: "Bob"}
return db.get(user_id) # returns None if not found
name = find_user(1)
if name is not None:
print(name.upper()) # ALICE
missing = find_user(99)
print(missing) # NoneA type-checker sees Optional[str] and knows you must check for None before calling string methods on the result. Without the check, it reports an error.
Union
Union[X, Y] means the value can be either type X or type Y:
from typing import Union
def stringify(value: Union[int, float, str]) -> str:
return str(value)
print(stringify(42)) # 42
print(stringify(3.14)) # 3.14
print(stringify("hi")) # hiUnion is most useful when a function genuinely accepts multiple unrelated types. If you find yourself writing Union[str, None], use Optional[str] instead — it is more idiomatic.
Callable
Callable[[ArgTypes...], ReturnType] annotates a function that is passed as an argument:
from typing import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
def double(n: int) -> int:
return n * 2
print(apply_twice(double, 3)) # 12Callable[[int], int] means: a callable that takes one int argument and returns an int. If the argument list is complex or unknown, use Callable[..., ReturnType].
Any
Any is a special type that disables type-checking for that value. Every type is both assignable to Any and assignable from Any:
from typing import Any
def log(value: Any) -> None:
print(value)
log(42)
log("hello")
log([1, 2, 3])Use Any sparingly — it is an escape hatch that removes the very protection type hints provide. It is appropriate when interfacing with untyped third-party code, or during a gradual migration of a large codebase.
Modern Syntax (Python 3.9+, 3.10+)
Built-in generics (Python 3.9+)
From Python 3.9 onwards you can use the built-in types directly as generics, without importing from typing:
# Python 3.9+
def word_count(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
def first(items: list[int]) -> int | None:
return items[0] if items else None
print(word_count("cat dog cat")) # {'cat': 2, 'dog': 1}
print(first([10, 20, 30])) # 10
print(first([])) # NoneUse list[str] instead of List[str], dict[str, int] instead of Dict[str, int], and so on.
X | Y union syntax (Python 3.10+)
Python 3.10 introduced the | operator for unions, replacing Union[X, Y] and Optional[X]:
# Python 3.10+
def parse(value: str | int | None) -> str:
if value is None:
return "nothing"
return str(value)
print(parse("hello")) # hello
print(parse(42)) # 42
print(parse(None)) # nothingstr | None is equivalent to Optional[str]. This syntax is cleaner and easier to read.
Annotating Classes
Annotate instance attributes inside __init__, and add return annotations to methods:
class BankAccount:
owner: str # class-level annotation (no default value)
balance: float
def __init__(self, owner: str, initial_balance: float = 0.0) -> None:
self.owner = owner
self.balance = initial_balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self.balance += amount
def withdraw(self, amount: float) -> bool:
if amount > self.balance:
return False
self.balance -= amount
return True
def __repr__(self) -> str:
return f"BankAccount(owner={self.owner!r}, balance={self.balance:.2f})"
account = BankAccount("Alice", 100.0)
account.deposit(50.0)
print(account.withdraw(30.0)) # True
print(account) # BankAccount(owner='Alice', balance=120.00)The annotation on self is always inferred — you never write self: BankAccount. The return type of __init__ is always None.
ClassVar
Use ClassVar[T] (from typing) to mark an attribute that belongs to the class, not each instance:
from typing import ClassVar
class Config:
MAX_RETRIES: ClassVar[int] = 3
timeout: int
def __init__(self, timeout: int) -> None:
self.timeout = timeout
print(Config.MAX_RETRIES) # 3A type-checker warns if you try to set ClassVar on an instance — it is intended to be shared at the class level.
Type Aliases
A type alias gives a long or complex type a shorter, more meaningful name:
from typing import List, Tuple
# Simple alias
UserID = int
Filename = str
# Structured alias
Coordinates = Tuple[float, float]
Matrix = List[List[float]]
def distance(p1: Coordinates, p2: Coordinates) -> float:
return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5
print(distance((0.0, 0.0), (3.0, 4.0))) # 5.0From Python 3.12, use the type statement for explicit, inspectable aliases:
# Python 3.12+
type Vector = list[float]
type Matrix = list[Vector]Generics with TypeVar
TypeVar lets you write a single function that works with any type while still preserving type relationships:
from typing import TypeVar, List
T = TypeVar("T")
def first_item(items: List[T]) -> T:
return items[0]
x: int = first_item([1, 2, 3]) # x is int
s: str = first_item(["a", "b"]) # s is strThe type-checker infers from the argument what T is, and carries that information through to the return type. Without TypeVar, you would have to return Any and lose type safety.
You can constrain TypeVar to a set of allowed types:
from typing import TypeVar
Numeric = TypeVar("Numeric", int, float)
def double(n: Numeric) -> Numeric:
return n * 2
print(double(4)) # 8 (int)
print(double(2.5)) # 5.0 (float)Static Type Checking with mypy
mypy is the most widely used static type-checker for Python. Install it with pip:
pip install mypyThen run it on a file:
mypy my_script.pyExample: catching a bug with mypy
Save the following as demo.py:
def greet(name: str) -> str:
return f"Hello, {name}!"
result = greet(42) # passing int instead of str
print(result.upper())Running mypy demo.py reports:
demo.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)Python itself runs the code fine (f-strings coerce any type), but mypy caught the mismatch before you had to discover it in production.
Useful mypy options
| Flag | Effect |
|---|---|
--strict | Enable all optional checks (recommended for new projects) |
--ignore-missing-imports | Suppress errors about third-party stubs |
--check-untyped-defs | Also type-check functions without annotations |
--disallow-untyped-defs | Require annotations on all function definitions |
A mypy.ini (or [tool.mypy] in pyproject.toml) keeps configuration out of the command line:
[mypy]
strict = true
ignore_missing_imports = trueGradual Typing
You do not have to annotate every function at once. Python supports gradual typing: annotated and unannotated code coexists peacefully. mypy skips unannotated functions by default (unless --check-untyped-defs is set).
A practical approach for an existing codebase:
- Add annotations to new code from day one.
- Annotate the most-called or most-error-prone functions first.
- Enable
--strictmodule by module as coverage improves. - Use
Anyonly where a third-party library is untyped, and add a comment explaining why.
Common Gotchas
Forward references
If a type refers to a class that is defined later in the same file, wrap the name in quotes to make it a string (a forward reference):
class Node:
def __init__(self, value: int, next: "Node | None" = None) -> None:
self.value = value
self.next = next
head = Node(1, Node(2))
print(head.value, head.next.value) # 1 2From Python 3.10+, add from __future__ import annotations at the top of the file instead. This makes all annotations lazy strings and eliminates the need for manual quoting.
Annotations at runtime
By default, annotations in Python 3.9 and earlier are evaluated eagerly. That means a forward reference without quotes raises a NameError:
# Works (with quotes):
def clone(self: "MyClass") -> "MyClass": ...With from __future__ import annotations (Python 3.7+), all annotations are stored as strings and evaluated only when inspected — solving the forward-reference problem automatically.
None vs Optional
A common mistake is annotating a return type as str when the function can actually return None. Always use Optional[str] (or str | None) when None is a possible return:
from typing import Optional
# Wrong — mypy will flag callers that assume this is always str
def get_name(user_id: int) -> str:
if user_id == 0:
return None # type: ignore — this is the bug
# Correct
def get_name_safe(user_id: int) -> Optional[str]:
if user_id == 0:
return None
return "Alice"list vs List (version compatibility)
If your code runs on Python 3.8 or earlier, you must use from typing import List and write List[str]. On Python 3.9+, list[str] works directly. If you need to support both, either use the typing imports or add from __future__ import annotations.
Quick Reference
| Annotation | Meaning |
|---|---|
x: int | Variable x is an integer |
def f(a: str) -> bool | Parameter a is str; return value is bool |
-> None | Function returns nothing meaningful |
Optional[str] | str or None |
Union[int, str] | int or str |
list[int] / List[int] | List of integers |
dict[str, int] / Dict[str, int] | Dict mapping str to int |
tuple[int, str] / Tuple[int, str] | Tuple of (int, str) |
Callable[[int], str] | Function taking int, returning str |
Any | Any type (disables checking) |
ClassVar[T] | Class-level attribute |
TypeVar("T") | Generic type variable |
Related Topics
- Python Functions — where type annotations on parameters and return types live.
- Python Classes and Objects — for annotating
__init__, methods, and class attributes. - Python Dataclasses — type annotations are required to declare dataclass fields.
- Python Abstract Classes — abstract base classes work naturally with type hints.