Automatic JSONB Mutation Tracking#
activemodel uses a serialize-and-compare strategy to detect in-place JSON mutations.
After SQLAlchemy loads or refreshes a model instance, each tracked JSON field is serialized
to a canonical JSON string and stored as a snapshot. Right before commit, the current value
is serialized again and compared against that snapshot. If the serialized values differ,
activemodel calls SQLAlchemy’s flag_modified(...) so the column is included in the
UPDATE.
This gives you ActiveRecord-style dirty tracking for complex JSON objects (either plain old py objects or Pydantic objects) without having to remember flag_modified() every time you mutate a nested attribute.
What Is Tracked#
The tracking layer currently covers:
Pydantic-backed JSON fields such as
SubModel,SubModel | None,list[SubModel], andlist[SubModel] | Noneraw
dictfieldstyped
dict[...]fieldslist[dict]fieldstop-level primitive lists such as
list[str],list[int],list[float], andlist[bool]
Rehydration is narrower than mutation tracking.
PydanticJSONMixin only rehydrates supported Pydantic annotations back into model objects on
load and refresh. Raw dict values and supported raw list[...] values stay as plain Python
containers, but they still participate in snapshot-based mutation tracking.
How It Works#
a row is loaded or refreshed
supported Pydantic fields are rehydrated back into Pydantic objects
each tracked JSON field is serialized into a stable JSON string snapshot
before commit, the current value is serialized again
if the serialized value changed, the field is marked dirty with
flag_modified(...)
This is why in-place changes like model.profile.name = "updated" and
model.settings["theme"] = "dark" can persist even though SQLAlchemy would not normally see
those nested mutations.
Pydantic Example#
from pydantic import BaseModel as PydanticBaseModel
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin
class AuditEntry(PydanticBaseModel):
actor: str
action: str
class UserRecord(
BaseModel,
PydanticJSONMixin,
TypeIDMixin("usr"),
table=True,
):
profile: AuditEntry = Field(sa_type=JSONB)
record = UserRecord.one("usr_123")
record.profile.action = "updated"
record.save()
The nested attribute mutation is detected automatically on commit.
Raw Dict Example#
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin
class FeatureFlags(
BaseModel,
PydanticJSONMixin,
TypeIDMixin("flag"),
table=True,
):
settings: dict[str, str] = Field(sa_type=JSONB)
record = FeatureFlags.one("flag_123")
record.settings["theme"] = "dark"
record.save()
settings stays a plain dict, but the in-place key mutation is still detected and persisted.
List Of Dicts Example#
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin
class WebhookLog(
BaseModel,
PydanticJSONMixin,
TypeIDMixin("wh"),
table=True,
):
deliveries: list[dict[str, str]] = Field(sa_type=JSONB)
log = WebhookLog.one("wh_123")
log.deliveries.append({"status": "ok", "provider": "stripe"})
log.save()
Top-level list[dict] payloads are tracked, including list mutation methods and nested dict
item updates.
Scope Limits#
The current implementation intentionally does not try to support every possible JSON type shape.
Notably out of scope:
ambiguous unions such as
SubModel | dict | Nonetuple-shaped JSON payloads
nested list shapes such as
list[list[SubModel]]
Those constraints keep rehydration rules predictable and avoid heuristic coercion.
Key API References#
The best entry points are the methods and functions that explain the behavior directly in their docstrings:
activemodel.jsonb_snapshot.register_before_commit_listener()activemodel.mixins.pydantic_json.PydanticJSONMixin.__transform_dict_to_pydantic__()activemodel.mixins.pydantic_json.PydanticJSONMixin.has_json_mutations()
If you want the exact implementation details, the module-level docs in
activemodel.jsonb_snapshot and activemodel.mixins.pydantic_json are the
most direct place to read next.