
Type Hints in Python: A Practical Guide to Static Typing
Python is dynamically typed, and that's a big part of why it's pleasant to write. You don't declare types, you just use values. The downside shows up as a codebase grows: you open a function you wrote six months ago and have no idea whether user is a dict, an ID, or an object, or whether the function can return None. You find out at runtime, often in production.
Type hints are Python's answer. They let you write down what types your functions expect and return, using ordinary syntax, without changing how the code runs. Editors use them for autocompletion and inline errors, and static type checkers like mypy and Pyright use them to catch whole classes of bugs before you run anything.
This guide covers the practical core: annotating functions and variables, collections, optional values and unions, callables, type aliases, classes, the escape hatches (Any, cast, object), and how to add hints to an existing project gradually. Everything here targets Python 3.13, with a couple of clearly labeled notes on 3.14. Generics, protocols, and TypedDict get their own post, as does configuring a type checker.
What Type Hints Are (and Aren't)
A type hint is an annotation attached to a parameter, return value, or variable:
# greet.py
def greet(name: str, excited: bool = False) -> str:
suffix = "!" if excited else "."
return f"Hello, {name}{suffix}"
print(greet("Ada"))
print(greet(42)) # runs fine at runtime
print(greet.__annotations__)
Output:
Hello, Ada.
Hello, 42.
{'name': <class 'str'>, 'excited': <class 'bool'>, 'return': <class 'str'>}
The second call passes an int where a str was declared, and Python doesn't care. The interpreter doesn't enforce type hints. It stores them in __annotations__ and moves on. That's deliberate: hints are for tools and for humans, and they cost nothing at runtime.
The enforcement happens in a separate step. Run mypy on the same file:
mypy greet.py
greet.py:8: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Pyright (which powers Pylance in VS Code) reports the same problem, and your editor underlines it as you type. That's the workflow: write hints, let a checker verify them. I'll stick to the annotations themselves here; Static Type Checking in Python with mypy and Pyright covers installing, configuring, and running the checkers.
Some libraries do read annotations at runtime on purpose. Pydantic, FastAPI, dataclasses, and Typer all use them to build validation, serialization, or CLIs. But that's those libraries choosing to use the information, not Python enforcing it.
Annotating Functions
Functions are where type hints pay off most. Annotate every parameter and the return type:
def calculate_total(prices: list[float], tax_rate: float = 0.2) -> float:
subtotal = sum(prices)
return round(subtotal * (1 + tax_rate), 2)
Some guidelines:
- Parameters with defaults put the annotation before the default:
tax_rate: float = 0.2. - Functions that return nothing should be annotated
-> None. It documents intent, and it means "this function is checked" for tools that skip unannotated functions. *argsand**kwargsare annotated with the type of each value:*args: intmeans every positional argument is anint, and**kwargs: strmeans every keyword value is astr.
def log(message: str, *tags: str, **fields: int) -> None:
print(message, tags, fields)
You usually don't need to annotate local variables inside functions. Checkers infer them from the assigned value: after subtotal = sum(prices), they already know subtotal is a float.
Annotating Variables
Annotate a variable when inference can't figure it out, or when you want to be explicit about a module-level value:
scores: list[int] = [90, 85, 77]
prices: dict[str, float] = {"mug": 12.5}
count: int | None = None
The case where you genuinely need an annotation is an empty collection. A checker sees cache = {} and has no idea what will go in it:
error: Need type annotation for "cache" (hint: "cache: dict[<type>, <type>] = ...") [var-annotated]
Writing cache: dict[str, int] = {} fixes it.
Built-in Types and Collections
Since Python 3.9 you can use the built-in collection types directly in annotations, with the element types in square brackets:
| Annotation | Meaning |
|---|---|
list[int] | A list of ints |
dict[str, float] | Dict with str keys and float values |
set[str] | A set of strings |
tuple[float, float] | Exactly two floats |
tuple[str, ...] | Any number of strings |
frozenset[int] | A frozenset of ints |
Older code imports List, Dict, Tuple, and friends from typing. Those still work but are deprecated aliases; prefer the lowercase built-ins in new code.
Note the tuple asymmetry. A list is assumed to be homogeneous, so list[int] takes one type. A tuple is often a fixed-size record, so tuple[float, float] lists each position, and you add ... for "variable length, all the same type".
Accept Abstract Types, Return Concrete Ones
For parameters, it's usually better to accept the most general type that works. If your function only loops over its input, it doesn't need a list; any iterable will do. The abstract types live in collections.abc:
# stats.py
from collections.abc import Iterable, Mapping, Sequence
def average(values: Iterable[float]) -> float:
total = 0.0
n = 0
for v in values:
total += v
n += 1
return total / n if n else 0.0
def first_word(lines: Sequence[str]) -> str:
return lines[0].split()[0]
def total_price(cart: Mapping[str, int], prices: Mapping[str, float]) -> float:
return sum(prices[item] * qty for item, qty in cart.items())
print(average([1, 2, 3]))
print(average(x * 0.5 for x in range(4)))
Output:
2.0
0.75
average now accepts lists, tuples, sets, and generators. Which abstract type to pick depends on what you do with the argument:
| You need to... | Annotate with |
|---|---|
| Loop over it once | Iterable[T] |
Index it or call len() | Sequence[T] |
| Read keys and values | Mapping[K, V] |
| Modify it in place | list[T], dict[K, V], or MutableSequence[T]/MutableMapping[K, V] |
For return types, be concrete: if you return a list, say list[T], so callers know they can append to it.
You might wonder why average([1, 2, 3]) is fine when the list holds ints, not floats. The typing rules treat int as acceptable wherever float is expected, as a convenience, since ints convert cleanly.
Optional Values and Unions
Use | to say a value can be one of several types. The most common case is "this or None":
def find_user(user_id: int, users: dict[int, str]) -> str | None:
return users.get(user_id)
name = find_user(1, {1: "ada"})
print(name.upper())
mypy flags the last line:
error: Item "None" of "str | None" has no attribute "upper" [union-attr]
This is one of the most valuable things a type checker does. "Forgot to handle None" is a very common Python bug, and once the return type says | None, the checker won't let you forget. The fix is to narrow the type:
if name is not None:
print(name.upper()) # the checker knows name is str here
Checkers understand is None / is not None checks, isinstance(), truthiness tests, early returns, and match statements, and narrow the type inside each branch accordingly.
Optional[str] and Union[str, int] from the typing module mean the same as str | None and str | int. You'll see them in older code; the | syntax (Python 3.10+) is the modern form.
A word on naming: an "optional" type is not the same as an optional argument. def f(x: int | None) still requires you to pass x; it just allows None. An argument becomes optional by having a default value.
Callables
When a parameter is a function, annotate it with Callable[[ArgTypes...], ReturnType] from collections.abc:
from collections.abc import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
print(apply_twice(lambda x: x * 3, 2)) # 18
Callable[[int], int] means "takes one int, returns an int". Use Callable[..., int] when you don't want to constrain the arguments. For callbacks with keyword arguments or overloaded signatures, Callable gets awkward; a Protocol with a __call__ method is the more expressive tool, covered in the advanced post.
Generators
Annotate generator functions with Iterator[T] (or Generator[YieldType, SendType, ReturnType] if you use send() or a return value):
from collections.abc import Iterator
def countdown(n: int) -> Iterator[int]:
while n > 0:
yield n
n -= 1
Type Aliases
Long or repeated types are easier to read with a name. Python 3.12 added the type statement for this:
from collections.abc import Callable
type UserId = int
type Handler = Callable[[str], None]
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
def lookup(uid: UserId) -> str:
return f"user-{uid}"
The JSON alias refers to itself, which the type statement supports directly because the value is evaluated lazily. On Python versions before 3.12, you'd write a plain assignment (UserId = int), optionally annotated as TypeAlias from typing.
An alias is just another name for the same type. UserId and int are interchangeable, so the checker won't stop you passing an order ID where a user ID is expected. If you want that protection, use typing.NewType("UserId", int), which creates a distinct type for the checker with no runtime cost.
Literal and Final
Literal restricts a value to specific constants. It's ideal for mode flags and other small sets of allowed strings:
from typing import Final, Literal
type Mode = Literal["r", "w", "a"]
def open_log(path: str, mode: Mode = "a") -> None:
print(f"opening {path} in {mode!r} mode")
open_log("app.log", "x") # error: expected "Literal['r', 'w', 'a']"
MAX_RETRIES: Final = 3
MAX_RETRIES = 4 # error: Cannot assign to final name "MAX_RETRIES"
Final marks a name that shouldn't be reassigned, the closest Python gets to a constant. Like everything else here, it's enforced by the checker, not at runtime. For larger fixed sets of values, an Enum is often a better fit than Literal, since it gives you a real runtime object to work with.
Annotating Classes
Instance attributes are usually inferred from __init__, provided its parameters are annotated. Always annotate __init__ with -> None:
from typing import Self
class Counter:
def __init__(self) -> None:
self.value = 0 # inferred as int
def increment(self, by: int = 1) -> Self:
self.value += by
return self
c = Counter().increment().increment(5)
print(c.value) # 6
Self (from typing, Python 3.11+) means "an instance of whatever class this method is called on", which matters for subclasses. A subclass of Counter calling increment() gets its own type back, not Counter.
Dataclasses are a natural fit for type hints, because the annotations are the field definitions:
# order.py
from dataclasses import dataclass, field
from decimal import Decimal
from typing import ClassVar
@dataclass
class Order:
id: int
customer: str
items: list[str] = field(default_factory=list)
discount: Decimal | None = None
tax_rate: ClassVar[float] = 0.2
def merge(self, other: "Order") -> "Order":
return Order(self.id, self.customer, self.items + other.items)
ClassVar marks tax_rate as a class-level attribute, so the dataclass doesn't treat it as a field.
Forward References
Inside the Order class body, the name Order doesn't exist yet when the method is defined, so the annotation is written as the string "Order". Checkers understand string annotations; Python just stores them as strings. Two other options:
- Put
from __future__ import annotationsat the top of the module. Every annotation in the file is then stored as a string automatically, so you can writeother: Orderwithout quotes. - Python 3.14: annotations are evaluated lazily by default (PEP 649 and PEP 749), so forward references work without quotes or the
__future__import. If you support 3.13 or earlier, keep using one of the approaches above.
Imports Used Only for Typing
Sometimes you need a type only for annotations, and importing it at runtime would be slow or would cause a circular import. Guard the import with TYPE_CHECKING, which is False at runtime and True for type checkers:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models import User
def display_name(user: User) -> str:
return user.name.title()
Because annotations aren't evaluated at runtime here (thanks to the __future__ import), the missing runtime import never matters.
Escape Hatches: Any, object, and cast
Not everything can be typed precisely, and Python gives you three tools for that. They're easy to confuse:
| Tool | Meaning | What the checker allows |
|---|---|---|
Any | "Don't check this" | Everything: any attribute, any call, assignable to anything |
object | "Any value at all" | Only what every object supports (repr, ==, str...) |
cast(T, value) | "Trust me, this is a T" | Treats the value as T from here on |
import json
from typing import Any, cast
def log_anything(value: object) -> None:
print(repr(value)) # fine
# value.upper() # error: "object" has no attribute "upper"
def debug_dump(value: Any) -> None:
print(value.whatever) # not checked at all
data = json.loads('{"port": 8080}') # data is Any
port = cast(int, data["port"]) # port is int from here on
Prefer object when you genuinely accept anything, since it forces you to narrow with isinstance() before doing type-specific things. Use Any sparingly, mostly at boundaries like parsed JSON, and convert to a real type as soon as you can. cast() does nothing at runtime, it simply returns the value, so it's only as correct as your reasoning. Validating with an isinstance() check, or with a library like Pydantic, is safer for data from outside your program.
Adding Type Hints to an Existing Project
You don't have to annotate everything at once. Type hints were designed for gradual adoption: unannotated code is treated loosely, so you can add hints file by file.
A practical order:
- Start with function signatures, especially public ones and anything called from many places. One annotated signature improves every call site.
- Annotate return types that can be
None. These catch the most bugs per minute spent. - Run a checker in its default mode and fix what it finds. By default, mypy skips the bodies of functions that have no annotations at all, so you won't be flooded with errors.
- Tighten gradually. Turn on stricter settings for one package at a time once it's annotated.
- Install stubs for libraries that don't ship their own types, such as
types-requests, so their APIs are checked too.
Resist the urge to sprinkle Any everywhere to make errors go away. Each Any is a hole in the safety net. When a type is hard to express, the advanced tools (generics, protocols, TypedDict) usually have an answer, and the advanced post linked below walks through them.
Common Mistakes
- Expecting runtime enforcement.
def f(x: int)happily accepts a string at runtime. Run a checker, or use a validation library at trust boundaries. - Over-specifying parameters.
list[str]rejects tuples and generators that would work fine. UseIterableorSequencewhen that's all you need. - Forgetting
| None. If a function can returnNone, say so, or the checker can't protect your callers. - Using the mutable default trap with hints.
def f(items: list[str] = [])is still a shared mutable default. Useitems: list[str] | None = Noneand create the list inside. - Annotating everything inside functions. Local variables are inferred. Extra annotations just add noise and can drift out of date.
Conclusion
Type hints document what your code expects in a form that tools can verify. Annotate function parameters and return types first, use built-in generics like list[int] and dict[str, float], accept abstract types like Iterable and Mapping in parameters, and mark anything that can be None with | None. Reach for type aliases, Literal, Final, and Self when they make intent clearer, and keep Any and cast() for genuine boundaries.
Remember that Python itself ignores the hints. The value comes from running a checker and from the editor support you get for free. When you're ready to go further, Generics, Protocols, and TypedDict: Advanced Type Hints in Python covers the tools for typing flexible, reusable code, and Static Type Checking in Python with mypy and Pyright shows how to wire a checker into your workflow.


