Your linter says it's fine. Your production server disagrees. Type hints are Python's fastest-growing feature — and a reliable source of bugs that mypy happily signs off on. Here are five patterns that pass the type checker and break at runtime.
When a function returns Optional[User], every caller is responsible for checking None before touching the result. mypy flags violations — in typed code. In an untyped or loosely typed caller, you get a free pass straight to AttributeError at runtime.
from typing import Optional
def get_user(user_id: int) -> Optional[User]:
return db.query(User).get(user_id)
# In an untyped caller:
def send_welcome_email(user_id: int):
user = get_user(user_id)
# If user is None, this crashes:
send_email(user.email, "Welcome!")
# ^^^^
# AttributeError: 'NoneType' has
# no attribute 'email'
from typing import Optional
def get_user(user_id: int) -> Optional[User]:
return db.query(User).get(user_id)
def send_welcome_email(user_id: int):
user = get_user(user_id)
if user is None:
logger.warning(
"user_not_found",
user_id=user_id
)
return
send_email(user.email, "Welcome!")
The function's return type advertises None as a possible value. Any call site that dereferences the result without a None check is a guaranteed crash when the record doesn't exist. CodeSight flags unchecked Optional dereferences and surfaces the specific line that will throw AttributeError.
The trap deepens with chained access: get_user(id).profile.avatar_url. Three potential AttributeErrors in one expression, all invisible to mypy when the chain starts from an untyped variable. Strict mypy mode with --strict catches some of these — but most codebases don't run strict, and legacy callers never will.
TypedDict gives you autocomplete and mypy validation at type-check time. At runtime it is a plain dict. No enforcement. Accessing a missing key still raises KeyError. Assigning a wrong type silently succeeds. The contract exists only in the linter's imagination.
from typing import TypedDict
class UserProfile(TypedDict):
name: str
email: str
age: int
def process_profile(profile: UserProfile):
# mypy: all good
print(profile["name"].upper())
print(profile["age"] + 1)
# At runtime, no validation happens:
data = {"name": "Alice"} # age missing
process_profile(data)
# KeyError: 'age'
# mypy never warned — data typed as
# dict[str, str], not UserProfile
from dataclasses import dataclass
# dataclass raises TypeError on missing
# fields at construction time
@dataclass
class UserProfile:
name: str
email: str
age: int
# Validate when data crosses a boundary
def parse_profile(raw: dict) -> UserProfile:
required = {"name", "email", "age"}
missing = required - raw.keys()
if missing:
raise ValueError(
f"Missing required fields: {missing}"
)
return UserProfile(**raw)
TypedDict fails wherever a typed function receives data from outside the typed module — API responses, DB rows, config files, JSON blobs. CodeSight flags TypedDict-annotated parameters used on data that crosses an unvalidated boundary (HTTP input, deserialized JSON, CLI args) without a parse/validation step between them.
The dangerous variant: TypedDict with total=False (all keys optional) combined with bracket access profile["key"] instead of profile.get("key"). mypy knows the key may be absent. If the access is outside a typed scope, it says nothing. The crash is guaranteed under certain inputs.
Assigning a List[Dog] to a List[Animal] looks reasonable — Dogs are Animals. But it breaks type safety: if you then append a Cat to the List[Animal] reference, you've put a Cat inside a List[Dog]. Python lists are invariant for this reason. The confusion lives at the Sequence / List boundary — Sequence is covariant (read-only), List is invariant (mutable). Mixing them is where bugs appear.
from typing import List
class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass
def add_cat(animals: List[Animal]) -> None:
animals.append(Cat())
dogs: List[Dog] = [Dog(), Dog()]
# mypy: error — correct, List is invariant
add_cat(dogs)
# But with a coercion via cast or Any:
from typing import cast, Any
add_cat(cast(List[Any], dogs))
# mypy: silent — runtime: dogs now contains
# a Cat. Next typed access to dogs[2]
# breaks assumptions downstream.
from typing import List, Sequence
class Animal: pass
class Dog(Animal): pass
# Read-only consumers declare Sequence
# (covariant — accepts List[Dog] safely)
def log_all(animals: Sequence[Animal]) -> None:
for a in animals:
print(type(a).__name__)
# Mutating consumers keep List (invariant)
def add_animal(
animals: List[Animal],
new: Animal
) -> None:
animals.append(new)
dogs: List[Dog] = [Dog()]
log_all(dogs) # OK — read-only path
# add_animal(dogs, Cat()) # mypy: error
The covariance rules are non-obvious and the failure is non-local. A cast(List[Any], ...)$ applied to suppress a mypy error on a generic container is a red flag — it tells mypy "trust me" while hiding a type-safety violation. CodeSight flags cast uses on generic containers and functions that accept Sequence[T] but call mutating methods on the parameter.
The underlying issue: Python erases generic type parameters at runtime. isinstance(dogs, List[Dog]) raises TypeError. You cannot inspect the element type of a list at runtime — only the container type. The type annotation and the runtime object are decoupled, and cast is the point where they silently diverge.
A decorator that wraps a function without functools.wraps and proper TypeVar usage replaces the original function's type signature with the wrapper's *args, **kwargs. mypy sees the outer wrapper. Every call site loses type coverage. Every argument mismatch becomes a silent runtime error.
import time
from typing import Callable, Any
def timed(func: Callable) -> Callable:
def wrapper(*args: Any, **kwargs: Any):
start = time.time()
result = func(*args, **kwargs)
print(f"{time.time()-start:.2f}s")
return result
return wrapper
@timed
def process_order(
order_id: int, urgent: bool
) -> dict:
...
# mypy: no type info — wrapper is Callable
# Passes wrong types silently at runtime:
process_order("bad-id", "yes")
import time, functools
from typing import TypeVar, Callable, cast
F = TypeVar("F", bound=Callable[..., object])
def timed(func: F) -> F:
@functools.wraps(func)
def wrapper(
*args: object, **kwargs: object
) -> object:
start = time.time()
result = func(*args, **kwargs)
print(f"{time.time()-start:.2f}s")
return result
return cast(F, wrapper)
@timed
def process_order(
order_id: int, urgent: bool
) -> dict:
...
# mypy now enforces original signature
process_order("bad-id", "yes") # error
Decorators look like annotations. Reviewers see @timed and move on — it's not a behavioral change. But without TypeVar + functools.wraps, every decorated function loses its type contract. CodeSight flags decorators that return bare Callable without preserving the wrapped function's type variable, and call sites where the argument types can no longer be verified.
The cascade: a shared @retry, @cache, or @rate_limit decorator that erases its wrapped signature silently disables mypy coverage across every call site in the codebase. One untyped wrapper, hundreds of unguarded callers. The bugs only appear in production, from user traffic, when someone passes the wrong thing to a function that mypy used to protect.
A class-level attribute assigned a mutable default (labels: list = []) is shared across every instance. Mutating it on one instance mutates it on all of them. mypy does not flag this. Python's dataclass decorator raises ValueError on bare mutable defaults to protect you — but the same bug survives in regular classes, and the field(default_factory=list) fix is easy to forget.
from typing import List
# Regular class — no ValueError warning.
# Shared state is invisible to mypy.
class ReviewConfig:
labels: List[str] = []
rules: dict = {}
def add_label(self, label: str) -> None:
self.labels.append(label)
a = ReviewConfig()
b = ReviewConfig()
a.add_label("security")
print(b.labels)
# ["security"] — b is corrupted.
# This survives every unit test that
# only creates one instance per test.
from dataclasses import dataclass, field
from typing import List, Dict
@dataclass
class ReviewConfig:
# Each instance gets its own list/dict
labels: List[str] = field(
default_factory=list
)
rules: Dict[str, str] = field(
default_factory=dict
)
def add_label(self, label: str) -> None:
self.labels.append(label)
a = ReviewConfig()
b = ReviewConfig()
a.add_label("security")
print(b.labels) # [] — correctly isolated
mypy types labels as List[str] on both versions — the shared-state bug is invisible to the type checker. The corruption only appears when two instances exist in the same scope, which unit tests that create one instance per test never exercise. CodeSight flags class-level mutable literal defaults (lists, dicts, sets) assigned outside dataclass field(default_factory=...).
The worst variant: a mutable default on a globally imported config singleton. Every module that imports and mutates the config is mutating the same object. State from one request leaks into the next. The bug is intermittent, order-dependent, and only reproducible under load — which is exactly when you have the least time to investigate.
Optional dereference detection, TypedDict boundary analysis, decorator signature review, mutable default flagging. Every Python PR in 30 seconds, before it merges.
Install Free on GitHub 5 PRs/month free · No credit card · Uninstall in one click