Querying and indexing JSONB columns in SQLAlchemy

Map the column as Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB)), filter with Product.attributes.contains({"color": "red"}) so a GIN index can serve it, and use ["key"].astext or .as_integer() whenever you compare an extracted value to a Python scalar. This guide is part of querying PostgreSQL JSONB, arrays and full-text search.

Quick Answer

The most frequent JSONB mistakes are treating an extracted jsonb value as text, and editing a document in place without SQLAlchemy noticing. Both have one-line fixes.

Python expression → PostgreSQL operator Six tiles. Indexing a column with a string key renders the arrow operator and returns JSONB. Adding astext renders the double-arrow operator and returns text. Indexing with a tuple renders the path operator. contains with a dictionary renders the containment operator, which a GIN index can serve. has_key renders the question-mark operator. as_integer renders a cast of the text value to integer. attributes['brand'] attributes -> 'brand' returns jsonb attributes['brand'].astext attributes ->> 'brand' returns text attributes[('dims', 'w')] attributes #> '{dims,w}' returns jsonb contains({'color': 'red'}) attributes @> ... GIN-indexable has_key('warranty') attributes ? 'warranty' GIN-indexable ['stock'].as_integer() CAST(... ->> ... AS INTEGER) typed comparison The difference between -> and ->> is the most common JSONB bug: ilike(), startswith() and joins to text columns all fail on jsonb, and only ->> can use an expression index.

Before — comparing jsonb to text, and an edit that is never saved:

from sqlalchemy import select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str]
    attributes: Mapped[dict] = mapped_column(JSONB, default=dict)


# attributes -> 'brand' is jsonb, not text, so text operators fail:
stmt = select(Product).where(Product.attributes["brand"].ilike("acme%"))
# ProgrammingError: operator does not exist: jsonb ~~* character varying

product.attributes["color"] = "blue"   # mutated in place
await session.commit()                 # no UPDATE is emitted

After — text extraction, containment, and tracked mutation:

from sqlalchemy import Index, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str]
    attributes: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=dict)

    __table_args__ = (
        Index("ix_products_attributes", "attributes", postgresql_using="gin"),
    )


# Text extraction for text operators and equality on one key:
by_brand = select(Product).where(Product.attributes["brand"].astext.ilike("acme%"))

# Containment, which the GIN index serves:
red = select(Product).where(Product.attributes.contains({"color": "red"}))

# Typed extraction for ranges:
in_stock = select(Product).where(Product.attributes["stock"].as_integer() > 0)

product.attributes["color"] = "blue"   # MutableDict marks the attribute dirty
await session.commit()                 # UPDATE products SET attributes=... WHERE id=...

Execution Context & Async Workflow Integration

SQLAlchemy's JSONB type does two separate jobs, and the bugs above come from confusing them. On the SQL side, its comparator turns Python indexing and method calls into PostgreSQL operators. On the Python side, its bind and result processors turn dictionaries into JSON text and back.

Why an in-place JSONB edit is not saved Left: product.attributes["color"] = "blue" modifies the dictionary object in place; the attribute still points at the same object, so the unit of work sees no change and the commit issues no UPDATE. Right: the column is declared with MutableDict.as_mutable(JSONB), or flag_modified is called; the attribute is marked dirty and the flush writes the whole new document. plain Mapped[dict] = JSONB product.attributes['color'] = 'blue' same dict object, no set event session sees nothing dirty commit issues no UPDATE MutableDict.as_mutable(JSONB) product.attributes['color'] = 'blue' MutableDict emits a change event attribute is marked dirty UPDATE writes the whole document MutableDict tracks top-level keys only. Nested edits still need flag_modified() or reassignment.

The operator mapping is precise. Product.attributes["brand"] renders attributes -> 'brand', which returns a jsonb value — the JSON string "Acme", quotes included. SQLAlchemy types that expression as JSON, so == "Acme" happens to work: the bound value is JSON-encoded too, and PostgreSQL compares two jsonb values. Everything text-shaped fails, though — ilike(), startswith(), a join to a VARCHAR column, a func.lower() — with operator does not exist: jsonb ~~* character varying or its relatives, and a jsonb-to-jsonb equality can never use an expression index on ->>. .astext renders ->> and returns text, which behaves like a string everywhere. For numbers and booleans, .as_integer(), .as_float() and .as_boolean() extract as text and cast, so > 0 is a numeric comparison rather than a lexical one.

Change tracking is a separate concern. The ORM detects changes by intercepting attribute assignment. product.attributes["color"] = "blue" does not assign to product.attributes; it mutates the dictionary the attribute already holds, so the unit of work sees nothing to flush. MutableDict.as_mutable(JSONB) wraps loaded values in a dictionary subclass that reports its own top-level changes to the parent. Nested changes — attributes["dims"]["width"] = 40 — are still invisible to it, and need either reassignment or flag_modified(product, "attributes").

