Validating JSONB documents with Pydantic TypeDecorators

A TypeDecorator over JSONB that calls model_dump() on the way in and model_validate() on the way out gives a JSONB column a real schema: attribute access, defaults in one place, and a ValidationError at the boundary instead of a KeyError three frames later. This guide belongs to querying Postgres JSONB, arrays and full-text search.

Quick Answer

One generic TypeDecorator serves every document model:

Validation at the type boundary Five steps. Application code assigns a Pydantic model to the attribute. On flush, bind_processor calls model_dump on it and hands a plain dict to the JSONB type. Postgres stores the document. On load, result_processor receives the dict and calls model_validate. The attribute is a typed model again, and a bad document raises at the boundary rather than deep in a request handler. assign a Pydantic model order.settings = Settings(...) typed in Python bind: model_dump a plain dict handed to JSONB Postgres stores JSONB one column, one document indexable with GIN result: model_validate dict back to a model typed on the way out a bad document ValidationError at load One type, two processors: everything else in the application sees a real model.
from typing import Any, TypeVar

from pydantic import BaseModel
from sqlalchemy import Dialect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import TypeDecorator

M = TypeVar("M", bound=BaseModel)


class PydanticJSONB(TypeDecorator[M]):
    """A JSONB column whose Python value is a validated Pydantic model."""

    impl = JSONB
    cache_ok = True

    def __init__(self, model: type[M], **kwargs: Any) -> None:
        self.model = model
        super().__init__(**kwargs)

    def process_bind_param(self, value: M | dict | None, dialect: Dialect) -> dict | None:
        if value is None:
            return None
        if isinstance(value, dict):
            value = self.model.model_validate(value)   # validate dicts too
        return value.model_dump(mode="json")

    def process_result_value(self, value: dict | None, dialect: Dialect) -> M | None:
        if value is None:
            return None
        return self.model.model_validate(value)

Used on a model:

from pydantic import BaseModel, Field
from sqlalchemy.orm import Mapped, mapped_column

from shop.models import Base


class Shipping(BaseModel):
    carrier: str
    speed: str = "standard"
    signature_required: bool = False
    notes: list[str] = Field(default_factory=list)


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    shipping: Mapped[Shipping] = mapped_column(
        PydanticJSONB(Shipping), default=Shipping(carrier="dhl")
    )
order = Order(shipping=Shipping(carrier="ups", speed="express"))
session.add(order)
await session.commit()

loaded = await session.get(Order, order.id)
loaded.shipping.speed        # "express", typed, not loaded.shipping["speed"]

cache_ok = True matters: without it SQLAlchemy cannot cache statements using the type, and every query recompiles. It is safe here because the type's behaviour depends only on self.model.

mode="json" in model_dump is what turns datetime, Decimal, UUID and Enum values into JSON-representable ones. Without it the driver is handed objects it cannot serialise.

Execution Context & Async Workflow Integration

A TypeDecorator is a pair of translation functions attached to a column type. process_bind_param runs when a value goes to the database; process_result_value runs when a row comes back. Because both live in the type, every part of the application — ORM attributes, Core insert(), select() results, bulk operations — goes through them, with no repeated conversion at call sites.

Plain dict or validated model Left: a plain JSONB column holds whatever dict was assigned, so typos in keys are invisible until something reads them, defaults are scattered through the code, and every reader writes its own get calls. Right: a TypeDecorator wrapping Pydantic gives one schema, defaults in one place, attribute access with types, and a clear error at the boundary. Mapped[dict] any keys, any types defaults scattered typos found at read time settings.get("retires", 3) Mapped[Settings] one schema, one place defaults from the model settings.retries, typed ValidationError at the boundary The JSONB column is identical in the database; only the Python boundary changes.

That uniformity is the real benefit over converting in a service layer. A single place decides what a valid document is, so a bulk insert cannot skip validation, and a raw select(Order.shipping) still returns a model, not a dict.

