Python Type Hints and mypy: A Practical Guide

Type hints document what your code expects, and mypy checks them before anything runs. Learn the syntax, the config, and how to adopt both gradually.

Executive Summary: Python type hints describe what functions accept and return, and mypy checks them without running your code. This guide covers how hints catch None errors and wrong arguments before deployment, what they cost, and a practical rollout: annotate function boundaries first, run mypy in CI, and tighten settings over time.

A function returned a user’s name, or None when the user did not exist. For two years, every caller happened to pass a valid ID. Then a new feature called it with an ID from a deleted account, and production logged AttributeError: 'NoneType' object has no attribute 'upper' a few thousand times before anyone woke up. A type checker would have flagged that line the day it was written.

Python type hints are optional annotations on variables, parameters, and return values that describe the expected types. Tools called static type checkers, such as mypy and pyright, read the annotations and report mismatches by analyzing the source code.

This guide uses Python 3.10 and mypy 0.96x. You should know how to write functions and classes. Run python --version first, because the X | Y syntax used here needs Python 3.10.

My position: type hints pay for themselves in any codebase that more than one person touches, or that lives longer than a few months. However, hints without a checker in CI are comments that slowly become wrong.

How Python type hints work

An annotation follows a colon after a name, and a return type follows an arrow.

def total_price(unit_price: float, quantity: int = 1) -> float:
    return unit_price * quantity


retries: int = 3
names: list[str] = ['asha', 'ben']
stock: dict[str, int] = {'keyboard': 14}
point: tuple[float, float] = (12.5, 48.1)

Since Python 3.9, the built-in containers accept type parameters directly, so you write list[str] and not typing.List[str]. Local variables rarely need annotations, because the checker infers their types from the assigned value. Good function signatures are where annotations matter most.

The myth: type hints change what Python does at runtime

Many developers assume that annotating a parameter as int makes Python reject a string. It does not. The interpreter stores annotations and otherwise ignores them.

def double(n: int) -> int:
    return n * 2


print(double('ab'))
abab

No error occurs, and the function returns a string despite its declared return type. Hints do not validate, do not convert, and do not make code faster. All their value comes from a separate tool that reads them. Consequently, input from outside your program still needs runtime validation.

Install and run mypy

Install mypy inside a virtual environment, which keeps the tool and its version tied to the project.

python --version
python -m venv .venv
source .venv/bin/activate          # macOS and Linux
.venv\Scripts\Activate.ps1         # Windows PowerShell
python -m pip install mypy

Now save this file as lookup.py. It contains two bugs.

USERS = {1: 'asha', 2: 'ben'}


def find_user(user_id: int) -> str | None:
    return USERS.get(user_id)


def greeting(user_id: int) -> str:
    name = find_user(user_id)
    return 'Hello, ' + name.upper()


print(greeting(1))
print(greeting('2'))

Run it normally, and the first call works while the second crashes:

Hello, ASHA
Traceback (most recent call last):
  File "lookup.py", line 14, in <module>
    print(greeting('2'))
  File "lookup.py", line 10, in greeting
    return 'Hello, ' + name.upper()
AttributeError: 'NoneType' object has no attribute 'upper'

Now run mypy on the same file. It executes nothing, and it reports both problems:

python -m mypy lookup.py
lookup.py:10: error: Item "None" of "Optional[str]" has no attribute "upper"
lookup.py:14: error: Argument 1 to "greeting" has incompatible type "str"; expected "int"
Found 2 errors in 1 file (checked 1 source file)

The first error would exist even if every caller passed a valid integer today. mypy reasons about every possible path, not just the ones your tests exercise. The trade-off is that it also complains about paths you know cannot happen, and you must then prove it or restructure the code.

Handle None with narrowing

The type str | None means “a string or None“. Older code writes the same thing as Optional[str], which is why mypy’s message uses that name. To use the value as a string, you must first rule out None. The checker follows your if statements, a behavior called narrowing.

def greeting(user_id: int) -> str:
    name = find_user(user_id)
    if name is None:
        return 'Hello, stranger'
    return 'Hello, ' + name.upper()     # here mypy knows name is str

Narrowing also works with isinstance(), with early raise statements, and with assert name is not None. Prefer the explicit if, because an assertion crashes at runtime when you are wrong.

Note the difference between two ideas that share the word “optional”. name: str | None means the value may be None. name: str = 'x' means the argument may be omitted. A parameter can be either, both, or neither.

The types you will use every day

Need Annotation Notes
One of several types int | str Python 3.10 syntax, defined by PEP 604
May be missing str | None Same as Optional[str]
Any read-only sequence Sequence[str] Accepts lists and tuples, from collections.abc
Anything you can loop over Iterable[str] Also accepts generators and sets
Any read-only mapping Mapping[str, int] Accepts dict and similar types
A function Callable[[int, str], bool] Argument types, then return type
A fixed set of values Literal['asc', 'desc'] From typing
A dictionary with known keys TypedDict Useful for JSON-shaped data
Opt out of checking Any Compatible with everything, checks nothing