Under asyncpg, serialisation happens in SQLAlchemy rather than in the driver: the asyncpg dialect registers a JSON codec that uses the engine's json_serializer and json_deserializer, which default to the standard library's json.dumps and json.loads. That is where TypeError: Object of type Decimal is not JSON serializable comes from, and it is raised at flush time, far from the assignment that caused it. The fix belongs on the engine, not on every assignment, and is shown in the advanced section below.

Because every JSONB write replaces the whole document, a large document edited frequently rewrites a large row frequently. That shapes both the upsert and conflict strategies you can use — a concurrent edit to a different key still conflicts — and the decision about which fields deserve real columns.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
operator does not exist: jsonb ~~* character varying (or jsonb = character varying)A text operator, or a text column, applied to attributes["key"], which is jsonb.Use attributes["key"].astext.
Numeric filter returns the wrong rows ("10" < "9")Comparing extracted text lexically.Use .as_integer() or .as_float() before comparing.
Commit succeeds but the document is unchangedIn-place mutation without change tracking.MutableDict.as_mutable(JSONB), or flag_modified(obj, "attributes").
Nested edit not saved even with MutableDictMutableDict only tracks top-level keys.Reassign the top-level key, or call flag_modified.
TypeError: Object of type Decimal is not JSON serializableThe default serializer cannot encode Decimal, datetime or UUID.Pass json_serializer= to create_async_engine.
Rows stored as JSON null instead of SQL NULLAssigning None to a JSONB attribute writes the JSON null literal by default.JSONB(none_as_null=True), or assign sqlalchemy.null().
GIN index exists but the plan shows a sequential scanFiltering with ->> equality, which GIN cannot serve.Rewrite as .contains({...}), or add an expression index.
Match the index to the query Three tiles. A default jsonb_ops GIN index supports containment, key existence and any-key and all-key existence, and is the largest. A jsonb_path_ops GIN index supports only containment and jsonpath matches but is smaller and faster for those. An expression B-tree index on attributes ->> brand supports equality, ranges and sorting on that single key. GIN (jsonb_ops) @> ? ?| ?& broadest, largest GIN (jsonb_path_ops) @> @? @@ only smaller, faster for @> B-tree on (attrs ->> 'brand') = < > ORDER BY one key, exact shape A GIN index cannot serve attributes ->> 'brand' = 'Acme'. Rewrite the filter as containment, or add an expression index whose expression matches the query exactly.

The None row deserves a note, because it breaks IS NULL filters silently. A JSONB column with no none_as_null stores Python None as the JSON value null, which is not SQL NULL, so Product.attributes.is_(None) does not find it. Decide once which you mean: none_as_null=True if absence should be SQL NULL, or keep the default and filter with Product.attributes == JSON.NULL for explicitly-null documents.

Verify fixes with the query plan rather than timing. Reading EXPLAIN output for a SQLAlchemy query shows how to compile the exact statement and confirm that a Bitmap Index Scan on ix_products_attributes replaced the sequential scan.

Advanced: Indexing Strategy and Custom Serializers

A single default GIN index is a reasonable start, and it is not always the right end. Three index shapes cover almost every JSONB workload, and choosing between them is a matter of reading which operators your queries actually use.

The JSONB round trip under asyncpg Five steps. A Python dictionary is assigned to the attribute. At flush, SQLAlchemy calls the engine json_serializer, json.dumps by default, producing a string. asyncpg sends it and PostgreSQL parses it into binary jsonb, reordering keys and removing duplicates. On select, the text form comes back and the json_deserializer turns it into a dictionary. Types JSON cannot represent, like Decimal and datetime, must be handled by a custom serializer. Python dict {'price': Decimal('9.50')} json_serializer at flush JSON text custom default= for Decimal sent by asyncpg PostgreSQL jsonb keys reordered, dupes removed returned as text on SELECT Python dict again json_deserializer TypeError: Object of type Decimal is not JSON serializable is raised at flush, not at assignment.
from sqlalchemy import Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str]
    attributes: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=dict)

    __table_args__ = (
        # Containment only (@>), about a third smaller than the default opclass.
        Index(
            "ix_products_attributes_path",
            "attributes",
            postgresql_using="gin",
            postgresql_ops={"attributes": "jsonb_path_ops"},
        ),
    )


# Equality, ranges and ORDER BY on one extracted key. Declared after the class, because the
# expression needs the mapped attribute, not the mapped_column() placeholder.
Index("ix_products_brand", Product.attributes["brand"].astext)

The expression index only serves a query whose expression matches it exactly — attributes ->> 'brand', not attributes -> 'brand', and not lower(attributes ->> 'brand'). If a key is filtered, sorted and joined on constantly, that is a signal it has outgrown the document and wants to be a real column, possibly a generated one populated from the JSON.

Serialisation is configured once, on the engine. A serializer that handles the types your domain actually stores removes a whole class of flush-time errors:

import datetime as dt
import decimal
import json
import uuid

