Fix: Python mypy Type Error — Incompatible Types and Missing Annotations
Quick Answer
How to fix Python mypy type errors — incompatible types in assignment, missing return type, Optional handling, TypedDict, Protocol, overloads, and common mypy configuration mistakes.
The Problem
Running mypy on a Python codebase produces errors that are hard to interpret:
src/service.py:14: error: Incompatible types in assignment (expression has type "None", variable has type "str") [assignment]
src/service.py:28: error: Argument 1 to "process" has incompatible type "Optional[User]"; expected "User" [arg-type]
src/api/routes.py:45: error: Function is missing a return type annotation [no-untyped-def]
src/models.py:67: error: Item "None" of "Optional[str]" has no attribute "upper" [union-attr]
Found 24 errors in 5 files (checked 12 source files)Or mypy incorrectly flags code that works correctly at runtime:
def get_user(user_id: int) -> User:
user = db.query(User).filter_by(id=user_id).first()
return user # error: Incompatible return value type (got "Optional[User]", expected "User")Or third-party library types are missing:
src/app.py:1: error: Cannot find implementation or library stub for module named "requests"Why This Happens
mypy performs static type analysis — it checks type correctness without running the code. Unlike Python’s runtime, mypy can’t know what value a function returns at runtime; it only knows what the type annotations say it will return.
Common causes:
Optional[T]not narrowed — if a variable can beNone, mypy requires you to check forNonebefore calling methods on it, even if you “know” it won’t be None in practice.- Missing type stubs — third-party libraries without bundled type information (or a corresponding
types-*stub package) are unknown to mypy. - Type mismatch in returns — a function annotated
-> strthat sometimes returnsNonecauses an error. Anysilently spreading — usingAnyor calling untyped functions makes the return typeAny, which propagates silently until it reaches a typed context.- Mutable default arguments —
def f(x: list = [])has a default argument of typelist[Unknown], notlist[int]. --strictmode — mypy’s strict mode enables additional checks that aren’t on by default, making previously-passing code fail.
Fix 1: Handle Optional Types Correctly
The most common mypy error involves Optional[T] (which means T | None):
from typing import Optional
def get_name(user_id: int) -> Optional[str]:
user = find_user(user_id)
return user.name if user else None
# WRONG — mypy doesn't know name won't be None here
def greet(user_id: int) -> str:
name = get_name(user_id)
return f"Hello, {name.upper()}"
# error: Item "None" of "Optional[str]" has no attribute "upper"
# CORRECT — narrow the type with an explicit None check
def greet(user_id: int) -> str:
name = get_name(user_id)
if name is None:
return "Hello, stranger"
return f"Hello, {name.upper()}" # mypy knows name is str here
# Also correct — assert (raises AssertionError if None at runtime)
def greet_required(user_id: int) -> str:
name = get_name(user_id)
assert name is not None, "User name required"
return f"Hello, {name.upper()}"
# Also correct — use the walrus operator
def greet_walrus(user_id: int) -> str:
if name := get_name(user_id):
return f"Hello, {name.upper()}"
return "Hello, stranger"SQLAlchemy first() returns Optional — handle it:
from sqlalchemy.orm import Session
from typing import Optional
def get_user(session: Session, user_id: int) -> Optional[User]:
return session.query(User).filter_by(id=user_id).first()
# Return type matches Optional[User] — correct
# Caller must handle Optional
def get_user_name(session: Session, user_id: int) -> str:
user = get_user(session, user_id)
if user is None:
raise ValueError(f"User {user_id} not found")
return user.name # mypy knows user is User, not Optional[User]Fix 2: Add Missing Type Annotations
mypy reports [no-untyped-def] when functions lack annotations. Add return types and parameter types:
# WRONG — no annotations (in strict mode or with disallow_untyped_defs)
def calculate_tax(amount, rate):
return amount * rate
# CORRECT — fully annotated
def calculate_tax(amount: float, rate: float) -> float:
return amount * rate
# Functions that return nothing
def log_event(message: str) -> None:
print(f"[LOG] {message}")
# Functions that can raise (no special annotation needed for exceptions)
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# Optional parameters
from typing import Optional
def send_email(to: str, subject: str, body: str, cc: Optional[str] = None) -> bool:
# Implementation
return True
# Python 3.10+ — use X | None instead of Optional[X]
def send_email(to: str, subject: str, body: str, cc: str | None = None) -> bool:
return TrueAnnotate class methods correctly:
from __future__ import annotations # Enable postponed evaluation of annotations
class UserService:
def __init__(self, db: Database) -> None:
self.db = db
def find(self, user_id: int) -> User | None:
return self.db.get(User, user_id)
@classmethod
def from_config(cls, config: dict[str, str]) -> UserService:
db = Database(config['url'])
return cls(db)
@staticmethod
def validate_email(email: str) -> bool:
return '@' in emailFix 3: Install Type Stubs for Third-Party Libraries
mypy needs type information for every imported library. Many popular libraries include types directly or have stub packages:
# Check if a library has types built-in (mypy will find them automatically)
# Libraries with bundled types: requests (v2.28+), attrs, pydantic, SQLAlchemy 2.x
# Install stub packages for libraries without built-in types
pip install types-requests # For requests
pip install types-PyYAML # For PyYAML
pip install types-redis # For redis-py
pip install types-Pillow # For Pillow/PIL
pip install types-boto3 # For boto3 (AWS SDK)
pip install types-psycopg2 # For psycopg2
# Find stub packages: pip search "types-"
# Or check: https://github.com/python/typeshedWhen no stubs exist — create a local stub or use type: ignore:
# Option 1 — use type: ignore for one line (adds Any)
import untyped_library # type: ignore[import]
# Option 2 — create a stub file: stubs/untyped_library.pyi
# stubs/untyped_library.pyi
def some_function(arg: str) -> int: ...
class SomeClass:
def method(self) -> None: ...
# Option 3 — mark it as Any in mypy.ini
# [mypy-untyped_library.*]
# ignore_missing_imports = TrueConfigure mypy to ignore missing imports for specific packages:
# mypy.ini or setup.cfg
[mypy]
python_version = 3.12
strict = true
[mypy-some_untyped_package.*]
ignore_missing_imports = True
[mypy-another_package]
ignore_missing_imports = TrueFix 4: Fix TypedDict for Dictionary Types
Using dict with string keys and mixed values is too loose for mypy. Use TypedDict for structured dictionaries:
# WRONG — dict[str, Any] loses type information
def get_config() -> dict[str, Any]:
return {"host": "localhost", "port": 5432, "debug": True}
config = get_config()
host = config["host"] # host is Any — mypy can't catch mistakes
config["prot"] = 5432 # Typo — mypy won't catch this
# CORRECT — TypedDict for structured dicts
from typing import TypedDict
class DatabaseConfig(TypedDict):
host: str
port: int
debug: bool
def get_config() -> DatabaseConfig:
return {"host": "localhost", "port": 5432, "debug": True}
config = get_config()
host: str = config["host"] # host is str — correctly typed
config["prot"] = 5432 # error: TypedDict "DatabaseConfig" has no key "prot"
# Optional keys in TypedDict (Python 3.11+)
class QueryOptions(TypedDict, total=False): # total=False makes all keys optional
limit: int
offset: int
order_by: strFix 5: Use Protocol for Structural Typing
Instead of inheriting from abstract base classes, use Protocol for duck typing:
from typing import Protocol, runtime_checkable
# Define what the type needs to support (structural subtyping)
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict[str, object]: ...
def to_json(self) -> str: ...
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
def to_dict(self) -> dict[str, object]:
return {"name": self.name, "email": self.email}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
# User satisfies Serializable even without explicitly inheriting from it
def serialize(obj: Serializable) -> str:
return obj.to_json()
user = User("Alice", "[email protected]")
serialize(user) # mypy accepts this — User matches the Serializable protocol
# Check at runtime
print(isinstance(user, Serializable)) # True (with @runtime_checkable)Fix 6: Fix Common Type Narrowing Issues
mypy uses type narrowing — after an if check, it knows the type within that block:
from typing import Union
def process(value: Union[str, int, None]) -> str:
# Type narrowing with isinstance
if isinstance(value, str):
return value.upper() # mypy knows value is str here
elif isinstance(value, int):
return str(value * 2) # mypy knows value is int here
else:
return "unknown" # mypy knows value is None here
# TypeGuard for custom type narrowing functions
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process_strings(items: list[object]) -> list[str]:
if is_string_list(items):
return [s.upper() for s in items] # mypy knows items is list[str] here
return []
# Literal types for exhaustive checks
from typing import Literal
Status = Literal["active", "inactive", "deleted"]
def get_message(status: Status) -> str:
if status == "active":
return "User is active"
elif status == "inactive":
return "User is inactive"
elif status == "deleted":
return "User is deleted"
# mypy knows this is unreachable — all Literal values handled
# Add: assert_never(status) for strict exhaustiveness checkingassert_never for exhaustive match:
from typing import NoReturn, Literal
def assert_never(value: NoReturn) -> NoReturn:
raise AssertionError(f"Unexpected value: {value}")
def process_status(status: Literal["active", "inactive"]) -> str:
if status == "active":
return "Active"
elif status == "inactive":
return "Inactive"
else:
assert_never(status) # mypy catches if you add a new Literal value without handling itFix 7: Configure mypy for the Right Strictness Level
Start with lenient settings and gradually increase strictness:
# mypy.ini
[mypy]
python_version = 3.12
# Start lenient — good for adding types to an existing codebase
# ignore_errors = False (default)
# disallow_untyped_defs = False (default)
# Medium strictness
warn_return_any = True
warn_unused_ignores = True
no_implicit_reexport = True
# Strict mode — enables all checks (can be overwhelming on an existing codebase)
# strict = True
# Ignore specific files or directories during migration
exclude = [
'migrations/',
'tests/',
'conftest.py',
]Enable strict mode per-file using inline config:
# mypy: strict
# (Put at top of file to enable strict mode for just this file)
def fully_typed_function(x: int) -> str:
return str(x)Gradually adopt types using # type: ignore with error codes:
# Suppress specific error types instead of all errors
result = some_untyped_function() # type: ignore[no-untyped-call]
value: str = result # type: ignore[assignment]
# Track suppressed errors — mypy --show-error-codes shows codes for each error
# Run: mypy --ignore-missing-imports src/ to find errors without missing stubs firstStill Not Working?
mypy cache causing stale errors — clear mypy’s cache when errors persist after fixing the code:
rm -rf .mypy_cache
mypy src/Generics not preserving type variables — if you’re writing generic functions, use TypeVar:
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T | None:
return items[0] if items else None
result = first([1, 2, 3]) # result is int | None — correctly inferredOverloaded functions for different return types:
from typing import overload
@overload
def process(value: str) -> str: ...
@overload
def process(value: int) -> int: ...
def process(value: str | int) -> str | int:
if isinstance(value, str):
return value.upper()
return value * 2mypy version incompatibility with Python version — ensure mypy supports your Python version. Run mypy --version and check the mypy changelog for compatibility notes.
For related Python issues, see Fix: Python asyncio Blocking the Event Loop and Fix: Python Import Circular Dependency.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: BullMQ Not Working — Jobs Not Processing, Workers Not Starting, or Redis Connection Failing
How to fix BullMQ issues — queue and worker setup, Redis connection, job scheduling, retry strategies, concurrency, rate limiting, event listeners, and dashboard monitoring.