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.