GeezerVison is showing github.com — Dataclass inheritance pattern breaks with Python 3.14.1 · Issue #13021 · sqlalchemy/sqlalchemy
Dataclass inheritance pattern breaks with Python 3.14.1 #13021DescriptionDescribe the bug
After upgrading to the latest CPython patch release 3.14.1 my app started to break mentioning a KeyError on a field inherited from a Mixin.
Seems related to the discussion on CPython here: python/cpython#142214
Not sure if it's something that should be fixed by SQLAlchemy or by CPython.
See stacktrace below & MRE.
Optional link from https://docs.sqlalchemy.org which documents the behavior that is expected
No response
SQLAlchemy Version in Use
2.0.44
DBAPI (i.e. the database driver)
/
Database Vendor and Major Version
/
Python Version
3.14.1
Operating system
Linux
To Reproduce
from sqlalchemy import ForeignKey
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
MappedAsDataclass,
declared_attr,
mapped_column,
relationship,
)
class Base(MappedAsDataclass, DeclarativeBase):
pass
class User(Base):
__tablename__ = "user_account"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
name: Mapped[str]
class CreatedByMixin(MappedAsDataclass, kw_only=True):
created_by_fk: Mapped[int] = mapped_column(
ForeignKey("user_account.id"), init=False
)
@declared_attr
@classmethod
def created_by(cls) -> Mapped[User]:
return relationship(foreign_keys=[cls.created_by_fk])
class Item(CreatedByMixin, Base, kw_only=True):
__tablename__ = "item"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
description: Mapped[str]
class SpecialItem(Item, kw_only=True):
__tablename__ = "special_item"
id: Mapped[int] = mapped_column(ForeignKey("item.id"), init=False, primary_key=True)
special_description: Mapped[str]
Error
Traceback (most recent call last):
File "/home/myname/test/main.py", line 41, in <module>
class SpecialItem(Item, kw_only=True):
...<3 lines>...
special_description: Mapped[str]
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_api.py", line 626, in __init_subclass__
super().__init_subclass__(**kw)
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_api.py", line 846, in __init_subclass__
_as_declarative(cls._sa_registry, cls, cls.__dict__)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_base.py", line 245, in _as_declarative
return _MapperConfig.setup_mapping(registry, cls, dict_, None, {})
~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_base.py", line 326, in setup_mapping
return _ClassScanMapperConfig(
registry, cls_, dict_, table, mapper_kw
)
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_base.py", line 564, in __init__
self._setup_dataclasses_transforms()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_base.py", line 1171, in _setup_dataclasses_transforms
self._apply_dataclasses_to_any_class(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
dataclass_setup_arguments, self.cls, annotations
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/home/myname/test/.venv/lib/python3.14/site-packages/sqlalchemy/orm/decl_base.py", line 1227, in _apply_dataclasses_to_any_class
dataclass_callable( # type: ignore[call-overload]
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
klass,
^^^^^^
...<4 lines>...
},
^^
)
^
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/dataclasses.py", line 1436, in dataclass
return wrap(cls)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/dataclasses.py", line 1426, in wrap
return _process_class(cls, init, repr, eq, order, unsafe_hash,
frozen, match_args, kw_only, slots,
weakref_slot)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/dataclasses.py", line 1217, in _process_class
text_sig = str(inspect.signature(
~~~~~~~~~~~~~~~~~^
cls,
^^^^
annotation_format=annotationlib.Format.FORWARDREF,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)).replace(' -> None', '')
^
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 3321, in signature
return Signature.from_callable(obj, follow_wrapped=follow_wrapped,
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
globals=globals, locals=locals, eval_str=eval_str,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
annotation_format=annotation_format)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 3036, in from_callable
return _signature_from_callable(obj, sigcls=cls,
follow_wrapper_chains=follow_wrapped,
globals=globals, locals=locals, eval_str=eval_str,
annotation_format=annotation_format)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 2557, in _signature_from_callable
return _get_signature_of(init)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 2440, in _signature_from_callable
sig = _get_signature_of(obj.__func__)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 2511, in _signature_from_callable
return _signature_from_function(sigcls, obj,
skip_bound_arg=skip_bound_arg,
globals=globals, locals=locals, eval_str=eval_str,
annotation_format=annotation_format)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/inspect.py", line 2334, in _signature_from_function
annotations = get_annotations(func, globals=globals, locals=locals, eval_str=eval_str,
format=annotation_format)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/annotationlib.py", line 973, in get_annotations
ann = _get_and_call_annotate(obj, format)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/annotationlib.py", line 1112, in _get_and_call_annotate
ann = call_annotate_function(annotate, format, owner=obj)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/annotationlib.py", line 711, in call_annotate_function
return annotate(format)
File "/home/myname/.local/share/uv/python/cpython-3.14.1-linux-x86_64-gnu/lib/python3.14/dataclasses.py", line 553, in __annotate__
new_annotations[k] = cls_annotations[k]
~~~~~~~~~~~~~~~^^^
KeyError: 'created_by'
Additional context
It seems to happen only if I have the two-layers of inheritance (i.e. with SpecialItem inheriting from Item, doesn't happen with Item only).
Running the above MRE works fine with Python 3.14.0 and breaks with Python 3.14.1. ActivityCaselIT commented on Dec 3, 2025 Hi,
This seems to be a regression on python side.
I'm not sure there is much that can be done in sqlalchemy side, since this a python bug. Let's see how the linked issue in python plays out
Will try to reproduce without sqlalchemy. Giuzzilla commented on Dec 3, 2025
Hi,
This seems to be a regression on python side.
I'm not sure there is much that can be done in sqlalchemy side, since this a python bug. Let's see how the linked issue in python plays out
Will try to reproduce without sqlalchemy.
Absolutely, I thought to open it here as well to keep track of it / in case there was something sketchy going on SQLA side and because I could reproduce it with SQLA (the linked CPython error seems slightly different even though it's still related to dataclasses). CaselIT commented on Dec 3, 2025 Yes, thanks for opening, it's surely useful to be aware of these things. DavidCEllis commented on Dec 3, 2025 Yes, apologies - this came as a side effect of an attempt to fix an issue with how dataclasses generates the annotations for its __init__ functions under PEP-649 annotations. Embedded in the __annotate__ logic was the mistaken assumption that if a field exists, it's because there is an annotation for that field. A tool replacing __annotations__ after the fact was not considered.
There's a patch pending for this now: python/cpython#142223 CaselIT commented on Dec 3, 2025 @zzzeek since you have all the machinery to build python setup, could you test of that patch fixes things for us? sqla-tester commented on Dec 3, 2025 sqla-tester commented on Dec 3, 2025 zzzeek commented on Dec 3, 2025 CaselIT commented on Dec 4, 2025 zzzeek commented on Dec 6, 2025 @DavidCEllis we've merged the above change where we dont have to swap out __annotations__ at all. looks like my original approach was overdoing it. nonethless, a public function that doesnt rely on class scan at all would be nice to have. thanks for the help! zzzeek commented on Dec 6, 2025 OK nope our __annotations__ change was merged prematurely, since it fails with this actual test case here that didnt get included in the CI runs. try again zzzeek commented on Dec 6, 2025 this is quite the unique test case! for whatever reason. that's weird too since it does not look that interesting and we have TONS of mixin combination tests zzzeek commented on Dec 6, 2025 oh, but we dont haev them with kw_only=True since that's a new option. OKEY zzzeek commented on Dec 6, 2025 So the issue is that in the above test, "created_by" is not actually a valid keyword for SpecialItem, that is, this is invalid (this is my code, I know this is not part of the test):
special_item = SpecialItem(
special_description="sd1",
description="d1",
created_by=User(name="u1"),
)
in our current 2.0 release, the above code works. But with the change that simplifies everything, it no longer works. hence my unit test that did the above breaks.
however pylance seems to agree with the new change:
which seems to be because CreatedByMixin.created_by is not a dataclass property, it' s a declared_attr function, as it has to be in this case on a mixin. the only way to get type checking to work here is using a TYPE_CHECKING block.
so now the issue is....is our dataclasses support supposed to also support @declared_attr callables as dataclass fields on init? with this change, it does not. did we have tests for this? docs? DavidCEllis commented on Dec 6, 2025 I'll admit I don't really know what you're doing here - I haven't used the sqlalchemy dataclasses support personally.
All I can say is the dataclasses issue arose because of the mistaken assumption I made that if a field exists, then it is because there is an annotation that created that field and so it was safe to retrieve that annotation.
This example broke with 3.14.1 because the annotations you temporarily swap in have an extra entry compared to the original annotations. The extra entry makes an extra field, but then you remove the annotation.
These are what you're swapping in and out:
User
use_annotations.keys() = dict_keys(['id', 'name'])
restored.keys() = dict_keys(['id', 'name'])
CreatedByMixin
use_annotations.keys() = dict_keys(['created_by_fk'])
restored.keys() = dict_keys(['created_by_fk'])
Item
use_annotations.keys() = dict_keys(['created_by_fk', 'id', 'description', 'created_by'])
restored.keys() = dict_keys(['id', 'description'])
SpecialItem
use_annotations.keys() = dict_keys(['id', 'special_description'])
restored.keys() = dict_keys(['id', 'special_description'])
3.14.1 errored because created_by existed in the annotations while creating Item but doesn't while creating SpecialItem as it has been removed by then. Giuzzilla commented on Dec 6, 2025
So the issue is that in the above test, "created_by" is not actually a valid keyword for SpecialItem, that is, this is invalid (this is my code, I know this is not part of the test):
special_item = SpecialItem(
special_description="sd1",
description="d1",
created_by=User(name="u1"),
)
in our current 2.0 release, the above code works. But with the change that simplifies everything, it no longer works. hence my unit test that did the above breaks.
however pylance seems to agree with the new change:
which seems to be because `CreatedByMixin.created_by` is not a dataclass property, it' s a `declared_attr` function, as it has to be in this case on a mixin. the only way to get type checking to work here is using a `TYPE_CHECKING ` block.
so now the issue is....is our dataclasses support supposed to also support @declared_attr callables as dataclass fields on init? with this change, it does not. did we have tests for this? docs?
Yeah, in my original code I indeed have an if TYPE_CHECKING: branch to please the type-checker.
I think I saw this pattern first in a discussion here, e.g.: #9408
My code has evolved over time and after 2.x I gradually moved every model to Dataclasses. (and this allowed me to keep the Mixins too) Giuzzilla commented on Dec 6, 2025
oh, but we dont haev them with kw_only=True since that's a new option. OKEY
The errors are identical if I remove the kw_only=True part, so I don't think that's relevant? zzzeek commented on Dec 6, 2025 zzzeek commented on Dec 6, 2025
I'll admit I don't really know what you're doing here - I haven't used the sqlalchemy dataclasses support personally.
We need simply in some cases to make a dataclass that includes a field where the original class did not have an attribute annotation for that field; the reason it doesnt have one is because that attribute is actually coming from a superclass that has a class-level python descriptor (i.e. a descriptor that fires off when you access it as MyClass.attr). On the concrete inheriting class, we want that attribute to be a dataclass attribute.
so at the moment there's no way to achieve this without overwriting __annotations__. my new patch is very careful about restoring __annotations__ this time around, however, it's not possible to restore __annotations__ if the original __annotations__ raised a NameError due to an import not resolvable. so i can't get it to work all the way, and I can't work around the Python 3.14.1 bug (though I still dont understand how dataclasses has an annotation dictionary with the key missing, when I'm setting new __annotations__ with the key; where did it get the old dictionary from?) DavidCEllis commented on Dec 6, 2025 I believe what happens is this:
- SQLAlchemy swaps in a dict with
created_by to the annotations
- Dataclasses uses this to create a field for
created_by
- SQLAlchemy removes the
created_by value so it is no longer in the annotations when it tries to replace them with the originals
- A subclass is created that will also have this field in
__init__
- When creating
__doc__ inspect.signature is called on the dataclassses __init__ which in turn calls the __annotate__ function
- This annotate function attempts to retrieve
created_by from the inherited class annotations when called but it has been removed
Unfortunately I think if you add a docstring some other logic still tries to inspect the __init__ function.
In 3.14.2 this should just be skipped if the annotations can't be retrieved so the created_by will be unannotated on __init__. zzzeek commented on Dec 6, 2025 why dont I just keep those new fields in __annotations__ then? since that's how dataclasses saw the class zzzeek commented on Dec 11, 2025 I've implemented the fully clean __annotations__ swap pattern in ceeac6d - we will go forward not supporting the 3.14.1 case here, which is fixed in 3.14.2. Reacted by Giulio Mazzanti Reacted by Edgar Ramírez Mondragón
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|