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:

  1. Learning curve - Team needed time to learn typing
  2. Resistance - “Python is dynamic, why add types?”
  3. Legacy code - Hard to type without refactoring

Solutions:

  1. Training - 2-hour workshop on type hints
  2. Show value - Demonstrated bugs caught by mypy
  3. 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

  1. Start with function signatures - Most valuable
  2. Use Optional - Be explicit about None
  3. Avoid Any - Defeats the purpose
  4. Use TypedDict - Better than Dict[str, any]
  5. 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:

  1. Type hints catch bugs early
  2. mypy integrates easily into CI/CD
  3. Gradual adoption works well
  4. Zero runtime performance impact
  5. Improves IDE experience

If you’re writing Python in production, use type hints. Your future self will thank you.