Type something to search...
Type Hints in Python: A Practical Guide to Static Typing

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.
  • *args and **kwargs are annotated with the type of each value: *args: int means every positional argument is an int, and **kwargs: str means every keyword value is a str.
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:

AnnotationMeaning
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 onceIterable[T]
Index it or call len()Sequence[T]
Read keys and valuesMapping[K, V]
Modify it in placelist[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 annotations at the top of the module. Every annotation in the file is then stored as a string automatically, so you can write other: Order without 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:

ToolMeaningWhat 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:

  1. Start with function signatures, especially public ones and anything called from many places. One annotated signature improves every call site.
  2. Annotate return types that can be None. These catch the most bugs per minute spent.
  3. 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.
  4. Tighten gradually. Turn on stricter settings for one package at a time once it's annotated.
  5. 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. Use Iterable or Sequence when that's all you need.
  • Forgetting | None. If a function can return None, 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. Use items: list[str] | None = None and 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading