✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

✨ Add support for SQLAlchemy polymorphic models - #1226

Open
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support
Open

✨ Add support for SQLAlchemy polymorphic models#1226
PaleNeutron wants to merge 27 commits into
fastapi:mainfrom
PaleNeutron:sqlalchemy_polymorphic_support

Conversation

@PaleNeutron

@PaleNeutronPaleNeutron commented Nov 26, 2024

Copy link
Copy Markdown

Introduce support for SQLAlchemy polymorphic models by adjusting field defaults and handling inheritance correctly in the SQLModel metaclass. Add tests to verify functionality with polymorphic joined and single table inheritance. Refer to #36 .

@PaleNeutron
PaleNeutron marked this pull request as ready for review November 26, 2024 03:26
@PaleNeutronPaleNeutron changed the title Support SQLAlchemy polymorphic modelsAdd support for SQLAlchemy polymorphic modelsNov 26, 2024
@PaleNeutronPaleNeutron changed the title Add support for SQLAlchemy polymorphic models[feature] Add support for SQLAlchemy polymorphic modelsNov 26, 2024
@mmx86

mmx86 commented Dec 2, 2024

Copy link
Copy Markdown

@tiangolo
Could you please comment on whether this request has a good chance of being merged?
My team and I, being under time constraints, are currently trying to decide whether to commit to this feature already.

Comment threadsqlmodel/_compat.py Outdated
Co-authored-by: John Pocock <John-P@users.noreply.github.com>
@ndeybach

Copy link
Copy Markdown

We are also exploring using SQLModel in our products. This would be quite an ease of life in how we are building our stack.

@tiangolo do you have a timeline as to when could this be merged / what needs to be done ?

@guhur

guhur commented Jan 3, 2025

Copy link
Copy Markdown

Thanks a lot for this PR! We would love to add this feature in our codebase.

Unfortunately, we could not use this PR along with a custom type.

@PaleNeutron would you mind checking this MRE?

(1) the code works fine if you comment DarkHero or if you comment my_model.

(2) however, it fails if both are in the module!

Code

importjsonimporttypingastfromfastapi.encodersimportjsonable_encoderfrompydanticimportBaseModel, TypeAdapter# Warning: we import a deprecated class from the `pydantic` package# See: https://github.com/pydantic/pydantic/issues/6381frompydantic._internal._model_constructionimportModelMetaclass# noqa: PLC2701fromsqlalchemy.engine.interfacesimportDialectfromsqlalchemy.ormimportmapped_columnfromsqlalchemy.sql.type_apiimport_BindProcessorType, _ResultProcessorTypefromsqlmodelimport (
JSON,
Column,
Field,
Session,
SQLModel,
TypeDecorator,
create_engine,
select,
)
defpydantic_column_type( # noqa: C901pydantic_type: type[t.Any],
) ->type[TypeDecorator]:
""" See details here: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082 """T=t.TypeVar("T")
classPydanticJSONType(TypeDecorator, t.Generic[T]):
impl=JSON()
cache_ok=Falsedef__init__(
self,
json_encoder: t.Any=json,
):
self.json_encoder=json_encodersuper().__init__()
defbind_processor(self, dialect: Dialect) ->_BindProcessorType[T] |None:
impl_processor=self.impl.bind_processor(dialect)
ifimpl_processor:
defprocess(value: T|None) ->T|None:
ifvalueisnotNone:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuevalue=jsonable_encoder(value_to_dump)
returnimpl_processor(value)
else:
defprocess(value: T|None) ->T|None:
ifisinstance(pydantic_type, ModelMetaclass):
value_to_dump=pydantic_type.model_validate(value)
else:
value_to_dump=valuereturnjsonable_encoder(value_to_dump)
returnprocessdefresult_processor(
self,
dialect: Dialect,
coltype: object,
) ->_ResultProcessorType[T] |None:
impl_processor=self.impl.result_processor(dialect, coltype)
ifimpl_processor:
defprocess(value: T) ->T|None:
value=impl_processor(value)
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
else:
defprocess(value: T) ->T|None:
ifvalueisNone:
returnNoneifisinstance(value, str):
value=json.loads(value)
returnTypeAdapter(pydantic_type).validate_python(value)
returnprocessdefcompare_values(self, x: t.Any, y: t.Any) ->bool:
returnx==yreturnPydanticJSONTypeclassMyModel(BaseModel):
name: str|None=NoneclassComplexModel(SQLModel, table=True):
id: t.Annotated[
int|None,
Field(
default=None,
primary_key=True,
),
] =Nonemy_model: t.Annotated[
MyModel|None,
Field(
sa_column=Column(pydantic_column_type(MyModel)),
),
] =NoneclassHero(SQLModel, table=True):
__tablename__="hero"id: int|None=Field(default=None, primary_key=True)
hero_type: str=Field(default="hero")
__mapper_args__= {
"polymorphic_on": "hero_type",
"polymorphic_identity": "hero",
}
classDarkHero(Hero):
dark_power: str=Field(
default="dark",
sa_column=mapped_column(
nullable=False, use_existing_column=True, default="dark"
),
)
__mapper_args__= {
"polymorphic_identity": "dark",
}
engine=create_engine("sqlite:///:memory:", echo=True)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
hero=Hero()
db.add(hero)
dark_hero=DarkHero(dark_power="pokey")
db.add(dark_hero)
db.commit()
statement=select(DarkHero)
result=db.exec(statement).all()
assertlen(result) ==1assertisinstance(result[0].dark_power, str)

