Python Type Hints and mypy: Catching Bugs Before Production
A production bug cost us $50k. A function expected an integer but received a string. The error only appeared under specific conditions, so our tests didn’t catch it.
I introduced type hints and mypy to our Python codebase. In the first week, mypy found 127 type errors. We’ve prevented 15+ production bugs since then.
Table of Contents
The $50k Bug
The code:
def calculate_discount(price, discount_percent):
return price * (1 - discount_percent / 100)
# Somewhere else
discount = request.args.get('discount') # Returns string "10"
final_price = calculate_discount(100, discount) # Bug!
discount is a string, not an integer. The calculation fails silently, returning wrong prices.
With type hints:
def calculate_discount(price: float, discount_percent: int) -> float:
return price * (1 - discount_percent / 100)
discount = request.args.get('discount') # str
final_price = calculate_discount(100, discount) # mypy error!
mypy catches this before deployment.
Adding Type Hints
Basic types:
# Variables
name: str = "Alice"
age: int = 30
price: float = 19.99
is_active: bool = True
# Functions
def greet(name: str) -> str:
return f"Hello, {name}"
# Multiple return values
def get_user(user_id: int) -> tuple[str, int]:
return ("Alice", 30)
# No return value
def log_message(message: str) -> None:
print(message)
Collections:
from typing import List, Dict, Set, Tuple, Optional
# Lists
numbers: List[int] = [1, 2, 3]
names: List[str] = ["Alice", "Bob"]
# Dictionaries
user: Dict[str, any] = {"name": "Alice", "age": 30}
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
# Sets
tags: Set[str] = {"python", "typing", "mypy"}
# Tuples (fixed size)
point: Tuple[int, int] = (10, 20)
# Optional (can be None)
def find_user(user_id: int) -> Optional[Dict[str, any]]:
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
return user if user else None
Type Aliases
For complex types:
from typing import Dict, List
# Define alias
User = Dict[str, any]
UserList = List[User]
def get_users() -> UserList:
return [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]
def process_user(user: User) -> None:
print(user["name"])
Classes and Type Hints
from typing import List, Optional
class User:
def __init__(self, name: str, age: int) -> None:
self.name: str = name
self.age: int = age
self.email: Optional[str] = None
def set_email(self, email: str) -> None:
self.email = email
def get_info(self) -> Dict[str, any]:
return {
"name": self.name,
"age": self.age,
"email": self.email
}
class UserService:
def __init__(self) -> None:
self.users: List[User] = []
def add_user(self, user: User) -> None:
self.users.append(user)
def find_user(self, name: str) -> Optional[User]:
for user in self.users:
if user.name == name:
return user
return None
Generic Types
For reusable code:
from typing import TypeVar, Generic, List
T = TypeVar('T')
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:
return self.items.pop()
def is_empty(self) -> bool:
return len(self.items) == 0
# Usage
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
str_stack: Stack[str] = Stack()
str_stack.push("hello")
Setting Up mypy
Install:
pip install mypy
Run:
mypy app.py
Configuration (mypy.ini):
[mypy]
python_version = 3.7
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_unimported = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_no_return = True
check_untyped_defs = True
strict_equality = True
Gradual Typing
You don’t need to type everything at once:
# Start with critical functions
def calculate_price(base: float, tax: float) -> float:
return base * (1 + tax)
# Gradually add types to other functions
def process_order(order): # No types yet
price = calculate_price(order['base'], order['tax'])
return price
Use # type: ignore for legacy code:
result = legacy_function() # type: ignore
Real-World Example: Flask API
Before:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/users/<user_id>')
def get_user(user_id):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
if user:
return jsonify(user)
return jsonify({"error": "Not found"}), 404
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
user_id = db.insert("INSERT INTO users ...", data)
return jsonify({"id": user_id}), 201
After:
from flask import Flask, request, jsonify, Response
from typing import Dict, Tuple, Union, Any
app = Flask(__name__)
@app.route('/users/<int:user_id>')
def get_user(user_id: int) -> Union[Response, Tuple[Response, int]]:
user: Optional[Dict[str, Any]] = db.query(
f"SELECT * FROM users WHERE id = {user_id}"
)
if user:
return jsonify(user)
return jsonify({"error": "Not found"}), 404
@app.route('/users', methods=['POST'])
def create_user() -> Tuple[Response, int]:
data: Dict[str, Any] = request.get_json()
user_id: int = db.insert("INSERT INTO users ...", data)
return jsonify({"id": user_id}), 201
Bugs Found by mypy
In our first scan:
1. Wrong argument types (43 errors):
# Error: Argument 1 has incompatible type "str"; expected "int"
user_id = "123"
get_user(user_id)
2. Missing return statements (28 errors):
# Error: Missing return statement
def get_user_name(user_id: int) -> str:
user = find_user(user_id)
if user:
return user.name
# Missing return for None case!
3. None checks (35 errors):
# Error: Item "None" has no attribute "name"
user = find_user(123) # Returns Optional[User]
print(user.name) # user might be None!
# Fix:
if user:
print(user.name)
4. Dictionary key errors (21 errors):
# Error: TypedDict "User" has no key "phone"
user: User = {"name": "Alice", "age": 30}
print(user["phone"]) # Key doesn't exist!
TypedDict for Structured Data
Better than Dict[str, any]:
from typing import TypedDict
class User(TypedDict):
name: str
age: int
email: str
def create_user(name: str, age: int, email: str) -> User:
return {
"name": name,
"age": age,
"email": email
}
user = create_user("Alice", 30, "alice@example.com")
print(user["name"]) # OK
print(user["phone"]) # mypy error: Key "phone" not in User
Protocols (Structural Subtyping)
Duck typing with type safety:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render(shape: Drawable) -> None:
shape.draw()
# Both work without explicit inheritance
render(Circle())
render(Square())
CI/CD Integration
Add mypy to CI pipeline:
# .gitlab-ci.yml
type-check:
stage: test
script:
- pip install mypy
- mypy src/
allow_failure: false # Fail build on type errors
Pre-commit hook:
# .git/hooks/pre-commit
#!/bin/bash
echo "Running mypy..."
mypy src/
if [ $? -ne 0 ]; then
echo "mypy failed. Commit aborted."
exit 1
fi
Performance Impact
mypy runs at development/CI time, not runtime. Zero performance impact in production.
Adoption Strategy
Our 6-week rollout:
Week 1-2: Add types to new code only Week 3-4: Add types to critical paths (payment, auth) Week 5: Add types to API endpoints Week 6: Add types to remaining code
We didn’t require 100% coverage immediately. Started with 30%, now at 85%.
Team Adoption
Challenges:
- Learning curve - Team needed time to learn typing
- Resistance - “Python is dynamic, why add types?”
- Legacy code - Hard to type without refactoring
Solutions:
- Training - 2-hour workshop on type hints
- Show value - Demonstrated bugs caught by mypy
- Gradual - Didn’t force 100% coverage
Results
After 6 months:
- 127 type errors found in first scan
- 15+ production bugs prevented
- Code review time reduced by 30%
- Refactoring confidence increased
- IDE autocomplete improved
Best Practices
- Start with function signatures - Most valuable
- Use Optional - Be explicit about None
- Avoid Any - Defeats the purpose
- Use TypedDict - Better than Dict[str, any]
- Run mypy in CI - Enforce type checking
Common Pitfalls
1. Overusing Any:
# Bad
def process(data: Any) -> Any:
return data
# Good
def process(data: Dict[str, str]) -> List[str]:
return list(data.values())
2. Forgetting Optional:
# Bad
def find_user(user_id: int) -> User:
return db.query(...) # Might return None!
# Good
def find_user(user_id: int) -> Optional[User]:
return db.query(...)
3. Not checking None:
user = find_user(123) # Optional[User]
print(user.name) # Error if None!
# Fix
if user:
print(user.name)
Conclusion
Type hints and mypy have significantly improved our code quality. We catch bugs before they reach production, and refactoring is less scary.
Key takeaways:
- Type hints catch bugs early
- mypy integrates easily into CI/CD
- Gradual adoption works well
- Zero runtime performance impact
- Improves IDE experience
If you’re writing Python in production, use type hints. Your future self will thank you.