Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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. When the annotations are missing, incorrect, or overly broad, mypy either flags too much or lets bugs slip through.

The single most common source of mypy errors is Optional[T] not being 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. The runtime might tolerate this assumption, but mypy is conservative on purpose — production traffic eventually hits the path where the value is None, and the AttributeError ships to your error tracker. Missing type stubs for third-party libraries cause a different class of failure: mypy emits Cannot find implementation or library stub and either falls back to Any (silently weakening every signature that touches that library) or fails the check.

Other frequent causes: type mismatch in returns where a function annotated -> str sometimes returns None; Any silently spreading from untyped functions and weakening downstream type guarantees; mutable default arguments where def f(x: list = []) gives mypy list[Unknown] instead of list[int]; and --strict mode enabling additional checks that aren’t on by default, surfacing latent issues in code that previously passed.

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

Production Incident Patterns

A failing mypy check usually means one thing in practice: deploys are frozen. The blast radius is “no deploys until the type error is fixed,” and the on-call response depends entirely on whether the error is a real bug or a stub-package regression.

Scenario: stub package upgrade breaks the CI gate. A scheduled dependabot PR bumps types-requests from 2.31.0.10 to 2.32.0.20. The next merge to main fails mypy with Incompatible types in assignment on dozens of call sites because the stub authors tightened the return type of Response.json(). None of the application code changed, but deploys are blocked. The wrong fix is to revert the stub bump — that just defers the problem. The right fix is to add explicit type annotations at the call sites the new stub flagged, then merge.

Temporary unblock vs. proper fix. # type: ignore[assignment] is the production-incident equivalent of a feature flag rollback: it gets you unblocked immediately. Use it deliberately, with the error code spelled out, and file a follow-up ticket the same hour. A # type: ignore without an error code suppresses every future error on that line, including new ones, which is exactly how subtle type errors ship to production months later. Always pin the error code.

Detecting drift. Run mypy in CI with --warn-unused-ignores enabled. This flags # type: ignore markers whose underlying error has been fixed — a healthy codebase ratchets these down over time. Track the count of # type: ignore comments in a CI metric; if the number grows month-over-month, your type discipline is regressing. For the recovery path, prefer to fix the type at its source (add the missing annotation, narrow the Optional, install the correct stub) rather than papering over the symptom at the call site.

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.

Incremental mode reporting phantom errors — mypy’s incremental mode caches per-file analysis. If a base module’s types changed but a dependent file’s cache wasn’t invalidated, you may see errors that disappear on a clean run. When errors don’t reproduce locally but fail in CI (or vice versa), run with --no-incremental to confirm whether the cache is at fault.

Any silently swallowing failures — if mypy reports no errors but you suspect type problems, enable --warn-return-any and --disallow-any-expr. These flags surface places where Any flowed in from an untyped function and propagated through your code path, defeating the rest of your type checks.

For related Python issues, see Fix: Pydantic Validation Error, Fix: Python AttributeError: ‘NoneType’ has no attribute, Fix: Python TypeError: ‘NoneType’ object is not subscriptable, and Fix: Python TypeError: missing required positional argument.

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