Async specifics. Both processors are plain synchronous functions, called by the dialect while it prepares parameters or processes rows, and they run inside the greenlet that the async driver uses. So they must not await, and they must not block: no HTTP call to fetch a schema, no synchronous file read, no lock. Keep them pure CPU work on data already in hand. A Pydantic model_validate is exactly that.

One consequence worth knowing: validation happens on the event loop's thread, so a very large document validated on thousands of rows is CPU time the loop cannot use for I/O. For a bulk export of many rows where the document is not needed, select the columns you want instead of whole entities, or use model_construct for a fast path:

    def process_result_value(self, value, dialect):
        if value is None:
            return None
        return self.model.model_construct(**value)      # no validation, no coercion

model_construct skips validation and coercion, so nested models stay dicts and datetime fields stay strings. It is right only when the data is known-good and the model is flat. The default should be model_validate.

Streaming. Because the processor runs per row, streaming a large table with yield_per validates incrementally rather than all at once, which keeps memory flat — the behaviour described in streaming large result sets with yield_per in SQLAlchemy applies unchanged.

Relationship to other TypeDecorator uses. This is the same mechanism as an encrypted column, and the same caveats about caching and cache_ok apply, as in writing a TypeDecorator for encrypted columns. Alembic also needs to know how to render the type in a migration, which is rendering custom types in Alembic autogenerate — without it, autogenerate emits PydanticJSONB() with no import and the migration fails to run.

Mutation Tracking, Defaults and Querying Inside the Document

Three practical problems come up immediately.

Three places to enforce a schema Three tiles. A Pydantic TypeDecorator enforces the schema in Python, in the process that writes. A Postgres CHECK constraint using jsonb operators enforces a few invariants in the database, for every writer. And a jsonb_path or JSON Schema extension gives full validation in the database at a cost. Most applications want the first, plus a small check for the keys they cannot live without. a Pydantic TypeDecorator in Python, on write and read full schema, rich errors a CHECK constraint in Postgres, every writer a few key invariants jsonb_path / JSON Schema in Postgres, full schema costly and awkward to change The type decorator is the working layer; a CHECK covers writers that bypass the ORM.

In-place mutation is not detected. Exactly as with a plain JSON or ARRAY column, changing the model in place leaves the attribute looking unchanged:

order.shipping.speed = "express"    # not detected: no UPDATE is emitted
await session.commit()

The explicit fix is a reassignment, which Pydantic makes tidy with model_copy:

order.shipping = order.shipping.model_copy(update={"speed": "express"})
await session.commit()

This is the option to prefer. It is visible at the call site, it works with frozen models, and it avoids wrapping every document. Making the Pydantic model frozen=True turns the silent failure above into an error, which is worth doing:

from pydantic import BaseModel, ConfigDict


class Shipping(BaseModel):
    model_config = ConfigDict(frozen=True)
    # ... fields as above

The alternative is flag_modified, which tells the session the attribute changed:

from sqlalchemy.orm.attributes import flag_modified

order.shipping.speed = "express"
flag_modified(order, "shipping")

Correct, and easy to forget. MutableDict.as_mutable() does not help here, because the Python value is a model rather than a dict.

Defaults. A mutable default on a column is a trap — default=Shipping(carrier="dhl") shares one instance across every new row. Use a callable:

    shipping: Mapped[Shipping] = mapped_column(
        PydanticJSONB(Shipping), default=lambda: Shipping(carrier="dhl")
    )

With a frozen model the shared instance is harmless, but the callable form is right either way. A server_default is also worth having so rows inserted outside the ORM get a valid document:

from sqlalchemy import text

    shipping: Mapped[Shipping] = mapped_column(
        PydanticJSONB(Shipping),
        default=lambda: Shipping(carrier="dhl"),
        server_default=text("""'{"carrier": "dhl", "speed": "standard"}'::jsonb"""),
    )

