Skip to content

Fix: Python mypy Type Error — Incompatible Types and Missing Annotations

FixDevs ·

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 be None, mypy requires you to check for None before 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 -> str that sometimes returns None causes an error.
  • Any silently spreading — using Any or calling untyped functions makes the return type Any, which propagates silently until it reaches a typed context.
  • Mutable default argumentsdef f(x: list = []) has a default argument of type list[Unknown], not list[int].
  • --strict mode — 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 True

Annotate 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 email

Fix 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/typeshed

When 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 = True

Configure 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 = True

Fix 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: str

Fix 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 checking

assert_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 it

Fix 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 first

Still 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 inferred

Overloaded 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 * 2

mypy 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles