Direct Answer
with_loader_criteria() is not the replacement for with_polymorphic(). They serve different purposes: with_polymorphic() changes the entity mapping (which columns are selected and how rows are instantiated), while with_loader_criteria() only adds filtering clauses to loader queries.
Confirmed Migration Patterns
1. Relationship-based polymorphic loading (most common)
Apply with_polymorphic() to the loader option, not the Select object:
from sqlalchemy.orm import selectinload, joinedload
# For collections — prefer selectinload to avoid wide JOINs
stmt = select(Parent).options(
selectinload(Parent.children).with_polymorphic([ChildA, ChildB])
)
# For single-parent relationships — joinedload is fine
stmt = select(Parent).options(
joinedload(Parent.child).with_polymorphic([ChildA, ChildB])
)
The with_polymorphic() method here is on the loader strategy (SelectInLoad/JoinedLoad), not on Select.
2. Top-level polymorphic entity (no relationship)
Use the standalone with_polymorphic() function from sqlalchemy.orm as the select() target:
from sqlalchemy.orm import with_polymorphic
PolyParent = with_polymorphic(Parent, [ChildA, ChildB])
stmt = select(PolyParent).where(PolyParent.type == 'child_a')
results = session.execute(stmt).scalars().all()
This returns an AliasedClass that maps all subclass columns in one query.
3. Explicit join control with contains_eager()
When you need manual JOINs (e.g., custom ON clauses, lateral joins):
from sqlalchemy.orm import contains_eager, aliased
ChildA_alias = aliased(ChildA)
ChildB_alias = aliased(ChildB)
stmt = (
select(Parent)
.join(ChildA_alias, Parent.id == ChildA_alias.parent_id, isouter=True)
.join(ChildB_alias, Parent.id == ChildB_alias.parent_id, isouter=True)
.options(
contains_eager(Parent.child_a, alias=ChildA_alias),
contains_eager(Parent.child_b, alias=ChildB_alias),
)
)
When to Use Each Approach
| Scenario | Recommended Pattern |
| Loading a collection/relationship with subclasses | selectinload(...).with_polymorphic([...]) |
| Loading a single related object with subclasses | joinedload(...).with_polymorphic([...]) |
| Querying the polymorphic entity directly (no parent relationship) | select(with_polymorphic(Parent, [...])) |
| Custom JOIN conditions or lateral joins | Manual join() + contains_eager() |
| Filtering which subclasses participate in a loader query | with_loader_criteria() (adds WHERE/ON, not entity mapping) |
Key Behavioral Differences
- Entity mapping:
with_polymorphic() on a loader or standalone function includes subclass columns in the SELECT and instantiates the correct subclass type. with_loader_criteria() does not.
- Scope:
with_loader_criteria() applies globally to the session unless scoped with include_aliases or propagate_to_loaders. Loader options are per-query.
- JOIN strategy:
joinedload.with_polymorphic() produces LEFT OUTER JOINs for each subclass — wide hierarchies can explode row counts. selectinload issues a second SELECT with IN clause, usually more efficient for collections.
Verification Steps
- Confirm SQLAlchemy version:
import sqlalchemy; print(sqlalchemy.__version__) (must be ≥ 2.0.0)
- Enable SQL echo:
create_engine(..., echo=True) to compare generated SQL against legacy Query.with_polymorphic()
- Check returned types:
isinstance(obj, ChildA) after loading
- Compare row counts and column sets between old and new approaches
One Diagnostic Question
Are you loading polymorphic subclasses through a relationship (e.g., Parent.children) or querying the base entity directly without a parent relationship? The answer determines whether you use loader options (pattern 1) or the standalone with_polymorphic() function (pattern 2).