Querying inside the document. The type decorator changes nothing about SQL. The column is JSONB, so every operator still works, and the indexing rules in querying and indexing JSONB columns in SQLAlchemy apply:

from sqlalchemy import select

# Containment, index-assisted by a GIN index on the column.
stmt = select(Order).where(Order.shipping.contains({"speed": "express"}))

# Text extraction for an equality filter.
stmt = select(Order).where(Order.shipping["carrier"].astext == "ups")

One wrinkle: the bind processor also runs on values used in comparisons, so comparing against a partial dict can fail validation. Order.shipping.contains({"speed": "express"}) goes through the JSONB comparator rather than the decorator in current SQLAlchemy, but the safe habit for a whole-column comparison is to build a full model, or to cast the column:

from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import JSONB

stmt = select(Order).where(
    cast(Order.shipping, JSONB).contains({"speed": "express"})
)

A hybrid property is the tidiest way to give a frequently filtered field a name in both Python and SQL, which is the technique in writing hybrid properties that work in Python and SQL.

Schema Evolution and Enforcement Outside the ORM

A JSONB document's schema changes without a migration, which is the point — and the reason it needs a plan. Two mechanisms cover it.

Versioned documents Three lanes. The stored document carries a version field. The Pydantic layer reads any known version and upgrades it in a validator, so old rows load. Rows are rewritten to the current version whenever they are next saved, and a background backfill finishes the rest when the old version is ready to be dropped. the document carries a version a required field, set by the model, never inferred a model validator upgrades on load v1 becomes v2 in Python, so every old row still loads rewritten on the next save, then backfilled the upgrade path can be deleted once no v1 rows remain

Version the document. Give the model a version field and an upgrade path, so every historical row still loads:

from typing import Any, Literal

from pydantic import BaseModel, model_validator


class Shipping(BaseModel):
    version: Literal[2] = 2
    carrier: str
    speed: str = "standard"
    signature_required: bool = False

    @model_validator(mode="before")
    @classmethod
    def upgrade(cls, data: Any) -> Any:
        if isinstance(data, dict) and data.get("version", 1) == 1:
            data = dict(data)
            data["signature_required"] = data.pop("needs_signature", False)
            data["version"] = 2
        return data

Rows are rewritten to version 2 whenever they are next saved. Whether that is enough depends on how often rows are written; when the upgrade path must eventually be deleted, finish the job with a backfill in batches, using the batching approach in processing large tables in batches with partitions.

The rules that make this sustainable are the ones from any forward-compatible format. New fields get defaults, so old documents validate. Removed fields are ignored rather than rejected — Pydantic's default behaviour, and a reason not to set extra="forbid" on stored documents even though it is tempting. Renames go through an upgrade step, never a bare rename. And a field's type never changes in place: add a new field and retire the old one, exactly as you would for a column in dropping a column safely during a rolling deploy.

Defend against non-ORM writers. The type decorator only runs in Python processes that use these models. A psql session, an ETL job, or a different service writing the same table bypasses it entirely. Where that matters, add a CHECK constraint for the invariants the application cannot survive without:

from sqlalchemy import CheckConstraint


class Order(Base):
    __tablename__ = "orders"
    __table_args__ = (
        CheckConstraint(
            "shipping ? 'carrier' AND jsonb_typeof(shipping -> 'carrier') = 'string'",
            name="ck_orders_shipping_carrier",
        ),
    )

A CHECK cannot express a whole schema without becoming unmaintainable, so pick the two or three keys whose absence would break a code path, and let Pydantic handle the rest. Adding the constraint to an existing table needs the NOT VALID then VALIDATE sequence, because a plain ADD CONSTRAINT scans and locks the table — the pattern in adding a NOT NULL column without locking in Postgres.

A last consideration: keep genuinely relational fields out of the document. A field that is filtered on every request, joined against, or constrained by a foreign key belongs in a column. JSONB is for the parts of a record whose shape varies or whose schema is owned by something other than the database.