from sqlalchemy.ext.asyncio import create_async_engine


def _default(value):
    if isinstance(value, decimal.Decimal):
        return str(value)            # keep precision; parse back explicitly where needed
    if isinstance(value, (dt.datetime, dt.date)):
        return value.isoformat()
    if isinstance(value, uuid.UUID):
        return str(value)
    raise TypeError(f"cannot serialise {type(value).__name__}")


def dumps(value) -> str:
    return json.dumps(value, default=_default, separators=(",", ":"))


engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    json_serializer=dumps,
    json_deserializer=json.loads,
)

Serialising Decimal as a string rather than a float is deliberate: a float silently loses precision for money, and the string round-trips exactly. The cost is that the value comes back as a string, so code reading prices out of a document must convert it — another hint that prices belong in columns.

Advanced: jsonpath Filters and Unnesting Arrays in Documents

Documents often carry lists — tags, variant options, line items — and two questions come up about them: "does any element match?" and "give me one row per element". PostgreSQL answers the first with jsonpath and the second with a set-returning function, and SQLAlchemy 2.0 exposes both without dropping to text.

Test the list, or unnest it Two bands. Testing: path_exists renders the @? operator and path_match renders @@; jsonb_path_exists accepts a vars document so user values are bound parameters; a GIN index can serve both. Unnesting: jsonb_array_elements_text with column_valued yields one row per element via an implicit lateral join; products without the key contribute no rows unless you use an explicit left lateral join. test: does any element match? path_exists('$.variants[*] ? (@.stock > 0)') → attributes @? '...' pass user values through a vars document, never string formatting unnest: one row per element func.jsonb_array_elements_text(attributes['tags']).column_valued('tag') implicit lateral join — rows without the key disappear unless LEFT JOIN LATERAL Frequent unnesting is a sign the list wants to be an ARRAY column or a child table.

For matching inside nested structures, path_exists() renders the @? operator and path_match() renders @@. Both can use a GIN index built with either operator class:

from sqlalchemy import select

from shop.models import Product

# Any variant in stock with a price under 20:
cheap_in_stock = select(Product.sku).where(
    Product.attributes.path_exists("$.variants[*] ? (@.stock > 0 && @.price < 20)")
)

# Pass the threshold as a jsonpath variable instead of formatting it into the string:
from sqlalchemy import cast, func
from sqlalchemy.dialects.postgresql import JSONB

max_price = 20
parameterised = select(Product.sku).where(
    func.jsonb_path_exists(
        Product.attributes,
        "$.variants[*] ? (@.price < $max)",
        cast({"max": max_price}, JSONB),
    )
)

The second form matters for user input. A jsonpath expression is a small language, and building one with string formatting has the same problems as building SQL that way; jsonb_path_exists() accepts a vars document, so the value travels as a bound parameter.

For one row per element, a table-valued function in the FROM clause does the unnesting. column_valued() names the output so it can be selected, filtered and grouped like any column:

from sqlalchemy import func, select

from shop.models import Product

tag = func.jsonb_array_elements_text(Product.attributes["tags"]).column_valued("tag")

# Tag frequency across the catalogue:
tag_counts = (
    select(tag, func.count().label("products"))
    .group_by(tag)
    .order_by(func.count().desc())
    .limit(20)
)
# SELECT tag, count(*) AS products
# FROM products, jsonb_array_elements_text(products.attributes -> 'tags') AS tag
# GROUP BY tag ORDER BY count(*) DESC LIMIT 20

PostgreSQL treats a function in FROM that references an earlier table as an implicit lateral join, so each product contributes one row per tag. A product without a tags key contributes none, which is usually right for counting and wrong for a report that must list every product; for that, use an explicit LEFT JOIN LATERAL built with .lateral() and isouter=True.

If queries like these are the main way a list is read, it is worth asking whether the list wants to be a PostgreSQL ARRAY column or a child table instead — both are indexable in ways a list inside a document is not, and the parent guide compares the three.

Frequently Asked Questions

Should I use JSON or JSONB?

JSONB, almost always. It is stored parsed, supports GIN indexing and containment operators, and removes duplicate keys. JSON preserves the exact input text, including key order and whitespace, which matters only if you need the original bytes back.

How do I update one key without rewriting the document?

At the SQL level, use func.jsonb_set(Product.attributes, "{color}", func.to_jsonb("blue")) in an update() statement; it is still a full row rewrite in PostgreSQL, but it avoids loading the document and avoids lost updates from concurrent edits to other keys.

Why is my GIN index not used?

Usually because the query uses ->> equality, which GIN does not support. Rewrite the filter as containment with .contains({"key": "value"}), or add an expression B-tree index on the exact extraction expression.

Can I type the contents of a JSONB column?

Not in the database. In Python, a TypeDecorator that validates against a Pydantic model or dataclass on the way in and out gives typed access, at the cost of losing MutableDict tracking unless you reassign on every change.