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], and list[SubModel] | None

  • raw dict fields

  • typed dict[...] fields

  • list[dict] fields

  • top-level primitive lists such as list[str], list[int], list[float], and list[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#

  1. a row is loaded or refreshed

  2. supported Pydantic fields are rehydrated back into Pydantic objects

  3. each tracked JSON field is serialized into a stable JSON string snapshot

  4. before commit, the current value is serialized again

  5. 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 | None

  • tuple-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:

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.