mypy
is a tool that checks your Python code for type errors without running it. It's great for catching bugs early and keeping your code clean and easy to maintain. It's widely used in Python and is a common part of many professional projects.
Python's type hints give you everything you'd expect from a modern type system — support for basic types, lists and dictionaries, custom classes, generics, and more. One of the best things about it is how flexible it is. You can gradually add type hints and customize them to fit your project's small or large needs.
In this article, you'll learn how to use type hints in your Python code with the help of the typing
module.
Prerequisites
Before proceeding, ensure you have the latest version of Python installed on your machine. This article assumes you're familiar with basic Python programming concepts.
Getting started with type hints
Type hints were introduced in Python 3.5 through PEP 484 and have evolved significantly since then. They let you indicate the expected types of variables, function parameters, and return values in your code.
To get started, create a new project directory and move into it:
mkdir python-typing && cd python-typing
Next, set up and activate a virtual environment:
python3 -m venv venv
source venv/bin/activate
Then install mypy
inside your virtual environment:
pip install mypy
Create a simple script with type hints:
def greeting(name: str) -> str:
return f"Hello, {name}!"
print(greeting("World"))
Run the program normally:
python main.py
Output:
Hello, World!
The program runs like any Python script. Type hints don't affect runtime behavior at all. Now check types with mypy
:
mypy main.py
Output:
Success: no issues found in 1 source file
This confirms your code aligns with the type annotations. Remember, Python still runs code with type errors, but mypy
catches them before runtime, helping you avoid bugs.
Basic types in Python
Python supports optional static typing using type hints, which help you catch bugs early and make your code easier to understand.
You can annotate variables and function arguments with basic types like int
, float
, bool
, and str
:
# Primitive types
x: int = 1
y: float = 2.5
z: bool = True
name: str = "John"
# Simple function with type hints
def greeting(name: str) -> str:
return f"Hello, {name}!"
For collections such as lists and dictionaries, use the typing
module to specify the types of their contents:
from typing import List, Dict
# Specify collection contents
numbers: List[int] = [1, 2, 3]
user_ages: Dict[str, int] = {"John": 30, "Jane": 25}
Run mypy
to check your types:
mypy main.py
Output:
Success: no issues found in 1 source file
The following diagram illustrates how mypy checks your code for type errors before runtime, saving you from bugs that would otherwise only be caught during execution:
The real benefit of type hints is that they show up when mistakes are made. For example, if you accidentally add a string to your list of integers, Python won’t complain at runtime — but mypy
will catch the issue before you even run the code.
Type aliases
Type aliases can help improve code readability and maintainability when working with complex or nested data structures.
Instead of repeating long type hints throughout your code, you can define aliases with meaningful names.
from typing import Dict, List, Tuple
# Type aliases
Point = Tuple[float, float]
Points = List[Point]
UserDatabase = Dict[str, Dict[str, str]]
def plot_points(points: Points) -> None:
for point in points:
x, y = point
print(f"Point: ({x}, {y})")
Type aliases make your code cleaner, especially when dealing with nested structures. They also centralize type definitions, making updates easier.
For example, instead of repeating List[Tuple[float, float]]
everywhere, you just use Points
. This makes function signatures and annotations much easier to read, especially in large codebases.
To check the types, use the following command in your terminal:
mypy main.py
This helps validate that your types are used correctly and can catch bugs early in development.
Function annotations
Function annotations are the most common use case for type hints.
They let you specify what types a function expects and what it returns, making your code more self-documenting and easier to understand for both humans and tools.
from typing import List, Optional, Union
def repeat_string(s: str, times: int) -> str:
"""Repeat a string multiple times."""
return s * times
def get_first_item(items: List[int]) -> Optional[int]:
"""Return the first item or None if empty."""
return items[0] if items else None
def process_value(value: Union[int, str]) -> str:
"""Process either an int or a string."""
if isinstance(value, int):
return f"Number: {value * 2}"
else:
return f"String: {value.upper()}"
These annotations tell readers and tools exactly what your functions expect and return.
repeat_string
takes astr
and anint
, and returns astr
.Optional[int]
means the function might return an integer orNone
if the list is empty.Union[int, str]
allowsprocess_value
to accept either an integer or a string.
Annotations also improve support from editors and IDEs (like autocomplete, static analysis, and refactoring). They're invaluable in larger codebases or teams, where clear function contracts reduce misunderstandings and bugs.
Let's see how mypy
helps catch errors. Create deliberate type mistakes with the following example:
from typing import List
def add_numbers(a: int, b: int) -> int:
return a + b
# Type error: string instead of int
result = add_numbers("5", 10)
def get_first_item(items: List[int]) -> int:
return items[0]
# Type error: empty list access
empty_list: List[int] = []
first_item = get_first_item(empty_list)
If you run this with Python, the first line would concatenate strings instead of adding numbers, and the second would crash with an IndexError. But mypy
catches both issues before runtime:
mypy main.py
Output:
main.py:9: error: Argument 1 to "add_numbers" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
In this case, mypy
flags the incorrect argument type passed to add_numbers
. It tells you that "5"
is a string, but an int
was expected.
These are exactly the kinds of bugs that can slip through testing and cause problems in production. With static type checking, mypy
helps you catch these issues early in development — before they turn into runtime errors.
Optional and union types
Python's type system provides elegant ways to express variables that can have multiple types or be absent. This is especially useful when dealing with optional return values or inputs that can vary in type.
from typing import Optional, Union
# Can return a string or None
def find_user(user_id: int) -> Optional[str]:
user_database = {1: "John", 2: "Jane"}
return user_database.get(user_id)
# Can accept either string or bytes
def process_input(data: Union[str, bytes]) -> str:
if isinstance(data, bytes):
return data.decode('utf-8')
return data
Optional[str]
is shorthand for Union[str, None]
. It clearly communicates that the function may return a string — or nothing at all (None
). This is common in functions that search, retrieve, or look up values that might not exist (e.g., from a dictionary or a database).
Union
types are perfect for writing flexible functions accepting multiple input types. In the example above, process_input
accepts either a string or a byte string (str
or bytes
). The function logic adapts based on the input type, handling both cases safely.
Using Optional
and Union
helps make your code:
- More expressive: Readers understand the range of valid inputs or outputs at a glance.
- Safer: Tools like
mypy
can alert you when you're not handling all possible cases (e.g., forgetting to check forNone
). - Cleaner: You avoid long comments or confusing docstrings — the types speak for themselves.
These hints are compelling when combined with static analysis tools or in larger codebases where assumptions about types can easily lead to bugs.
Generic types
Generics allow you to create reusable components that work with different types. Instead of writing separate versions of the same class or function for each data type, generics let you define a single implementation that can handle them all — safely and predictably.
from typing import Generic, TypeVar, List
# Define a type variable
T = TypeVar('T')
# Create a generic class
class Stack(Generic[T]):
def __init__(self) -> None:
self.items: List[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
if not self.items:
raise IndexError("Pop from empty stack")
return self.items.pop()
Usage:
# Integer stack
int_stack = Stack[int]()
int_stack.push(1)
# String stack
str_stack = Stack[str]()
str_stack.push("hello")
In this example, T
is a type variable — a placeholder that will be replaced by a concrete type when the class is used. When you write Stack[int]
, you're telling Python (and type checkers like mypy
) that this stack will only hold integers.
This lets you:
- Avoid code duplication: One implementation works for all types.
- Enforce type safety:
mypy
will raise an error if you try to push a string into aStack[int]
. - Write flexible data structures: You can build things like generic lists, trees, caches, or queues — all type-safe and reusable.
Generics are widely used in libraries and frameworks, especially for building containers, APIs, and utilities that need to support many data types without sacrificing type checking.
They are also fully supported by tools like mypy
, which can validate that your generic types are used consistently across your code.
Gradual typing
You don't need to add types to your entire codebase at once. Python's type system supports gradual typing, which means you can start with a few critical functions and expand from there at your own pace.
For example, take a look at this code:
# Fully typed function
def get_user_data(user_id: int) -> dict:
# ...
return user_data
# Partially typed function
def process_data(data):
# ...
return processed_data
In this example, get_user_data
has complete type annotations, which helps tools like mypy
check that it's being used correctly. On the other hand, process_data
has no annotations, so it's treated dynamically — just like traditional Python.
This approach is constructive when working with large or legacy codebases. You can begin by typing functions that are central to your application’s logic or are most prone to bugs. Over time, as you refactor or revisit modules, you can add more annotations and tighten up type checks.
Type checkers like mypy
will analyze the parts of your code that include type hints, and skip over the rest. This allows you to benefit from type checking immediately, without requiring a complete rewrite.
To enforce a stricter typing policy as you go, you can run:
mypy filename.py --disallow-untyped-defs
This flags any function that’s missing annotations, helping you identify where to improve type coverage next. Gradual typing lets you balance safety and flexibility, while progressively improving code clarity and reliability.
Final thoughts
This article has explored Python type hints in depth—from basic syntax to more advanced patterns. Type hints improve readability and maintainability and help catch bugs early in development.
You can start using type hints gradually in your existing code, configure mypy
to fit your workflow, and explore tools like Pyright or Pyre for even more powerful checks.
For more details, see the official Python typing documentation.
Thanks for reading — happy typing!
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github