Resolving Warnings, Errors & Common Mistakes

Exact errorRoot CauseProduction Fix
StatementError: Object of type Shipping is not JSON serializableprocess_bind_param returned the model.Return value.model_dump(mode="json").
TypeError: Object of type datetime is not JSON serializablemodel_dump() without mode="json".Pass mode="json".
pydantic_core.ValidationError on loadA stored document predates the current model.Add a model_validator(mode="before") upgrade path.
An attribute change is never persistedIn-place mutation of the model.Reassign with model_copy(update=...), or flag_modified.
Every query recompiles; ObjectNotExecutableError in caching logscache_ok not set on the TypeDecorator.Set cache_ok = True.
NameError: name 'PydanticJSONB' is not defined in a migrationAutogenerate rendered the custom type with no import.Add a render_item hook in env.py.
Every new row shares one documentA mutable instance as default=.Use default=lambda: Model(...).
ValidationError when filtering with a partial dictThe bind processor validated a comparison value.Cast the column to JSONB, or compare a full model.
Rows written by another service fail to loadNo enforcement outside the ORM.Add a CHECK constraint for the essential keys.
Ten thousand rows, four column types Four bars giving relative wall-clock cost for loading ten thousand rows with a small JSONB document each. A plain JSONB dict column is the baseline. A TypeDecorator that constructs a Pydantic model per row adds a modest amount. Validating on every load with a strict model adds a little more. Re-parsing the JSON text in application code because the type returned a string is the slowest. plain JSONB, dict in Python baseline TypeDecorator, model_construct ~1.1x TypeDecorator, model_validate ~1.4x JSON re-parsed in the handler ~1.9x Indicative, not a benchmark: validation costs far less than hand-rolled parsing.

Two notes on the table's fifth and sixth rows, which are the ones that bite in production rather than in development.

cache_ok. Omitting it produces a warning, not an error, and the application works — slowly, because SQLAlchemy's compiled-statement cache is bypassed for every statement touching the column. On a busy endpoint that is a large, silent regression. Set it, and keep the type's behaviour dependent only on its constructor arguments so that staying cacheable remains true.

Alembic rendering. env.py needs to know how to write the type into a migration script:

def render_item(type_, obj, autogen_context):
    if type_ == "type" and obj.__class__.__name__ == "PydanticJSONB":
        autogen_context.imports.add("import shop.types")
        autogen_context.imports.add("import shop.models")
        return f"shop.types.PydanticJSONB({obj.model.__module__}.{obj.model.__name__})"
    return False


context.configure(..., render_item=render_item)

There is a subtlety here worth stating plainly: a migration that references the current Pydantic model is not reproducible, because the model will change. For DDL purposes the type is just JSONB, so the simplest correct answer is often to render it as postgresql.JSONB(astext_type=sa.Text()) and keep the Python model out of migration history entirely. That keeps old migrations runnable after the model has moved on — the same reasoning that keeps enum values and imports out of migration scripts in reviewing autogenerated migrations before applying them.

Frequently Asked Questions

How do I store a Pydantic model in a JSONB column?

Wrap JSONB in a TypeDecorator whose process_bind_param calls model_dump(mode="json") and whose process_result_value calls model_validate. The attribute is then typed in Python and JSONB in Postgres.

Why is my change to a stored model not saved?

SQLAlchemy does not see in-place mutation of the document. Reassign the attribute — obj.doc = obj.doc.model_copy(update={...}) — or call flag_modified(obj, "doc"). Freezing the Pydantic model turns the silent failure into an error.

Do I still need cache_ok on the TypeDecorator?

Yes. Without cache_ok = True SQLAlchemy skips its compiled-statement cache for every statement using the column, which is a significant and silent slowdown.

How do I change the document schema later?

Give the document a version field and upgrade old versions in a model_validator(mode="before"). Rows are rewritten on their next save, and a batched backfill finishes the rest before the upgrade path is removed.