Python's dynamic typing is a double-edged sword. At the REPL or in a 500-line script, it's liberating. In a production codebase with six contributors and two years of history, it's a liability. Type hints change that — but only if you use them right and pair them with a strict checker.
Why bother? The real cost of untyped Python
The usual argument for type hints is "IDE autocompletion" — which undersells them completely. The actual value is catching bugs before they reach production.
Consider this function:
def get_user_role(user_id):
user = db.get(user_id)
return user["role"]
It works fine in tests where db.get always returns a dict. It blows up in production when db.get returns None for a missing user. With types:
from typing import Optional
def get_user_role(user_id: int) -> Optional[str]:
user = db.get(user_id)
if user is None:
return None
return user["role"]
Now a type checker forces you to handle the None case. That's not just documentation — it's a constraint that prevents a class of bugs.
In security-sensitive code paths (authentication, authorization, input validation), this matters even more. A missed None check in a permissions function isn't just a crash — it can be an exploitable condition. This is the kind of issue that shows up in security hardening checklists for Python applications.
Picking your checker: mypy vs pyright
Both are good. The choice depends on your workflow.
mypy is the original. It's slower on large codebases but has the most mature ecosystem. Plugins exist for Django, SQLAlchemy, Pydantic, and others.
pyright (also the engine behind Pylance in VS Code) is significantly faster and has stricter inference by default. If you're greenfielding a project, pyright in strict mode is the better choice.
In practice: use pyright for real-time feedback in the editor, and mypy in CI where the extra plugin support pays off.
A minimal pyproject.toml configuration for both:
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = false
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingImports = true
Start with strict = true on new code. For legacy codebases, add # type: ignore pragmatically and track the count — it should decrease over time, never increase.
Real-world patterns that actually come up
TypedDict for structured data
When you're passing around dicts with known shapes — config objects, API responses, database rows before ORM — TypedDict is cleaner than a full dataclass:
from typing import TypedDict
class AuditEvent(TypedDict):
user_id: int
action: str
resource: str
timestamp: float
success: bool
def log_event(event: AuditEvent) -> None:
print(f"[{event['timestamp']}] user={event['user_id']} action={event['action']}")
This is especially useful for audit trail data or any place where the schema matters but you don't want the overhead of a full ORM model.
Protocol for structural typing
Protocols let you define interfaces without inheritance — Python's answer to Go's implicit interfaces:
from typing import Protocol
class Authenticatable(Protocol):
def verify_token(self, token: str) -> bool: ...
def get_permissions(self) -> list[str]: ...
def authorize(user: Authenticatable, required_permission: str) -> bool:
if not user.verify_token(token="..."):
return False
return required_permission in user.get_permissions()
Any class with verify_token and get_permissions methods satisfies Authenticatable — no inheritance required. This is more flexible than ABCs and plays better with third-party code you don't control.
Annotated for validation metadata
Python 3.9+ Annotated lets you attach metadata to types. Pydantic v2 uses this extensively:
from typing import Annotated
from pydantic import BaseModel, Field
UserId = Annotated[int, Field(gt=0, description="Positive integer user ID")]
Email = Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
class CreateUserRequest(BaseModel):
user_id: UserId
email: Email
role: str
This keeps validation close to the type definition rather than scattered across separate validators. The type alias (UserId, Email) also shows up in error messages, which makes debugging faster.
Common pitfalls and how to fix them
Any as a crutch. Every Any is a type checker escape hatch. It's sometimes necessary — dynamic dispatch, legacy code, badly typed third-party libraries — but each one is a blind spot. Enable --warn-return-any in mypy and audit Any usages in code review.
Mutable default arguments typed incorrectly. The classic Python footgun:
# Wrong — both the type annotation and the logic
def add_item(item: str, collection: list[str] = []) -> list[str]:
collection.append(item)
return collection
# Correct
def add_item(item: str, collection: list[str] | None = None) -> list[str]:
if collection is None:
collection = []
collection.append(item)
return collection
mypy won't flag the shared mutable default directly, but requiring None as the default makes the safe pattern natural.
Overly broad ignore_missing_imports. If a library ships without type stubs, mypy treats everything it exports as Any. Use [[tool.mypy.overrides]] to suppress warnings for specific packages rather than setting ignore_missing_imports = true globally — the global flag masks real errors.
Not using reveal_type during debugging. When you're unsure what type mypy infers, insert reveal_type(variable) anywhere and run mypy — it prints the inferred type without failing. Remove it before committing.
The takeaway
Type hints are not just documentation. Used with mypy or pyright in strict mode, they form a lightweight static analysis layer that catches a real class of bugs before code ships. The overhead is front-loaded — setting up the config, annotating existing code — but the ongoing cost is low and the signal is high.
The return on investment is highest for:
- Functions that handle user input or authentication
- Any function that returns
Optionalor raises conditionally - Code paths that call external APIs or touch databases
If you're starting today: enable pyright strict in your editor, add mypy to CI with strict = true, and treat every # type: ignore comment as technical debt. Count them in your CI output and trend them down over time.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)