Corresponding error code

python test.py
Traceback (most recent call last):
File "/Users/guhur/src/argile-lib-python/test.py", line 101, in<module>
class DarkHero(Hero):
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/sqlmodel/main.py", line 542, in __new__
new_cls = super().__new__(cls, name, bases, dict_used, **config_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 202, in __new__
complete_model_class(
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 572, in complete_model_class
generate_pydantic_signature(init=cls.__init__, fields=cls.model_fields, config_wrapper=config_wrapper),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 159, in generate_pydantic_signature
merged_params = _generate_signature_parameters(init, fields, config_wrapper)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_signature.py", line 115, in _generate_signature_parameters
kwargs = {} iffield.is_required() else {'default': field.get_default(call_default_factory=False)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/fields.py", line 546, in get_default
return _utils.smart_deepcopy(self.default)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/guhur/Library/Caches/pypoetry/virtualenvs/argile-lib-python-RxGRaJe1-py3.11/lib/python3.11/site-packages/pydantic/_internal/_utils.py", line 318, in smart_deepcopy
return deepcopy(obj) # slowest way when we actually might need a deepcopy
^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 265, in _reconstruct
y = func(*args)
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 264, in<genexpr>
args = (deepcopy(arg, memo) forargin args)
^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 206, in _deepcopy_list
append(deepcopy(a, memo))
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in _deepcopy_tuple
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 211, in<listcomp>
y = [deepcopy(a, memo) forain x]
^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 172, in deepcopy
y = _reconstruct(x, memo, *rv)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 271, in _reconstruct
state = deepcopy(state, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 146, in deepcopy
y = copier(x, memo)
^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 231, in _deepcopy_dict
y[deepcopy(key, memo)] = deepcopy(value, memo)
^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.11/3.11.8/Frameworks/Python.framework/Versions/3.11/lib/python3.11/copy.py", line 161, in deepcopy
rv = reductor(4)
^^^^^^^^^^^
TypeError: cannot pickle 'module' object

@adsharma

Copy link
Copy Markdown

This could help with a different kind of polymorphism. Details here.

Specifically:

@fquery.sqlmodel.model()
@dataclass
class Hero:
...

Creates two classes Hero (dataclass) and HereoSQLModel (sqlmodel).

Using polymorphism, we could allow the caller to return Hero (dataclass, cheaper when static typing is good enough) or HeroSQLModel if runtime validation is needed.

@PaleNeutron

Copy link
Copy Markdown
Author

@guhur , good test, I found a bug through it.

@0x003e

Copy link
Copy Markdown

Hello, could someone please approve this merge request ?
Thank you.

@jensrischbieth

jensrischbieth commented Jul 8, 2025

Copy link
Copy Markdown

@PaleNeutron, I have found another potential issue.

Consider the following linked list:

fromtypingimportOptionalfromsqlmodelimportField, Relationship, SQLModelclassBaseNode(SQLModel, table=True):
__tablename__='node_table'id: str=Field(primary_key=True)
node_type: str# Self-referential relationship - this causes the issuenext_id: Optional[str] =Field(default=None, foreign_key='node_table.id')
next: Optional['BaseNode'] =Relationship(
sa_relationship_kwargs={
'remote_side': '[BaseNode.id]',
'uselist': False
}
)
__mapper_args__= {
'polymorphic_on': 'node_type',
'polymorphic_identity': 'base',
}
classEmailNode(BaseNode):
__mapper_args__= {
'polymorphic_identity': 'email',
}
# Create two nodesnode1=EmailNode(id="1", node_type="email")
node2=EmailNode(id="2", node_type="email")
try:
node1.next=node2# This failsexceptAttributeErrorase:
print(e)
# This works because it's just a regular fieldtry:
node1.next_id="2"# This worksexceptExceptionase:
print(e)
'next' is a ClassVar of `EmailNode` and cannot be set on an instance. If you want to set a value on the class, use `EmailNode.next = value`.

Thanks again for your contributions! Hopefully they can be merged soon!

@PaleNeutron

Copy link
Copy Markdown
Author

@jensrischbieth Confirmed, working on it.

@budroco

budroco commented Aug 13, 2025

Copy link
Copy Markdown

Thanks for this PR!

I constructed a simple example, in case anybody wants something to quickly evaluate.

The initial result looks quite promising to me and may mean we won't have to migrate away from SQLModel after all.

In difference to test_polymorphic_model.py I swapped mapped_column with Column to get rid of type errors and just ignored the error on __tablename__ because it's so small and doesn't propagate. Now the entire script is free of type errors (at least in my IDE)!

Let's see how merging this PR goes, it would alleviate a lot of pain for a lot of people.

# This is a uv script. Run with `uv run` and dependencies will be fetched on the fly.## /// script# requires-python = ">=3.9,<3.14"# dependencies = [# "sqlmodel @ git+https://github.com/PaleNeutron/sqlmodel@sqlalchemy_polymorphic_support"# ]# ///# Adapted from https://github.com/PaleNeutron/sqlmodel/blob/64f774fb3b5b66ef8d55aab0b26a7733146e60a8/tests/test_polymorphic_model.pyfromtypingimportOptionalfromsqlalchemyimportColumn, IntegerfromsqlmodelimportField, Session, SQLModel, create_engine, selectclassAnimal(SQLModel, table=True):
__tablename__="animal"# type: ignoreid: Optional[int] =Field(default=None, primary_key=True)
name: strtype: str=Field(default="animal")
__mapper_args__= {
"polymorphic_on": "type",
"polymorphic_identity": "animal",
}
classCat(Animal):
meow_cuteness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "cat"}
classDog(Animal):
bark_loudness: int=Field(sa_column=Column(Integer, nullable=True), default=None)
__mapper_args__= {"polymorphic_identity": "dog"}
if__name__=="__main__":
# Create database and sessionengine=create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
withSession(engine) asdb:
db.add_all(
[
Animal(name="Generic Animal"),
Cat(name="Whiskers", meow_cuteness=10),
Dog(name="Rocky", bark_loudness=8),
]
)
db.commit()
animals=db.exec(select(Animal)).all()
print("All animals:", animals)
cats=db.exec(select(Cat)).all()
print("All cats:", cats)
dogs=db.exec(select(Dog)).all()
print("All dogs:", dogs)

Result:

All animals: [Animal(type='animal', name='Generic Animal', id=1), Cat(type='cat', name='Whiskers', id=2), Dog(type='dog', name='Rocky', id=3)]
All cats: [Cat(type='cat', name='Whiskers', id=2, meow_cuteness=10)]
All dogs: [Dog(type='dog', name='Rocky', id=3, bark_loudness=8)]

@ssoimada

Copy link
Copy Markdown

Nice work, please release this!

@piewared

Copy link
Copy Markdown

Can this be merged in soon? Don't want to have to abandon SQLModel, but polymorphism is really important for us

@a0s

a0s commented Aug 21, 2025

Copy link
Copy Markdown

MissingGreenlet Error with Polymorphic Models and Lazy Loading

Problem Description

When accessing lazy-loaded attributes on polymorphic SQLAlchemy models in async context, getting MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here error.

Minimal Example

fromsqlalchemyimportColumn, String, ForeignKey, selectfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlmodelimportSQLModel, Field# Base polymorphic modelclassBaseConnection(SQLModel, table=True):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id: int=Field(primary_key=True)
type: str=Field(sa_column=Column(String(50)))
# Derived polymorphic model with FKclassWireGuardConnection(BaseConnection, table=False):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id: int=Field(sa_column=Column(ForeignKey("peers.id"), nullable=True))
asyncdefdelete_connection(session: AsyncSession, connection_id: int):
# Initial query works finestmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# This triggers MissingGreenlet error due to lazy loadingifhasattr(connection, "peer_id") andconnection.peer_id: # ❌ Error hereprint(f"Peer ID: {connection.peer_id}")

Error Details

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here.
Was IO attempted in an unexpected place?

Root Cause

When accessing connection.peer_id, SQLAlchemy tries to perform lazy loading to fetch the attribute, but this happens outside the proper greenlet context.

This issue specifically occurs with polymorphic inheritance when trying to access subclass attributes that weren't loaded in the initial query.

Solution 1: Use session.refresh()

Use session.refresh() to eager load all attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to load all attributes properlyawaitsession.refresh(connection) # ✅ Fix# Now safe to access attributesifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 2: Use joinedload or selectinload

Load relationships eagerly in the initial query:

fromsqlalchemy.ormimportselectinloadasyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt= (
select(BaseConnection)
.options(selectinload("*")) # Load all relationships
.where(BaseConnection.id==connection_id)
)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Safe to access attributes - already loadedifhasattr(connection, "peer_id") andconnection.peer_id:
print(f"Peer ID: {connection.peer_id}")

Solution 3: Type-safe approach

Check the type before accessing subclass attributes:

asyncdefdelete_connection(session: AsyncSession, connection_id: int):
stmt=select(BaseConnection).where(BaseConnection.id==connection_id)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Refresh to be safeawaitsession.refresh(connection)
# Type-safe checkifconnection.type=="wireguard":
wireguard_conn=connection# Type hint: WireGuardConnectionifwireguard_conn.peer_id:
print(f"Peer ID: {wireguard_conn.peer_id}")

Environment

  • SQLAlchemy: 2.x (async)
  • SQLModel/FastAPI stack
  • PostgreSQL with asyncpg

Related Issues

Prevention

  1. Always use await session.refresh(obj) before accessing subclass attributes
  2. Use eager loading strategies (selectinload, joinedload) when you know you'll need related data
  3. Consider using explicit type checks instead of hasattr() for polymorphic models

@PaleNeutron

PaleNeutron commented Aug 22, 2025

Copy link
Copy Markdown
Author

@a0s , I think you will face the same error in pure sqlalchemy, check this test below:

full test code
importasynciofromsqlalchemyimportColumn, ForeignKey, Integer, String, selectfromsqlalchemy.ext.asyncioimportAsyncSession, create_async_enginefromsqlalchemy.ormimportdeclarative_base, joinedload, relationship, sessionmaker# A base for our declarative modelsBase=declarative_base()
# A hypothetical Peer modelclassPeer(Base):
__tablename__="peers"id=Column(Integer, primary_key=True)
name=Column(String)
def__repr__(self):
returnf"Peer(id={self.id}, name='{self.name}')"# Base polymorphic modelclassBaseConnection(Base):
__tablename__="connections"__mapper_args__= {"polymorphic_on": "type"}
id=Column(Integer, primary_key=True)
type=Column(String(50))
def__repr__(self):
returnf"BaseConnection(id={self.id})"# Derived polymorphic model with a foreign keyclassWireGuardConnection(BaseConnection):
__mapper_args__= {"polymorphic_identity": "wireguard"}
peer_id=Column(ForeignKey("peers.id"))
# This is the relationship that would cause lazy loading.# When you access `connection.peer`, SQLAlchemy will query the `peers` table.peer=relationship("Peer")
def__repr__(self):
returnf"WireGuardConnection(id={self.id})"asyncdefsetup_db(engine):
"""Create database tables."""asyncwithengine.begin() asconn:
awaitconn.run_sync(Base.metadata.create_all)
asyncdefseed_data(session: AsyncSession):
"""Insert some example data."""print("--- Inserting example data ---")
peer1=Peer(name="user_A_peer")
connection1=WireGuardConnection()
connection1.peer=peer1# SQLAlchemy will automatically set peer_idsession.add_all([peer1, connection1])
awaitsession.commit()
print("Data insertion complete.")
asyncdefdemonstrate_bug(Session: sessionmaker):
"""Demonstrates the MissingGreenlet error caused by lazy loading."""asyncwithSession() assession:
# Get the connection objectstmt=select(BaseConnection).where(BaseConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
# Attempt to access a lazy-loaded attribute after the session is closedprint("\n--- Attempting to access lazy-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ❌ ERROR: This will attempt to execute a new database query, but the session is inactive# This is the MissingGreenlet error you might encounterprint(f"✅ Access successful: peer id is {connection.peer_id}")
exceptExceptionase:
print(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefdemonstrate_fix(Session: sessionmaker):
"""Demonstrates how to fix the issue using eager loading."""asyncwithSession() assession:
# Use joinedload to eagerly load the 'peer' relationship# select(WireGuardConnection) ensures we're loading the subclass that has the 'peer' relationshipstmt=select(WireGuardConnection).where(WireGuardConnection.id==1)
result=awaitsession.execute(stmt)
connection=result.scalar_one_or_none()
print("\n--- Attempting to access eagerly-loaded 'connection.peer_id' ---")
print(f"Current object: {connection}")
try:
# ✅ OK: The 'peer' data was loaded in the initial query, so no new database query is neededprint(f"✅ Access successful: peer id is {connection.peer_id}")
print("Since the data was eagerly loaded, no new query is triggered.")
exceptExceptionase:
# No error will occur hereprint(f"❌ Caught error: {type(e).__name__}: {e}")
asyncdefmain():
# Use an in-memory databaseengine=create_async_engine("sqlite+aiosqlite:///:memory:", echo=True)
# Create an async session with a crucial setting: expire_on_commit=FalseSession=sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
awaitsetup_db(engine)
# Use a session to insert dataasyncwithSession() assession:
awaitseed_data(session)
# Run the demonstrationsawaitdemonstrate_bug(Session)
awaitdemonstrate_fix(Session)
# Close the engine connection poolawaitengine.dispose()
if__name__=="__main__":
asyncio.run(main())

If you look at the SQL queries being logged to the console, you'll see exactly why this is happening.

The Lazy-Loading Issue

Initially, your code using select(BaseConnection) explicitly tells SQLAlchemy to only fetch the id and type columns. That's why the first query is:

SELECTconnections.id, connections.typeFROM connections

Notice that the peer_id column isn't part of this initial selection.

When your code later tries to access the connection.peer attribute, SQLAlchemy attempts to lazy-load this unloaded data. This triggers a second, immediate query to fetch just the missing peer_id:

SELECTconnections.peer_idAS connections_peer_id FROM connections...

The problem is that this lazy-loading operation doesn't work correctly in an async context. It tries to perform I/O without the required await, which causes the MissingGreenlet error you're seeing.

BTW, even the code will work in sync sqlalchemy, it will harm performance seriously and should be treated as a bug. It will perform an implicit select query for each item.


Recommendation for FastAPI Users

Because of complex I/O issues like this, I don't recommend using async database sessions and async api functions for junior developers.

FastAPI (via Starlette) is designed to run synchronous functions, like a standard database call, in a separate thread pool. This means a regular, synchronous I/O operation will not block the main application thread. For this reason, it's often much simpler and safer to stick with synchronous database sessions in your app (most internal web app's concurrency is lower than your worker number). If you use sync io function in async api route, you may encounter issues with blocking the event loop which is the worst case scenario for performance in a fastapi application.

@aidenprice

Copy link
Copy Markdown

Is polymorphic_load: inline a potential solution to @a0s problem?

For example;

__mapper_args__ = {
"polymorphic_identity": "wireguard",
"polymorphic_load": "inline",
}

See SQLAlchemy docs https://docs.sqlalchemy.org/en/20/orm/queryguide/inheritance.html#configuring-with-polymorphic-on-mappers

@yurii-franasiuk

Copy link
Copy Markdown

I understand that polymorphic model support has only been implemented based on Single Table Inheritance, and Joined Table Inheritance is not implemented?

@leonardbiofi

Copy link
Copy Markdown

What is the status ? Also can't wait for this to be released :)

@stuaxo

Copy link
Copy Markdown

I'm new to this branch, how is the test coverage?

In the django world I use django polymorphic and am really missing this feature.

@budroco

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

@github-actionsgithub-actionsBot added the conflicts Automatically generated when a PR has a merge conflict label Dec 26, 2025
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has a merge conflict that needs to be resolved.

@heitorslira

Copy link
Copy Markdown

+1

@svlandegsvlandeg linked an issue Feb 14, 2026 that may be closed by this pull request
8 tasks
@Rami-Kassouf-FOO

Copy link
Copy Markdown

can this please be merged?

@HWiese1980

Copy link
Copy Markdown

What's keeping you from merging this, guys?

@alebacca89

Copy link
Copy Markdown

Great feature! Why don't you merge it? It would make projects cleaner and make life easier for many people! Please, let's merge it!!!

@ildivan

Copy link
Copy Markdown

We need this feature please merge it!!

@stuaxo

Copy link
Copy Markdown

Hi all
The reason this isn't merged is that it's not ready yet.

If anybody wants to move it closer to being merged then pull it down, reproduce some of the issues above and then try and submit fixes (maybe try start by resolving the merge conflicts).

It's not going to get merged before it works as much as it would be useful to all of us - open source takes time.
If anybody can put some of their work dev time into this it'll probably progress faster.

Have fun out there.

S

@dolfandringa

dolfandringa commented Mar 11, 2026

Copy link
Copy Markdown

@stuaxo Can you give concise feedback on why this isn't ready yet for merging, besides the merge conflicts? I don't think its reasonable to ask the OP to stay interested and start working on this again after 2 years of very little activity by the repo admins, while the OP has responded to and addressed technical issues quite well. I really still want this PR to be merged. I'd be happy to put effort into it, but only if there is consensus on what needs to be addressed and with the commitment that that effort actually leads to this feature being merged into sqlmodel.

I would like to push for consensus for it be "good enough" and stay close to the original sqlalchemy syntax and functionality (as that is what many people are used to anyway), not a "perfect" solution. It can evolve from there. But not having polymorphic identities at least with single table inheritance is really annoying in our code base.

@elarchet

Copy link
Copy Markdown

Happy to help too

@stuaxo

Copy link
Copy Markdown

I'd like to let you know that I discovered problems with objects with relationships. I unfortunately don't have the time to share details or a PoC right now, but hope that I will somewhat soon.

would be the first thing for someone to check, and then the merge conflicts.

I'm not asking op to anything, I'm asking everyone to stop asking op "please merge this" while there are still things to fix that can be found by scrolling a up just a tiny bit from this message.

@rdbisme

Copy link
Copy Markdown

For those interested, we're driving an internal project using sqlmodel and we've tried to complete this PR here: #1894.

Every review is welcome :)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflictsAutomatically generated when a PR has a merge conflictfeatureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How do you define polymorphic models similar to the sqlalchemy ones?

20 participants

@PaleNeutron@mmx86@ndeybach@guhur@adsharma@dolfandringa@ahmdatef@svlandeg@alexandre-piccin@KunxiSun@jensrischbieth@barrynorman@cobnett3@0x003e@budroco@ssoimada@piewared@a0s@aidenprice@yurii-franasiuk