Python’s type hints have matured into a full type system. In 2026, most production Python code uses type hints + mypy for static checking. This catches bugs before runtime, improves IDE autocomplete, and makes refactoring safer. This complete tutorial covers everything from basic types to Generic + Protocol + TypedDict.
Why use type hints
- Catch bugs: mypy catches type errors before runtime.
- Better IDE: autocomplete, refactoring, navigation all improve.
- Self-documenting: function signatures show intent.
- Easier refactoring: know exactly what breaks when you change types.
- Team communication: types are contracts.
Basic types
# Variables
name: str = "Jude"
age: int = 30
height: float = 5.9
is_active: bool = True
# Function signatures
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
# No type = Any type (avoid)
def bad_function(x): # x has no type hint
return x + 1Collections
# Lists
names: list[str] = ["Ana", "Ben", "Carol"]
# Dicts
scores: dict[str, int] = {"Ana": 95, "Ben": 87}
# Tuples (fixed length + types)
point: tuple[int, int] = (10, 20)
mixed: tuple[str, int, float] = ("Ana", 25, 5.5)
# Sets
unique_names: set[str] = {"Ana", "Ben"}
# Nested
nested: dict[str, list[int]] = {
"primes": [2, 3, 5, 7],
"even": [2, 4, 6, 8]
}Optional and None
from typing import Optional
# Optional means "could be None"
def find_user(user_id: int) -> Optional[dict]:
if user_id in database:
return database[user_id]
return None
# Python 3.10+ syntax (cleaner)
def find_user(user_id: int) -> dict | None:
...
# Optional[X] is same as X | NoneUnion types
from typing import Union
# Old syntax
def parse(value: Union[str, int]) -> str:
return str(value)
# Python 3.10+ syntax (cleaner)
def parse(value: str | int) -> str:
return str(value)
# Multiple types
def convert(x: int | float | str) -> str:
return str(x)TypedDict (structured dicts)
from typing import TypedDict
class User(TypedDict):
name: str
email: str
age: int
is_active: bool
# Now mypy checks this
user: User = {
"name": "Jude",
"email": "[email protected]",
"age": 30,
"is_active": True
}
# mypy errors if:
# - missing key ("age" not provided)
# - wrong type ("age": "thirty")
# - extra key ("status": "premium")Callable (function types)
from typing import Callable
# Function type: takes int, returns str
Formatter = Callable[[int], str]
def default_formatter(n: int) -> str:
return f"Number: {n}"
def format_all(
numbers: list[int],
formatter: Formatter
) -> list[str]:
return [formatter(n) for n in numbers]
result = format_all([1, 2, 3], default_formatter)Generic types
from typing import TypeVar, Generic
T = TypeVar("T")
# Generic function
def first(items: list[T]) -> T:
return items[0]
# Works with any list
first([1, 2, 3]) # returns int
first(["a", "b"]) # returns str
# Generic class
class Stack(Generic[T]):
def __init__(self):
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
int_stack: Stack[int] = Stack()
int_stack.push(1) # OK
int_stack.push("hello") # mypy error: expected intProtocol (structural typing)
from typing import Protocol
class SupportsQuack(Protocol):
def quack(self) -> str:
...
# Anything with quack() method matches this Protocol
# No need to explicitly inherit
class Duck:
def quack(self) -> str:
return "Quack!"
class Person:
def quack(self) -> str:
return "I'm quacking"
def make_sound(animal: SupportsQuack) -> None:
print(animal.quack())
make_sound(Duck()) # OK
make_sound(Person()) # OK (has quack method)Literal types
from typing import Literal
# Restrict to specific values
def set_log_level(
level: Literal["debug", "info", "warning", "error"]
) -> None:
...
set_log_level("debug") # OK
set_log_level("verbose") # mypy error: not in LiteralInstall and setup mypy
# Install uv add --dev mypy # Or with pip pip install mypy # Run mypy on your code uv run mypy src/ # Configuration in pyproject.toml [tool.mypy] python_version = "3.12" strict = true # enable all checks warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true # all functions must have types # Ignore specific files [[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false # tests can skip type hints
Common mypy errors and fixes
# Error: Missing return type annotation
def greet(name: str): # missing return type
return f"Hi {name}"
# Fix:
def greet(name: str) -> str:
return f"Hi {name}"
# Error: Incompatible type
result: int = "hello" # str assigned to int
# Fix:
result: int = 42
# Error: Object of type "None" has no attribute
user = find_user(1) # returns Optional[dict]
name = user["name"] # error: user could be None
# Fix:
if user is not None:
name = user["name"]
# Or use type narrowing:
assert user is not None
name = user["name"]Integrating types with Pydantic
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
age: int | None = None
# Pydantic + type hints work together
# Runtime validation + static type checking
user: User = User(
id=1,
name="Jude",
email="[email protected]"
)
# Both mypy AND Pydantic catch mistakesGradual adoption strategy
- Add type hints to new code first.
- Run mypy with permissive config initially.
- Gradually strictify config (add strict=true).
- Add types to functions with most callers first.
- Use
Anytemporarily where types unclear. - Full strict mode is aspirational, 80% typed is huge win.
Common modern mistakes to avoid
- Skipping the “why” before adopting a new tool. Modern Python tools (uv, Ruff, Polars) are fast and clean. But adopting them without understanding what problem they solve wastes time. Read the tool’s motivation section first.
- Migrating everything at once. Legacy code bases have too many surprises. Migrate one module at a time and test after each step.
- Ignoring backwards compatibility. New Python tools sometimes break with older Python versions. Verify your target Python version supports the tool before committing.
- Trusting benchmarks blindly. “10x faster than X” often means “10x faster for one specific workload”. Test with YOUR data before assuming the benchmark generalizes.
- Not reading the CHANGELOG. Every dependency upgrade may break something. Skimming the changelog for breaking changes takes 2 minutes and saves hours.
Adoption path for modern
- Read official docs cover-to-cover for the core concepts. Yes, cover-to-cover.
- Complete the official tutorial project. Follow along exactly, no shortcuts.
- Adapt one small piece of your existing project to the new tool. Ship it.
- If it survives 2 weeks in production, expand adoption.
- If it caused issues, rollback and reassess whether the tool fits your use case.
When to use vs when to stick with what you know
Modern Python tools offer real improvements over their predecessors, but “better” is not the same as “necessary for you.” Consider adopting when:
- Your current tool is slow enough to block productivity (uv/Ruff speedups matter here).
- You are starting a new project with no legacy constraints.
- Your team has bandwidth to learn and support the new tool.
- The tool has been stable and community-adopted for 12+ months.
Stick with your existing tools when: production stability is critical and you have working infrastructure; your team is small and cannot afford learning curves; the tool is early-stage and API may change.
Ecosystem integration considerations
Every new Python tool interacts with the broader ecosystem. Before committing, check:
- Does your CI/CD pipeline support it? Most tools have GitHub Actions and GitLab CI templates.
- Does your IDE support it? VS Code and PyCharm have first-class support for most modern tools. Older editors may lag.
- Do dependency scanners (Snyk, Dependabot) recognize it? Ensures security updates get flagged.
- Is there production monitoring integration? Datadog, Sentry, New Relic support matters for production.
For teams shipping production Python code, the ecosystem answer is often more important than the tool answer. A slightly-worse tool that plays well with your stack beats a slightly-better tool that fights it.
Best practices summary
- Read the docs before Stack Overflow. Official docs are cleaner and more accurate than forum answers.
- Pin versions in production. Locked dependencies prevent surprise breakage. Update deliberately, not accidentally.
- Test after every dependency upgrade. Run the full test suite. Catch regressions before deployment.
- Contribute back. Report bugs, submit fixes, write tutorials. The tools improve when users engage.
- Reassess yearly. The Python ecosystem moves fast. What was best last year may be second-best today.
Official documentation
Recommended Python resources
The links below are affiliate links. We may earn a commission at no extra cost to you when you buy or sign up. See our affiliate disclosure.
Frequently Asked Questions
Do type hints slow down Python?
No. Type hints are runtime metadata but Python doesn’t enforce them at runtime. Zero performance cost. mypy checks statically before running.
Should I use type hints everywhere?
In production code: yes. In quick scripts: optional. In tests: many teams skip. Add types where they help (public APIs, complex functions).
Is mypy required?
Not required but highly recommended. Types without mypy give IDE benefits but miss bugs. mypy catches type errors before runtime.
Are there mypy alternatives?
Yes. pyright (Microsoft, VS Code integrated), pyre (Meta), pytype (Google). All fine, mypy most popular. Ruff also has some type checks.
Do type hints make Python static-typed?
Optionally. Python remains dynamically typed at runtime. Type hints + mypy give static-typing benefits without changing Python’s execution model.
