2025, Dec 12 21:00
Preventing invalid SQLAlchemy ORM equality comparisons with mypy: types, overloads, and plugins
Learn why mypy won't flag mismatched SQLAlchemy ORM equality checks and how to fix them with typed columns, __eq__ overloads, and a small plugin to catch errors.
Static typing and SQLAlchemy can live together, but there is a sharp edge that often surprises people: equality checks on ORM columns. You can compare an integer column to a string, get a perfectly valid SQL expression object, and mypy won’t complain. If you expect a Non-overlapping equality check or reportUnnecessaryComparison warning, you won’t see it out of the box.
Problem statement
The setup follows SQLAlchemy’s mypy plugin documentation. The code compares an integer primary key to a string inside a where clause, and the type checker stays silent.
from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import declarative_base
OrmBase = declarative_base()
class Account(OrmBase):
__tablename__ = "account"
id = Column(Integer, primary_key=True)
label = Column(String)
q = select(
Account.label
).where(
Account.id == "test" # comparing an int column to a str
)
Type-checking is invoked as follows:
mypy --strict --config-file mypy.ini test.py
And the configuration uses the SQLAlchemy plugin:
[mypy]
plugins = sqlalchemy.ext.mypy.plugin
Why mypy doesn’t flag it
From a native typing perspective, the equality operator on an ORM column returns a ColumnElement[bool]. That’s a proper, non-trivial expression object that the ORM’s where method accepts. In other words, the type system only sees “a valid boolean SQL expression,” not “an obviously incompatible equality check.” Because Column.__eq__ yields ColumnElement[bool], the type checker has no basis for a Non-overlapping equality check or reportUnnecessaryComparison warning.
A practical workaround
If you want the type checker to push back on clearly incompatible comparisons, you can introduce a more specific column type and overload its equality operator. The idea is simple: let equality with the correct Python type produce a normal SQL boolean expression, but let equality with any other type be statically false. That way the type checker has a signal it can act upon.
class IntField(Column[int]):
if TYPE_CHECKING:
@overload
def __eq__(self, other: int) -> ColumnElement[bool]: # type: ignore[override]
...
@overload
def __eq__(self, other: object) -> Literal[False]:
...
class Account(OrmBase):
__tablename__ = "account"
id = IntField(Integer, primary_key=True)
label = Column(String)
With this approach, comparing the integer id to a string becomes a statically false comparison, which a type checker can surface as an error. There is a trade-off, though: if you compare the column to a non-literal union, for example when obj: int | str, an expression like Account.id == obj will also be flagged. While that’s runtime-safe for SQLAlchemy, it will still produce a type error.
There is an alternative signature: make the second overload return ColumnElement[Literal[False]] instead. This prevents errors in the general case, but it also means a string comparison won’t be reported anymore.
These ideas also point toward writing a custom plugin that treats ColumnElement[Literal[False]] as an error and, if desired, avoids relying on --strict-equality. That’s an avenue to explore if you need tighter guarantees within your project.
Why this matters
SQLAlchemy’s operator overloads are designed to build SQL expression trees, not to enforce Python-level value compatibility. The result is ergonomic for query construction, but it hides obvious mismatches from static analysis. If your team relies on mypy to catch incorrect comparisons, this gap can let subtle bugs slip into review undetected.
Conclusion
Expecting mypy to flag non-overlapping equality checks on ORM columns won’t work with native typing alone because Column.__eq__ intentionally returns a valid ColumnElement[bool]. If you need the type checker to complain, specialize your columns and overload __eq__ so that incompatible comparisons become statically false. Be aware of the limitations with union types, and consider building a small plugin around ColumnElement[Literal[False]] if you want a more systematic enforcement. Understanding where SQL expression typing diverges from Python value typing helps you choose when to tighten checks and when to trust runtime behavior.