A useful rule comes from that table: accept general types and return specific ones. A parameter typed Iterable[str] lets callers pass a list, a tuple, or a generator. A return type of list[str] tells callers exactly what they can do with the result.

Any versus object

These two look similar and behave in opposite ways. Any turns the checker off: every operation on the value is allowed. object is the base of all types, so every value fits, but you can do almost nothing with it until you narrow it. Use object when you truly accept anything, and treat Any as a temporary escape hatch.

Generics, Protocol, and type aliases

Three more tools cover most remaining cases. A TypeVar links an input type to an output type. A Protocol describes what an object can do, without requiring inheritance. A TypeAlias names a type so that signatures stay readable.

The complete example below uses all three, together with dataclasses, which work well with type checkers because their fields are already annotated. Save it as tasks.py.

from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Protocol, TypeAlias, TypeVar

TaskId: TypeAlias = int
T = TypeVar('T')


@dataclass(frozen=True)
class Task:
    id: TaskId
    title: str
    assignee: str | None = None


class HasTitle(Protocol):
    @property
    def title(self) -> str: ...


def first(items: Sequence[T]) -> T | None:
    return items[0] if items else None


def titles(items: Iterable[HasTitle]) -> list[str]:
    return [item.title for item in items]


def find_task(tasks: Sequence[Task], task_id: TaskId) -> Task | None:
    for task in tasks:
        if task.id == task_id:
            return task
    return None


def assignee_label(task: Task) -> str:
    if task.assignee is None:
        return 'unassigned'
    return task.assignee.upper()


def main() -> None:
    tasks = [Task(1, 'Write tests', 'asha'), Task(2, 'Fix login')]
    print(titles(tasks))
    found = find_task(tasks, 2)
    if found is not None:
        print(assignee_label(found))
    print(first(tasks))


if __name__ == '__main__':
    main()
python tasks.py
['Write tests', 'Fix login']
unassigned
Task(id=1, title='Write tests', assignee='asha')
python -m mypy tasks.py
Success: no issues found in 1 source file

Look at what each tool buys you. Because of T, mypy knows that first(tasks) returns Task | None, not some unknown object. Because of the protocol, titles() accepts any object with a title attribute, and Task never has to mention HasTitle. That style is called structural typing, and it matches how Python code already behaves.

For decorators, Python 3.10 added ParamSpec (PEP 612), which lets a wrapper keep the signature of the function it wraps. You can see it applied in a guide to writing decorators.

If you must support Python versions older than 3.10, the typing_extensions package backports newer features such as TypeAlias and ParamSpec. Adding from __future__ import annotations at the top of a file also lets you write str | None in annotations on Python 3.7 and later.

The blind spot: unannotated functions are skipped

Here is the fact that surprises nearly every team, and it is the most important insight in this guide. By default, mypy does not check the body of a function that has no annotations. It treats the parameters and the result as Any.

def greeting(user_id):                 # no annotations
    name = find_user(user_id)
    return 'Hello, ' + name.upper()    # same bug as before
Success: no issues found in 1 source file

The bug is still there, and mypy reports success. Therefore, “mypy passes” tells you nothing until you know how much of the code it actually examined. A green check on a mostly unannotated codebase is a false signal.

Two settings close the gap. check_untyped_defs makes mypy analyze unannotated bodies. disallow_untyped_defs reports every function that lacks annotations. My heuristic for an existing project: run mypy once with --disallow-untyped-defs and read the error count as your to-do list, not as a failure.

python -m mypy --disallow-untyped-defs src/

Configure mypy in pyproject.toml

mypy reads its settings from a [tool.mypy] table in pyproject.toml. Start with a configuration that is strict for new code and lenient where you must be.

[tool.mypy]
python_version = "3.10"
check_untyped_defs = true
disallow_untyped_defs = true
warn_return_any = true
warn_unused_ignores = true
no_implicit_optional = true

[[tool.mypy.overrides]]
module = "legacy.*"
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "some_untyped_library.*"
ignore_missing_imports = true

The overrides tables relax rules per module, which lets you enforce annotations in new packages while old ones catch up. For a new project, replace the individual flags with strict = true.

Third-party libraries sometimes ship without type information. mypy then prints a message such as this:

error: Library stubs not installed for "requests" (or incompatible with Python 3.10)
note: Hint: "python3 -m pip install types-requests"

Install the suggested stub package into your virtual environment. If no stubs exist, add an override with ignore_missing_imports = true for that one library, never globally.

When mypy is wrong about a single line, silence only that error and say which one:

value = legacy_call()  # type: ignore[no-any-return]

A bare # type: ignore hides every error on the line, including ones added later. To see what mypy has inferred, insert reveal_type(value) temporarily, run mypy, and read the note it prints.

