Repository navigation
Frozen field assignment raises ValidationError but mutates ORM state and persists on commit #2116
First Check
Example Codefastapi> from pydantic import ValidationError
... from sqlmodel import Field, Session, SQLModel, create_engine
...
...
... class FrozenProbe(SQLModel, table=True):
... id: int | None = Field(default=None, primary_key=True)
... code: str = Field(schema_extra={"frozen": True})
...
fastapi> engine = create_engine("sqlite://")
... SQLModel.metadata.create_all(engine)
...
fastapi> with engine.begin() as connection:
... connection.execute(
... FrozenProbe.__table__.insert().values(id=1, code="original")
... )
...
fastapi> with Session(engine) as session:
... record = session.get(FrozenProbe, 1)
... assert record is not None
...
... try:
... record.code = "changed"
... except ValidationError:
... print("Assignment rejected")
...
... print(record.code)
... session.commit()
...
Assignment rejected
changed
fastapi> with Session(engine) as session:
... saved = session.get(FrozenProbe, 1)
... assert saved is not None
... print("Database value:", saved.code)
...
Database value: changedDescriptionAssigning a new value to a frozen field on a database-loaded SQLModel raises ValidationError, but still changes the object's value and SQLAlchemy state. I expect the rejected assignment to leave the original value unchanged. Instead, after catching the error, the field contains the rejected value. Calling session.commit() persists that value, and loading the record in a new session confirms the change. This concerns field-level freezing (Field(schema_extra={"frozen": True})) on a table=True model. Operating SystemmacOS Operating System DetailsNo response Project VersionPython 3.15.0rc2 · SQLModel 0.0.48 · SQLAlchemy 2.1.5 · Pydantic 2.14.1. Python Versionpython 3.15 Additional ContextNo response |
Replies: 5 comments
|
This comes from the order inside I ran your example on SQLModel 0.0.48, SQLAlchemy 2.1.5, Pydantic 2.14.1 (Python 3.15.0rc3) and got the same result. Expiring just that attribute in the except ValidationError:
session.expire(record, ["code"])After that, |
|
I know we can use a workaround, as you suggested, with I've already investigated this and identified a potential execution-order issue involving both SQLAlchemy and SQLModel's setters. Looking at the source code, it appears that SQLAlchemy's That's why I raised this discussion: to investigate whether this is a bug or unexpected behavior and find a way to fix the root cause directly in the source code rather than relying on workarounds. |
|
Yes — this is a bug in the contract sense, and your read of the execution order is correct. Here's the full mechanism, why the code is structured that way, and what a root fix would look like. Exact mechanism (still on # Set in SQLAlchemy, before Pydantic to trigger events and updates
if is_table_model_class(self.__class__) and is_instrumented(self, name):
set_attribute(self, name, value)
# Set in Pydantic model to trigger possible validation changes...
if name not in self.__sqlmodel_relationships__:
super().__setattr__(name, value)
That's the contract violation: for Why not just swap the two calls? The code comment ("Set in SQLAlchemy, before Pydantic to trigger events and updates") hints at the reason: pydantic's A safe root fix is validate-or-restore: if is_table_model_class(self.__class__) and is_instrumented(self, name):
old = get_attribute(self, name) # current value, lazy-load safe
set_attribute(self, name, value) # instrumented write, events fire
try:
super().__setattr__(name, value) # pydantic validation
except ValidationError:
set_attribute(self, name, old) # restore via instrumentation
raise
returnBecause the restore also goes through I'd open this as an issue — your repro (rejected assignment + |
|
To close the loop on "bug or expected behavior": it's a contract bug — a validator that raises must leave no observable state change, and here This discussion has the minimal repro and mechanism analysis — worth converting into an issue against |
|
Passing parameters to |
Passing parameters to
FieldasField(schema_extra={"frozen": True})is a workaround that has never been officially documented.So, frozen field is just not supported.
I've attempted to work on it, but it turned out to be not simple task. We track this internally and will discuss when Sebastian has time for it.