mypy, pyright, and runtime validation compared

Tool When it runs Strength Limit
mypy On demand and in CI The reference checker, with a plugin system and broad library support Slower on large codebases, lenient defaults
pyright In the editor (it powers Pylance in VS Code) and in CI Fast feedback as you type, quick support for new typing features Needs Node.js for the command-line tool
Runtime validation (for example pydantic) While the program runs Checks real data from requests, files, and environment variables Costs CPU time, and finds errors only on executed paths

These are not competitors. A static checker verifies that your code is consistent with itself. Runtime validation verifies that the outside world matches your assumptions. Most services need both.

How real systems adopt type checking

  • The checker runs in CI. A pull request cannot merge while mypy reports errors. Without this gate, annotations drift away from the code within weeks.
  • Boundaries first. Teams annotate public functions, data models, and the layers that talk to databases and HTTP. Internal helpers follow, since inference covers much of them.
  • Strict for new code, lenient for old. Per-module overrides hold new packages to disallow_untyped_defs while legacy modules are exempt. The exempt list shrinks over time.
  • Typed data models at the core. Dataclasses and TypedDict replace loose dictionaries, so a misspelled key becomes a checker error.
  • A pinned checker version. New mypy releases find new errors. Pinning the version in the development requirements keeps CI from breaking on an unrelated change.

In my experience adding mypy to a four-year-old billing service, the first strict run reported several hundred errors. Most were missing annotations, but about a dozen were real: functions that could return None and callers that never checked. Two of those matched error reports we had been unable to reproduce. We fixed the real ones first, then enabled disallow_untyped_defs one package at a time over a few months.

Adopting type hints: a decision framework

  1. Is this a new project? Set strict = true from the first commit. Strictness is cheap on day one and expensive later.
  2. Is it an existing codebase? Enable check_untyped_defs first, fix what it finds, and add CI. Then enforce full annotations per module.
  3. Which functions get annotations first? Start with those called from many places, and those that return None in some cases.
  4. Does data cross a process boundary here? Add runtime validation at that point, then convert to typed objects.
  5. Is the checker fighting you on dynamic code? Isolate that code behind a small typed function, and use Any inside it only.

When NOT to invest in type hints

  • Throwaway scripts and notebooks. A fifty-line script that you run once and delete gains little. Exploration moves faster without them.
  • Heavily dynamic code. Code that builds classes at runtime or relies on __getattr__ cannot be described accurately. Annotations there become false statements, which are worse than none.
  • As a replacement for validation or tests. Hints prove that types line up. They do not prove that a discount is calculated correctly, or that a JSON payload has the fields you expect.

Common mistakes

  • Annotating without running a checker. Unchecked hints go stale, and readers trust signatures that no longer match the code.
  • Trusting a pass on unannotated code. mypy skips unannotated function bodies by default. The success message hides real bugs.
  • Reaching for Any to silence errors. Any spreads: every value derived from it is also unchecked. One shortcut can disable checking across a module.
  • Using a mutable container type for parameters. A parameter typed list[float] rejects a list[int], because lists are invariant. Use Sequence[float] for read-only inputs.
  • Writing a bare type: ignore. It suppresses every current and future error on that line. Include the error code in brackets.
  • Setting ignore_missing_imports globally. A typo in an import of your own module then passes silently and resolves to Any.

Key takeaways

  • Type hints do nothing at runtime. A checker such as mypy or pyright gives them value.
  • Write X | None for values that may be missing, and narrow with an if before use.
  • Accept general types such as Iterable and Sequence, and return specific ones.
  • Turn on check_untyped_defs, because mypy otherwise skips unannotated functions.
  • Keep configuration in [tool.mypy], and relax rules per module with overrides.
  • Run the checker in CI and pin its version.
  • Use runtime validation for external data, and static typing for internal consistency.

FAQ

What are type hints in Python?

Type hints are optional annotations that describe the expected types of variables, parameters, and return values. Tools such as mypy read them to find type errors without running the code.

Does Python enforce type hints at runtime?

No. The interpreter ignores type hints when it runs your program. Passing a string to a parameter annotated as int raises no error unless the code itself fails.

What is mypy used for?

mypy is a static type checker. It analyzes annotated Python source code and reports mismatches, such as calling a method on a value that may be None.

What is the difference between Optional[str] and str | None?

They mean the same thing: a string or None. The str | None form is the newer syntax, available from Python 3.10.

Should I use mypy or pyright?

Both work well. pyright gives fast feedback in editors such as VS Code, while mypy is the long-established checker with plugins. Many teams use pyright in the editor and one of the two in CI.

Annotate the boundaries, then let the checker work

Type hints turn assumptions that lived in your head into statements a machine can verify. Start where the risk is highest: public functions and anything that can return None. Then make the checker a required step, so the statements stay true.

Rule of thumb: a type hint that no tool checks is a comment, and comments lie.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *