NoSQL & Other Data Stores

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. The drivers are PyMongo 4.18, redis-py 8.1, elasticsearch-py 9.5, qdrant-client 1.19 and SQLAlchemy 2.0 (the version SQLModel runs on). FastAPI supports Python 3.10 through 3.14; the code was executed on Python 3.13. Relational data is covered on SQL Databases with SQLModel; this page covers everything else the Bookshelf API may need: free-form review documents in MongoDB, a Redis cache and rate limiter, a full-text search index in Elasticsearch, semantic search over reviews in a vector store, and a JSON column for the flexible part of a book. FastAPI has no data layer of its own, so every store is a plain Python client; what FastAPI adds is where the client lives (the lifespan) and how a path function gets it (a dependency).

Which Store for Which Job

Start from the access pattern, not from the product. The comparison of engines themselves is on Choosing the Right Database; this table maps the Bookshelf needs to the stores used below.

Table 1. Bookshelf needs and the store that fits them
Need Store Driver Why

Reviews with a free-form shape (tags, reactions, edits)

MongoDB

PyMongo async API (AsyncMongoClient)

Documents map to Pydantic models; no migrations for new optional fields.

Hot book pages, rate limiting, short-lived tokens

Redis

redis.asyncio

In-memory, sub-millisecond reads, atomic counters and expiry.

Full-text search over titles and descriptions with relevance ranking

Elasticsearch

AsyncElasticsearch

Analyzers, scoring, facets, paging; not a primary data store.

"Reviews that feel like this one" (semantic search)

Qdrant, or pgvector inside PostgreSQL

AsyncQdrantClient, or SQLModel with the pgvector package

Nearest-neighbour search over embeddings with metadata filters.

A few flexible attributes on an otherwise relational row

JSON column in PostgreSQL or SQLite

SQLModel and SQLAlchemy JSON / JSONB

Keeps one transactional store; no second system to run.

A useful rule: keep the source of truth in one place (here PostgreSQL through SQLModel) and treat Redis, Elasticsearch and the vector store as derived, rebuildable views of it, unless the data (the reviews in MongoDB) is genuinely document-shaped and lives only there.

Lifespan-Managed Clients as Dependencies

Every driver below follows the same recipe, so learn it once:

  1. Create the client once per process when the application starts, inside the lifespan. A client owns a connection pool, background monitoring tasks and, for asyncio drivers, an association with one event loop. Creating one per request would throw all of that away on every call.

  2. Hand it to the requests through lifespan state: the lifespan yields a dictionary and Starlette copies it into request.state for every request.

  3. Close the client after the yield, once the server has finished handling requests.

  4. Expose it through a small dependency that reads request.state, so path functions ask for a typed argument and tests can replace it.

flowchart LR subgraph life["lifespan (once per process)"] direction TB open["create clients
pool, ping, indexes"] --> yld(["yield { 'mongo': ..., 'redis': ..., 'es': ... }"]) yld --> close["close clients
aclose() / close()"] end yld -. "copied into" .-> st["request.state"] st --> dep["dependency
get_redis(request)"] dep --> op["path operation
async def get_book(r: RedisDep)"] op --> engine[("MongoDB · Redis
Elasticsearch · Qdrant")]

The smallest runnable version, with a fake client so that you can see the mechanics without a server:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict

from fastapi import Depends, FastAPI, Request


class Store:
    """Stands in for any async client: a database driver, a Redis client, a search client."""

    def __init__(self, url: str) -> None:
        self.url = url
        self.closed = False

    async def ping(self) -> str:
        return f"pong from {self.url}"

    async def aclose(self) -> None:
        self.closed = True


class State(TypedDict):
    store: Store


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
    store = Store("memory://bookshelf")  # created once per process, after the server started
    yield {"store": store}               # the dict is copied into every request's state
    await store.aclose()                 # runs once at shutdown


app = FastAPI(lifespan=lifespan)


def get_store(request: Request) -> Store:
    return request.state.store


StoreDep = Annotated[Store, Depends(get_store)]


@app.get("/health")
async def health(store: StoreDep) -> dict[str, str]:
    return {"store": await store.ping()}

The pieces:

  • @asynccontextmanager lifespan. Everything before yield runs before the first request is served; everything after runs after the last one. If the code before yield raises, the application does not start, which is what you want when a required database is unreachable (the examples below ping or create indexes there on purpose).

  • The yielded dictionary is the lifespan state. It is exposed as request.state.store; the State TypedDict only documents the keys for type checkers, since request.state itself is untyped. Hence the dependency (get_store) declares the precise return type once, and the rest of the code stays typed and never touches request.state directly.

  • Prefer lifespan state to a module global. A global client created at import time exists before the event loop and is shared by every test, every reloader process and every forked worker. The state approach ties the client’s life to the running application and to the loop that runs it.

  • on_event("startup") is the old way. The startup / shutdown handlers are deprecated; the lifespan replaces them (FastAPI 0.93.0, 2023-03-07); once you pass lifespan, the old handlers are not called. Details are on Settings, Lifespan & Background Tasks.

  • Sub-applications. Lifespan events run only for the main application, not for mounted sub-applications, so a mounted app that needs a client must receive it (for example through its own lifespan or a shared object); see Sub-Applications, WSGI & GraphQL.

  • Configuration, not code. Connection strings and API keys come from the environment or a secrets file (for example through a BaseSettings class), never from source code. The examples read os.environ to stay short; the settings dependency pattern is on the settings page linked above.

Testing Without Servers

There are two complementary ways to test code that depends on a client:

  • Override the dependency. app.dependency_overrides[get_store] = lambda: FakeStore() replaces the client for one test, and the lifespan is not even needed. In the run above, a TestClient used without with does not run the lifespan, so without an override request.state.store does not exist and the request fails with an AttributeError.

  • Run the lifespan against a fake or a real server. with TestClient(app) as client: runs the lifespan. Use an in-memory fake where one exists (fakeredis, mongomock-motor, Qdrant’s :memory: mode) for fast tests, and a real server in a container for the integration tests that must prove the queries behave.

See Testing and Testing Events: lifespan for the details. The examples on this page were run exactly this way, and the section What Was Run Against a Real Server at the end says which engine was real and which was faked.

MongoDB with the PyMongo Async API

MongoDB stores JSON-like documents (BSON), which fit reviews well: each review is one document with a reader, a rating, text and a list of tags, and a new optional field needs no migration. The engine is described in MongoDB Getting Started, Documents and BSON and Drivers and Tooling.

Use PyMongo’s Async API; Motor Is Deprecated

For years the asyncio driver for MongoDB was Motor, a wrapper that ran the synchronous PyMongo driver on a thread pool. PyMongo itself now ships a native asyncio API (pymongo.AsyncMongoClient), and according to MongoDB’s official documentation Motor is deprecated:

  • Motor was scheduled to be deprecated on 2026-05-14, one year after the production release of the PyMongo Async API, so on the date of this page it is already deprecated.

  • Only critical bug fixes are made until 2027-05-14. The latest Motor release on PyPI is 3.7.1 (2025-05-14).

  • MongoDB recommends migrating to the PyMongo Async API while Motor is still supported, and publishes a migration guide.

  • The native API talks to the server with asyncio tasks instead of delegating to a thread pool, which the migration guide says improves latency and throughput; it also warns that some workloads may see slower results than with Motor, so measure.

New code should use AsyncMongoClient. For an existing Motor code base, the migration is mostly mechanical:

Table 2. Motor to PyMongo Async API
Motor PyMongo async API Notes

from motor.motor_asyncio import AsyncIOMotorClient

from pymongo import AsyncMongoClient

The motor package is no longer needed; bson and pymongo.errors are the same.

AsyncIOMotorClient(uri, io_loop=loop)

AsyncMongoClient(uri)

io_loop is not accepted; the client uses the running loop.

client.close() (a plain call)

await client.close()

In the new API closing is a coroutine (checked with inspect.iscoroutinefunction on both). aclose() also exists.

cursor = collection.aggregate(pipeline)

cursor = await collection.aggregate(pipeline)

aggregate is a coroutine in the new API; find() stays a plain call that returns a cursor in both.

await cursor.to_list(0) or to_list(None)

await cursor.to_list(None) or a positive length

to_list(0) is not valid; use None for "all" (and bound it with limit()).

cursor.each(), MotorGridOut.stream_to_handler()

not available

Use async for and the GridFS streaming API instead.

The remaining differences listed by MongoDB are that AsyncMongoClient is not thread safe and must be used by a single event loop, which matches the lifespan pattern: one client per process, created inside the running loop. Third-party fakes written for Motor (such as mongomock-motor) mimic Motor’s signatures and may differ in details such as the aggregate call above, which matters only in tests.

A Lifespan-Managed Client and the Review Collection

The following application stores reviews in a reviews collection. The URI comes from MONGODB_URI (a secret in production, so it is never written in code), the client is created in the lifespan and closed after it, the database is exposed as a dependency, and PyMongoError is mapped to 503:

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, Any, TypedDict

from bson import ObjectId
from bson.errors import InvalidId
from fastapi.responses import JSONResponse
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, BeforeValidator, Field
from pymongo import DESCENDING, AsyncMongoClient, ReturnDocument
from pymongo.asynchronous.database import AsyncDatabase
from pymongo.errors import PyMongoError


class State(TypedDict):
    mongo: AsyncMongoClient[dict[str, Any]]


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
    # The URI comes from the environment (a secret in production), never from the code.
    client: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(
        os.environ.get("MONGODB_URI", "mongodb://localhost:27017"),
        serverSelectionTimeoutMS=2000,
    )
    db = client[os.environ.get("MONGODB_DB", "bookshelf")]
    await db["reviews"].create_index("book_isbn")
    yield {"mongo": client}  # becomes request.state.mongo
    await client.close()


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


@app.exception_handler(PyMongoError)
async def mongo_unavailable(request: Request, exc: PyMongoError) -> JSONResponse:
    return JSONResponse(status_code=503, content={"detail": "Review store unavailable"})


def get_db(request: Request) -> AsyncDatabase[dict[str, Any]]:
    return request.state.mongo[os.environ.get("MONGODB_DB", "bookshelf")]


DbDep = Annotated[AsyncDatabase[dict[str, Any]], Depends(get_db)]

# MongoDB's _id is an ObjectId: expose it as a string called "id".
PyObjectId = Annotated[str, BeforeValidator(str)]


class ReviewIn(BaseModel):
    reader: str = Field(min_length=1, max_length=60)
    rating: int = Field(ge=1, le=5)
    text: str = Field(default="", max_length=2000)
    tags: list[str] = []


class Review(ReviewIn):
    id: PyObjectId = Field(validation_alias="_id")
    book_isbn: str


def parse_object_id(review_id: str) -> ObjectId:
    try:
        return ObjectId(review_id)
    except InvalidId:
        raise HTTPException(status_code=404, detail="Review not found")


@app.post("/books/{isbn}/reviews", status_code=201)
async def add_review(isbn: str, review: ReviewIn, db: DbDep) -> Review:
    doc = {"book_isbn": isbn, **review.model_dump()}
    result = await db["reviews"].insert_one(doc)
    return Review.model_validate({**doc, "_id": result.inserted_id})


@app.get("/books/{isbn}/reviews")
async def list_reviews(isbn: str, db: DbDep, limit: int = 20) -> list[Review]:
    cursor = db["reviews"].find({"book_isbn": isbn}).sort("_id", DESCENDING).limit(limit)
    return [Review.model_validate(doc) for doc in await cursor.to_list(length=limit)]


@app.get("/reviews/{review_id}")
async def get_review(review_id: str, db: DbDep) -> Review:
    doc = await db["reviews"].find_one({"_id": parse_object_id(review_id)})
    if doc is None:
        raise HTTPException(status_code=404, detail="Review not found")
    return Review.model_validate(doc)


@app.patch("/reviews/{review_id}")
async def edit_review(review_id: str, patch: ReviewIn, db: DbDep) -> Review:
    doc = await db["reviews"].find_one_and_update(
        {"_id": parse_object_id(review_id)},
        {"$set": patch.model_dump()},
        return_document=ReturnDocument.AFTER,
    )
    if doc is None:
        raise HTTPException(status_code=404, detail="Review not found")
    return Review.model_validate(doc)


@app.delete("/reviews/{review_id}", status_code=204)
async def delete_review(review_id: str, db: DbDep) -> None:
    result = await db["reviews"].delete_one({"_id": parse_object_id(review_id)})
    if result.deleted_count == 0:
        raise HTTPException(status_code=404, detail="Review not found")


class RatingSummary(BaseModel):
    isbn: str = Field(validation_alias="_id")
    average: float
    count: int


@app.get("/books/{isbn}/rating")
async def rating(isbn: str, db: DbDep) -> RatingSummary:
    pipeline = [
        {"$match": {"book_isbn": isbn}},
        {"$group": {"_id": "$book_isbn", "average": {"$avg": "$rating"}, "count": {"$sum": 1}}},
    ]
    cursor = await db["reviews"].aggregate(pipeline)  # aggregate() is a coroutine in the async API
    docs = await cursor.to_list(length=1)
    if not docs:
        raise HTTPException(status_code=404, detail="No reviews")
    return RatingSummary.model_validate(docs[0])

What to notice:

  • ObjectId and _id. MongoDB assigns an ObjectId primary key named _id. Pydantic cannot validate that type out of the box, and JSON cannot carry it, so the Review model reads _id with validation_alias and converts it to str with Annotated[str, BeforeValidator(str)]; the response then exposes it as id. In the other direction parse_object_id converts a path string back to ObjectId; an invalid string raises bson.errors.InvalidId, which becomes a 404 rather than a 500.

  • Which calls are coroutines. insert_one, find_one, find_one_and_update, delete_one, create_index and aggregate are awaited; find() returns a cursor immediately, and the cursor is consumed with await cursor.to_list(length) or async for. Always bound a cursor (limit() and to_list(length=…​)) so a large collection cannot be loaded into memory by a request.

  • Return the updated document. find_one_and_update(…​, return_document=ReturnDocument.AFTER) returns the new version; the default returns the document before the update.

  • Aggregation. {"$group": …​} computes the average rating per book on the server; see Aggregation Pipeline.

  • Indexes at startup. create_index("book_isbn") is idempotent; creating the indexes you rely on in the lifespan keeps queries fast from the first request (see Indexes). Do not rely on it for migrations of large production collections; run those deliberately.

  • Fail fast and degrade gracefully. With the server down, the lifespan fails at startup within the serverSelectionTimeoutMS (2 seconds here) with ServerSelectionTimeoutError; a request that hits a server that went away later raises the same family of errors, which the exception handler turns into 503 instead of a traceback.

  • Models. Use Pydantic models for the API and plain dictionaries at the driver boundary; the data-modeling guidance (embedding versus referencing) is in Data Modeling.

Try it (the response shapes below come from the run of this application):

export MONGODB_URI="mongodb://localhost:27017"
fastapi dev mongo_app.py
curl -X POST http://127.0.0.1:8000/books/978-0441172719/reviews \
  -H 'Content-Type: application/json' \
  -d '{"reader":"ana","rating":5,"text":"Great","tags":["sf"]}'
curl http://127.0.0.1:8000/books/978-0441172719/rating
{"reader":"ana","rating":5,"text":"Great","tags":["sf"],"id":"000000000000000000000001","book_isbn":"978-0441172719"}
{"isbn":"978-0441172719","average":3.5,"count":2}

In OpenAPI the models appear as ordinary schemas (ReviewIn, Review, RatingSummary), so a generated client never sees ObjectId.

Redis with redis.asyncio

Redis is an in-memory data structure server, and it is the usual answer for two jobs in an API: caching the results of expensive reads and counting requests for rate limiting. The engine is covered in Redis Getting Started and Clients and Client-Side Caching. In Python, the asyncio client is redis.asyncio, part of the official redis package (the former standalone aioredis project was folded into it), imported as from redis import asyncio as redis.

The client itself:

  • redis.Redis.from_url(url, decode_responses=True, max_connections=20) creates a client with an internal connection pool, shared by all requests. decode_responses=True returns str instead of bytes.

  • Close it with await client.aclose(); the internal pool is closed with it. To share one pool between several clients, create a ConnectionPool and pass it with Redis.from_pool(pool) or the connection_pool argument, and close the pool yourself.

  • Since redis-py 8.0 clients use RESP3 on the wire by default, with response shapes kept compatible with RESP2; set protocol=2 to force the old protocol.

  • Errors derive from redis.RedisError; an unreachable server raises redis.ConnectionError (checked against the real client).

Caching with Cache-Aside

Cache-aside means the application, not the cache, owns the logic: look in Redis, on a miss load from the source of truth and store the result with an expiry, and delete the key when the data changes. The application below caches a book page for 60 seconds and reports X-Cache: hit or miss so you can see it work:

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict

from fastapi import Depends, FastAPI, HTTPException, Request, Response
from pydantic import BaseModel
from redis import asyncio as redis


class State(TypedDict):
    redis: redis.Redis


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
    # One client = one connection pool, shared by every request.
    client = redis.Redis.from_url(
        os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
        decode_responses=True,  # str in, str out
        max_connections=20,
    )
    await client.ping()  # fail fast at startup if Redis is unreachable
    yield {"redis": client}
    await client.aclose()  # redis-py >= 5.0.1; close() is deprecated


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


def get_redis(request: Request) -> redis.Redis:
    return request.state.redis


RedisDep = Annotated[redis.Redis, Depends(get_redis)]


class Book(BaseModel):
    isbn: str
    title: str
    rating_average: float


DB: dict[str, Book] = {"978-0441172719": Book(isbn="978-0441172719", title="Dune", rating_average=4.5)}
DB_READS = 0  # counts how often the "database" is really hit


@app.get("/books/{isbn}")
async def get_book(isbn: str, r: RedisDep, response: Response) -> Book:
    global DB_READS
    key = f"book:{isbn}"
    cached = await r.get(key)
    if cached is not None:
        response.headers["X-Cache"] = "hit"
        return Book.model_validate_json(cached)
    DB_READS += 1
    book = DB.get(isbn)
    if book is None:
        raise HTTPException(status_code=404, detail="Book not found")
    await r.set(key, book.model_dump_json(), ex=60)  # expire after 60 seconds
    response.headers["X-Cache"] = "miss"
    return book


@app.put("/books/{isbn}")
async def update_book(isbn: str, book: Book, r: RedisDep) -> Book:
    DB[isbn] = book
    await r.delete(f"book:{isbn}")  # invalidate: the next read repopulates the cache
    return book


class RateLimit:
    """Fixed window: at most `limit` requests per `window` seconds per client and route."""

    def __init__(self, limit: int, window: int) -> None:
        self.limit = limit
        self.window = window

    async def __call__(self, request: Request, response: Response, r: RedisDep) -> None:
        client = request.client.host if request.client else "unknown"
        key = f"ratelimit:{request.url.path}:{client}"
        async with r.pipeline(transaction=True) as pipe:  # MULTI/EXEC
            pipe.incr(key)
            pipe.expire(key, self.window, nx=True)  # start the window only once
            pipe.ttl(key)
            count, _, ttl = await pipe.execute()
        response.headers["X-RateLimit-Remaining"] = str(max(self.limit - count, 0))
        if count > self.limit:
            raise HTTPException(
                status_code=429,
                detail="Too many requests",
                headers={"Retry-After": str(max(ttl, 1))},
            )


@app.get("/search", dependencies=[Depends(RateLimit(limit=3, window=60))])
async def search(q: str) -> dict[str, str]:
    return {"q": q}

Run against fakeredis, the sequence below shows the cache working: the first read is a miss, the second a hit, the PUT invalidates the key, and the next read is a miss again (the "database" was read twice in total):

curl -i http://127.0.0.1:8000/books/978-0441172719   # X-Cache: miss
curl -i http://127.0.0.1:8000/books/978-0441172719   # X-Cache: hit
curl -X PUT http://127.0.0.1:8000/books/978-0441172719 \
  -H 'Content-Type: application/json' \
  -d '{"isbn":"978-0441172719","title":"Dune","rating_average":4.6}'
curl -i http://127.0.0.1:8000/books/978-0441172719   # X-Cache: miss, rating_average 4.6

Design notes:

  • Serialize with Pydantic. model_dump_json() writes the value and model_validate_json() reads it back and validates it, so a corrupted or outdated entry fails loudly instead of leaking into the response.

  • Always set an expiry. set(key, value, ex=60) bounds staleness even if an invalidation is missed, and stops the cache growing forever (see Architecture, Keyspace and Eviction).

  • Name keys systematically (book:{isbn}) so related keys can be found and invalidated together.

  • Invalidate on write. The PUT deletes the key; updating the cache in place instead is possible but easier to get wrong when several writers exist.

  • Do not cache failures blindly. The 404 above is not cached, so a flood of requests for a missing ISBN reaches the database every time; cache a short-lived "not found" marker if that matters.

  • Stampedes. When a hot key expires, many requests can reload it at once. Add random jitter to the expiry, or take a short lock with set(key, "1", nx=True, ex=5) so only one request reloads.

  • Cache only what is safe to share. Never cache a response that depends on the caller (a user’s profile, authorization decisions) under a key that does not include the caller.

  • Client-side HTTP caching is different. Cache-Control and ETag headers tell browsers and proxies to cache; Redis caches inside your service. They combine well; see Response Models & Return Types for setting headers.

Rate Limiting

The RateLimit dependency in the same file implements a fixed window limiter. For each client and route, it INCR`ements a counter; the first increment of a window also starts a timer with `EXPIRE …​ NX (set the expiry only if none exists; the NX option needs Redis 7.0 or later), so the key vanishes when the window ends. The three commands run in one MULTI/EXEC transaction through r.pipeline(transaction=True), so they are sent in one round trip and applied atomically. The result, from the run, with a limit of 3 per 60 seconds:

{"q":"dune"}
{"q":"dune"}
{"q":"dune"}
{"detail":"Too many requests"}

The fourth call returns 429 Too Many Requests with Retry-After: 60 (the remaining time to live of the window); the successful responses carry X-RateLimit-Remaining set to 2, 1 and 0. Because it is a class with call and parameters, one instance is created per route (Depends(RateLimit(limit=3, window=60))), which is the parameterized dependency pattern from Dependency Injection.

Choices and limits of this design:

  • Fixed window allows bursts. A client can send limit requests at the end of one window and limit more at the start of the next. For smoother behaviour use a sliding window or token bucket, implemented atomically with a sorted set or a Lua script (see Pipelining, Transactions and Scripting).

  • Who is "the client"? request.client.host is the address the server sees. Behind a reverse proxy that is the proxy unless proxy headers are configured (Middleware, CORS & Proxies); for authenticated routes key by user ID instead.

  • If Redis is down. Decide on purpose: failing closed (reject) protects the backend, failing open (allow) protects availability. Wrap the dependency body accordingly.

  • One limiter per concern. A global per-IP limit plus a stricter limit on expensive or sensitive routes (login, search) is typical; a reverse proxy or API gateway can enforce the coarse limit before FastAPI is reached.

Elasticsearch with the Async Client

Elasticsearch is a distributed search engine: the right tool for relevance-ranked full-text search, filters and facets over data that lives elsewhere. It is documented in Elasticsearch Getting Started, Clients and REST Conventions, Mapping and Field Types and Query DSL: Full-Text Queries.

The official Python client has an asynchronous twin, elasticsearch.AsyncElasticsearch, with the same API plus await. It needs aiohttp, installed with pip install "elasticsearch[async]". The client library’s documentation shows the same shape used here: create the client in the lifespan and close it on shutdown. Leaving it unclosed makes aiohttp warn about an unclosed client session when the process exits.

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict

from elasticsearch import AsyncElasticsearch, NotFoundError, TransportError
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel

INDEX = "books"


class State(TypedDict):
    es: AsyncElasticsearch


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
    es = AsyncElasticsearch(
        os.environ.get("ELASTICSEARCH_URL", "http://localhost:9200"),
        api_key=os.environ.get("ELASTICSEARCH_API_KEY"),  # None means no authentication
        request_timeout=5,
    )
    if not await es.indices.exists(index=INDEX):
        await es.indices.create(
            index=INDEX,
            mappings={
                "properties": {
                    "title": {"type": "text"},
                    "description": {"type": "text"},
                    "genre": {"type": "keyword"},
                }
            },
        )
    yield {"es": es}
    await es.close()  # closes the underlying HTTP connections


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


@app.exception_handler(TransportError)
async def search_unavailable(request: Request, exc: TransportError) -> JSONResponse:
    return JSONResponse(status_code=503, content={"detail": "Search unavailable"})


def get_es(request: Request) -> AsyncElasticsearch:
    return request.state.es


EsDep = Annotated[AsyncElasticsearch, Depends(get_es)]


class BookDoc(BaseModel):
    isbn: str
    title: str
    description: str = ""
    genre: str = "unknown"


class SearchHit(BaseModel):
    score: float | None
    book: BookDoc


@app.put("/search/books/{isbn}", status_code=204)
async def index_book(isbn: str, book: BookDoc, es: EsDep) -> None:
    await es.index(index=INDEX, id=isbn, document=book.model_dump(), refresh="wait_for")


@app.get("/search/books")
async def search_books(
    q: Annotated[str, Query(min_length=1)],
    es: EsDep,
    genre: str | None = None,
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
) -> list[SearchHit]:
    query: dict = {
        "bool": {
            "must": [{"multi_match": {"query": q, "fields": ["title^2", "description"]}}],
            "filter": [{"term": {"genre": genre}}] if genre else [],
        }
    }
    resp = await es.search(index=INDEX, query=query, from_=(page - 1) * size, size=size)
    return [
        SearchHit(score=hit["_score"], book=BookDoc.model_validate(hit["_source"]))
        for hit in resp["hits"]["hits"]
    ]


@app.delete("/search/books/{isbn}", status_code=204)
async def unindex_book(isbn: str, es: EsDep) -> None:
    try:
        await es.delete(index=INDEX, id=isbn)
    except NotFoundError:
        raise HTTPException(status_code=404, detail="Book not indexed")

Notes on the code:

  • Authentication. api_key= (or basic_auth=, bearer_auth=) takes secrets from the environment; Elastic Cloud uses cloud_id= with an API key. request_timeout bounds every call so a slow cluster cannot hold requests forever.

  • Keyword-only arguments. Modern client calls take index=, id=, document=, query=, size= and from_= (the trailing underscore avoids the Python keyword; it is sent as from). The older single body= argument is still accepted, but the keyword arguments are clearer and are what the documentation uses.

  • Responses. Calls return an ObjectApiResponse, which behaves like a dictionary for the JSON body (resp["hits"]["hits"]); each hit has _id, _score and _source.

  • Errors. HTTP error statuses raise ApiError subclasses such as NotFoundError (the DELETE above maps it to 404); network problems raise TransportError subclasses such as ConnectionError. ApiError and TransportError are separate hierarchies, so a handler for one does not catch the other. To treat a status as a normal result use es.options(ignore_status=404).

  • Refresh. Documents become searchable after the next index refresh (about one second by default). refresh="wait_for" makes the write wait for that, convenient in demos and tests; do not use it on hot write paths.

  • Keep the index in sync. Elasticsearch is a derived view. Write to the source of truth first and then index, or index in a background task (see Settings, Lifespan & Background Tasks), and plan a re-index job for the day the mapping changes.

  • Paging. from_ plus size is fine for the first pages; deep paging needs search_after (see Search API and Pagination). The Query(le=50) bound on size keeps clients from asking for huge pages.

  • Query builders. elasticsearch.dsl (the former elasticsearch-dsl, now part of the client) offers AsyncSearch and AsyncDocument classes if you prefer Python objects to dictionaries.

  • Vector search is built in. Elasticsearch can also hold dense vectors for semantic search; see Vector and Semantic Search.

The requests sent by the client in the run (page 2 of 5 results, filtered by genre) were:

HEAD /books
PUT /books
PUT /books/_doc/978-0441172719?refresh=wait_for
POST /books/_search  {"from": 5, "query": {"bool": {"must": [{"multi_match": {"query": "desert", "fields": ["title^2", "description"]}}], "filter": [{"term": {"genre": "sf"}}]}}, "size": 5}

title^2 boosts matches in the title; the filter clause (not must) skips scoring and is cacheable, which is why the genre goes there.

A Vector Store for Semantic Search over Reviews

Keyword search finds reviews that contain the words you typed. Semantic search finds reviews that mean something similar ("slow and political" matching "drags in the middle") by comparing embeddings: vectors produced by a model, where nearby vectors have similar meaning. The concepts are on What Is a Vector Database, Embeddings and Similarity Metrics and Semantic Search; choosing among engines is on Comparing Vector Databases. Large-language-model applications that build on this are outside this section; the AI guides on this site cover retrieval-augmented generation.

There are two sensible homes for the vectors:

Qdrant with AsyncQdrantClient

The Qdrant Python client provides AsyncQdrantClient. Its constructor takes url= and api_key= for a server, or ":memory:" (and path=) for a local mode that runs inside the Python process: ideal for tests and demos. The application below indexes each review’s embedding with its rating and book as payload, and searches with a metadata filter. It needs an embedding function; to keep the page runnable offline, a toy hashing embedder is injected as a dependency. It matches shared words, not meaning, so replace get_embedder with a real embedding model (a hosted API or a local model, see Generating Embeddings in Practice); nothing else changes, and tests can keep the cheap one by overriding the dependency.

import hashlib
import math
import os
import re
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, Protocol, TypedDict

from fastapi import Depends, FastAPI, Query, Request
from pydantic import BaseModel, Field
from qdrant_client import AsyncQdrantClient, models

COLLECTION = "reviews"
DIM = 64


class Embedder(Protocol):
    dimension: int

    async def embed(self, texts: list[str]) -> list[list[float]]: ...


class HashingEmbedder:
    """Toy embedder so the example runs offline: it matches shared words, not meaning.
    Replace it with a real embedding model (an API call or a local model) in production."""

    dimension = DIM

    async def embed(self, texts: list[str]) -> list[list[float]]:
        vectors = []
        for text in texts:
            vec = [0.0] * DIM
            for word in re.findall(r"[a-z']+", text.lower()):
                bucket = int(hashlib.sha256(word.encode()).hexdigest(), 16) % DIM
                vec[bucket] += 1.0
            norm = math.sqrt(sum(v * v for v in vec)) or 1.0
            vectors.append([v / norm for v in vec])
        return vectors


class State(TypedDict):
    qdrant: AsyncQdrantClient


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
    url = os.environ.get("QDRANT_URL")  # e.g. http://localhost:6333
    client = AsyncQdrantClient(url=url, api_key=os.environ.get("QDRANT_API_KEY")) if url else AsyncQdrantClient(":memory:")
    if not await client.collection_exists(COLLECTION):
        await client.create_collection(
            COLLECTION,
            vectors_config=models.VectorParams(size=DIM, distance=models.Distance.COSINE),
        )
    yield {"qdrant": client}
    await client.close()


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


def get_qdrant(request: Request) -> AsyncQdrantClient:
    return request.state.qdrant


def get_embedder() -> Embedder:
    return HashingEmbedder()


QdrantDep = Annotated[AsyncQdrantClient, Depends(get_qdrant)]
EmbedderDep = Annotated[Embedder, Depends(get_embedder)]


class ReviewIn(BaseModel):
    book_isbn: str
    rating: int = Field(ge=1, le=5)
    text: str = Field(min_length=3)


class ReviewHit(BaseModel):
    id: str
    score: float
    book_isbn: str
    rating: int
    text: str


@app.post("/semantic/reviews", status_code=201)
async def index_review(review: ReviewIn, qdrant: QdrantDep, embedder: EmbedderDep) -> dict[str, str]:
    [vector] = await embedder.embed([review.text])
    point_id = str(uuid.uuid4())
    await qdrant.upsert(
        COLLECTION,
        points=[models.PointStruct(id=point_id, vector=vector, payload=review.model_dump())],
    )
    return {"id": point_id}


@app.get("/semantic/reviews")
async def search_reviews(
    q: Annotated[str, Query(min_length=2)],
    qdrant: QdrantDep,
    embedder: EmbedderDep,
    isbn: str | None = None,
    min_rating: Annotated[int, Query(ge=1, le=5)] = 1,
    limit: Annotated[int, Query(ge=1, le=20)] = 5,
) -> list[ReviewHit]:
    [vector] = await embedder.embed([q])
    must = [models.FieldCondition(key="rating", range=models.Range(gte=min_rating))]
    if isbn:
        must.append(models.FieldCondition(key="book_isbn", match=models.MatchValue(value=isbn)))
    response = await qdrant.query_points(
        COLLECTION, query=vector, query_filter=models.Filter(must=must), limit=limit, with_payload=True
    )
    return [ReviewHit(id=str(p.id), score=p.score, **(p.payload or {})) for p in response.points]

Results from a run in local mode (the vector database was real, the embedder was the toy one):

[
  {"id": "<uuid>", "score": 0.655, "book_isbn": "978-0441172719", "rating": 5, "text": "Epic desert planet story with giant sandworms"}
]

(The first hit of GET /semantic/reviews?q=desert sandworms planet; the other two reviews scored 0.0.)

Key points:

  • Collection and distance. create_collection(…​, vectors_config=VectorParams(size=…​, distance=Distance.COSINE)) fixes the vector size and the metric; both must match the embedding model, and changing the model means re-embedding everything into a new collection.

  • Points. A point is an ID (an unsigned integer or a UUID), a vector and a payload. upsert inserts or replaces. Batch the upserts when ingesting many reviews.

  • Query. query_points(collection, query=vector, query_filter=…​, limit=…​, with_payload=True) returns a response whose points have id, score and payload. (The older search method used in many tutorials is no longer available on the async client in current versions; use query_points.)

  • Filters. Filter(must=[FieldCondition(…​)]) restricts the candidates by payload, here rating >= min_rating and an optional ISBN. On a server, create payload indexes (create_payload_index) for the fields you filter on.

  • Validate on the way in and out. The ReviewIn and ReviewHit models keep the vector store’s loosely typed payload out of your API contract.

  • Keep a source of truth. Store the review text in your primary database and treat the vector store as an index that can be rebuilt; write to both from a single service function or a background task.

pgvector with SQLModel

When the reviews already live in PostgreSQL, the pgvector package adds a Vector column type and distance operators to SQLAlchemy, and therefore to SQLModel’s sa_column. The table needs the extension (CREATE EXTENSION vector;, normally in a migration):

from typing import Any

from pgvector.sqlalchemy import Vector
from sqlalchemy import Column
from sqlalchemy.dialects import postgresql
from sqlmodel import Field, SQLModel, select


class ReviewEmbedding(SQLModel, table=True):
    __tablename__ = "review_embedding"
    id: int | None = Field(default=None, primary_key=True)
    book_isbn: str = Field(index=True)
    text: str
    embedding: list[float] = Field(sa_column=Column(Vector(64)))  # needs CREATE EXTENSION vector


def nearest(query_vector: list[float], limit: int = 5):
    distance = ReviewEmbedding.embedding.cosine_distance(query_vector)
    return select(ReviewEmbedding, distance.label("distance")).order_by(distance).limit(limit)


if __name__ == "__main__":
    stmt = nearest([0.1] * 64)
    print(stmt.compile(dialect=postgresql.dialect()))
    print(ReviewEmbedding.__table__.c.embedding.type)
    from sqlalchemy.schema import CreateTable
    print(CreateTable(ReviewEmbedding.__table__).compile(dialect=postgresql.dialect()))

    from sqlalchemy import Index
    from sqlalchemy.schema import CreateIndex
    hnsw = Index(
        "review_embedding_hnsw",
        ReviewEmbedding.embedding,
        postgresql_using="hnsw",
        postgresql_with={"m": 16, "ef_construction": 64},
        postgresql_ops={"embedding": "vector_cosine_ops"},
    )
    print(CreateIndex(hnsw).compile(dialect=postgresql.dialect()))

The statements it generates for PostgreSQL (compiled, not run against a server) are:

SELECT review_embedding.id, review_embedding.book_isbn, review_embedding.text, review_embedding.embedding,
       review_embedding.embedding <=> %(embedding_1)s AS distance
FROM review_embedding ORDER BY review_embedding.embedding <=> %(embedding_1)s LIMIT %(param_1)s

CREATE TABLE review_embedding (
    id SERIAL NOT NULL,
    book_isbn VARCHAR NOT NULL,
    text VARCHAR NOT NULL,
    embedding VECTOR(64),
    PRIMARY KEY (id)
)

CREATE INDEX review_embedding_hnsw ON review_embedding USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64)

<⇒ is cosine distance (smaller is closer; similarity is 1 - distance). Without an index the search scans every row, which is exact but slow; the HNSW index trades a little recall for speed (the hnsw.ef_search setting tunes the trade-off at query time). The sessions and Depends wiring are the same as on SQL Databases with SQLModel; the vector is just another column. The Vector(64) dimension must equal the embedding size, and filters are plain SQL WHERE clauses, which is pgvector’s strongest argument: one query can combine similarity, filters and joins in one transaction.

Table 3. Qdrant or pgvector?
Question Qdrant pgvector

Operational cost

A second service to run, back up and secure

An extension in the database you already run

Filtering

Payload filters with payload indexes, designed for filtered search

Any SQL predicate, joins, transactions

Scale

Built for very large collections, sharding, quantization

Commonly used up to millions of rows; beyond that, benchmark and plan carefully

Consistency with the rest of the data

Eventual: you keep it in sync

Same transaction as the row

Typical choice

Search is a core product feature

Search is one feature of a relational application

JSON Columns in SQL

Sometimes only part of a row is flexible: a book has an ISBN and a title for sure, but formats, awards and an edition note differ by book. Instead of a second database, store that part in a JSON column. PostgreSQL has JSON and the binary, indexable JSONB; SQLite and MySQL have JSON support too; SQLAlchemy’s generic sqlalchemy.JSON type maps Python dictionaries and lists to each database’s JSON type. SQL-side functions are on JSON and XML Functions.

SQLModel has no JSON field type of its own, so you declare the column with SQLAlchemy through sa_column. The example adds three important ideas: JSONB on PostgreSQL via with_variant, MutableDict to track changes, and a Pydantic model that defines (and validates) the shape that goes in the column.

from collections.abc import Iterator
from contextlib import asynccontextmanager
from typing import Annotated, Any

from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import JSON, Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.pool import StaticPool
from sqlmodel import Field, Session, SQLModel, create_engine, select

# JSON on every database, JSONB (binary, indexable) on PostgreSQL;
# MutableDict makes assigning a top-level key mark the row as changed.
ExtrasType = MutableDict.as_mutable(JSON().with_variant(JSONB(), "postgresql"))


class BookExtras(BaseModel):
    """The shape we promise for the flexible part of a book."""

    edition: str | None = None
    formats: list[str] = []
    awards: list[str] = []


class Book(SQLModel, table=True):
    isbn: str = Field(primary_key=True)
    title: str
    extras: dict[str, Any] = Field(default_factory=dict, sa_column=Column(ExtrasType, nullable=False))


class BookCreate(BaseModel):
    isbn: str
    title: str
    extras: BookExtras = BookExtras()


class BookPublic(BaseModel):
    isbn: str
    title: str
    extras: BookExtras


engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)


@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)  # use Alembic migrations in real projects
    yield
    engine.dispose()


app = FastAPI(title="Bookshelf API", lifespan=lifespan)


def get_session() -> Iterator[Session]:
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


@app.post("/books", status_code=201)
def create_book(data: BookCreate, session: SessionDep) -> BookPublic:
    book = Book(isbn=data.isbn, title=data.title, extras=data.extras.model_dump(mode="json"))
    session.add(book)
    session.commit()
    session.refresh(book)
    return BookPublic(isbn=book.isbn, title=book.title, extras=BookExtras.model_validate(book.extras))


@app.get("/books")
def list_books(session: SessionDep, edition: str | None = None, award: str | None = None) -> list[BookPublic]:
    statement = select(Book)
    if edition:
        statement = statement.where(Book.extras["edition"].as_string() == edition)
    books = session.exec(statement).all()
    result = [BookPublic(isbn=b.isbn, title=b.title, extras=BookExtras.model_validate(b.extras)) for b in books]
    if award:  # filtering inside a JSON array is database specific: do it in Python here
        result = [b for b in result if award in b.extras.awards]
    return result


@app.post("/books/{isbn}/awards", status_code=201)
def add_award(isbn: str, award: str, session: SessionDep) -> BookPublic:
    book = session.get(Book, isbn)
    if book is None:
        raise HTTPException(status_code=404, detail="Book not found")
    extras = BookExtras.model_validate(book.extras)
    extras.awards.append(award)
    # Assign a new value to a top-level key: appending to book.extras["awards"] in place
    # would not be detected, because only the top-level dictionary is tracked.
    book.extras["awards"] = extras.awards
    session.add(book)
    session.commit()
    session.refresh(book)
    return BookPublic(isbn=book.isbn, title=book.title, extras=BookExtras.model_validate(book.extras))

Run on SQLite, the sequence behaved like this (the same code compiles to JSONB on PostgreSQL):

{"isbn":"978-0441172719","title":"Dune","extras":{"edition":"2nd","formats":["hardcover","ebook"],"awards":[]}}
{"isbn":"978-0261103573","title":"The Hobbit","extras":{"edition":null,"formats":[],"awards":[]}}
{"isbn":"978-0441172719","title":"Dune","extras":{"edition":"2nd","formats":["hardcover","ebook"],"awards":["Hugo"]}}

The generated SQL for the edition filter, per database:

-- PostgreSQL
SELECT book.isbn, book.title, book.extras FROM book WHERE CAST((book.extras ->> %(extras_1)s) AS VARCHAR) = %(param_1)s
-- SQLite
WHERE JSON_EXTRACT(book.extras, ?) = ?

Points worth knowing:

  • The table model does not validate. A SQLModel class with table=True skips Pydantic validation, so the shape of extras is enforced by the separate BookCreate and BookPublic models (BookExtras inside), and the column stores model_dump(mode="json"). Always use mode="json" so dates, enums and sets become JSON-safe values.

  • with_variant(JSONB(), "postgresql") keeps one model that is generic JSON on SQLite (tests) and JSONB on PostgreSQL (production). JSONB can be indexed with a GIN index and queried with containment operators; see JSON and XML Functions for the SQL side.

  • Change tracking. SQLAlchemy does not notice changes made inside a plain JSON value. MutableDict.as_mutable(…​) makes assigning a top-level key (book.extras["awards"] = […​]) mark the row as modified, but it does not track nested changes: the run showed book.extras["awards"].append("Locus") followed by commit() left the stored list at ["Hugo", "Nebula"], while assigning a new list persisted it. Build a new value and assign it, or call sqlalchemy.orm.attributes.flag_modified.

  • Querying. Book.extras["edition"].as_string() == "2nd" filters on a JSON key in the database. Filtering inside JSON arrays is database specific (@> on PostgreSQL), which is why the example filters by award in Python; for anything you filter or sort on often, promote the field to a real column.

  • Migrations. Adding a JSON column is a normal Alembic migration; changing the shape of the JSON is a data migration you write yourself, so version the shape (a schema_version key) if it will evolve.

  • When not to use it. Data you join on, constrain, or aggregate heavily belongs in columns. A JSON column is for attributes that vary, not a way to avoid schema design.

Where Older Guides Are Outdated, and What Changed Recently

  • Motor. Guides that tell you to use Motor with FastAPI describe the pre-2025 situation. PyMongo’s own async API is the recommended driver, Motor was deprecated on 2026-05-14 and receives only critical bug fixes until 2027-05-14.

  • aioredis. The standalone aioredis package is no longer maintained separately; use redis.asyncio from redis. Use await client.aclose() to close it, and note the RESP3 default of redis-py 8.0.

  • Elasticsearch. Older guides use separate "async" or elasticsearch-dsl packages and the body= argument. Install elasticsearch[async], call the API with keyword arguments, and use elasticsearch.dsl if you want the DSL.

  • Startup events and globals. Global clients created at import time and @app.on_event("startup") hooks are replaced by the lifespan (0.93.0, 2023-03-07) and lifespan state exposed through request.state and a dependency.

  • Pydantic v2. Models read documents with model_validate and write with model_dump / model_dump_json; the v1 .dict() and .json() methods and parse_obj are the old API. Use Annotated with BeforeValidator for ObjectId, not the custom get_validators classes of older guides.

  • Vector search is mainstream. It is now available in Elasticsearch, Redis, PostgreSQL (pgvector), MongoDB Atlas and dedicated engines; start from the access pattern before adding another system.

  • Release history. See What’s Changed & Migration.

Pitfalls

  • Creating a client per request. Every request opens new pools and handshakes and leaks connections under load. Create one in the lifespan.

  • Creating the client at import time. It may be bound to the wrong event loop and is shared across tests, reloader processes and forked workers. With several worker processes each one runs its own lifespan and so has its own client and pool; size pools with the number of workers in mind (see Deployment).

  • Blocking drivers in async def. A synchronous client (for example the classic pymongo.MongoClient or redis.Redis) called from async def blocks the event loop; use the async clients, or def path functions that run in the threadpool (see Async & Concurrency).

  • Forgetting await. A coroutine that is never awaited does nothing and returns a coroutine object; Python warns, the response is wrong. This is especially easy with aggregate (awaited in PyMongo async, not in Motor) and cursor methods.

  • Not closing clients. Missing aclose() / close() produces "unclosed client session" warnings and leaves connections open at shutdown.

  • Unbounded results. find() without limit, search without size, or caching a whole table. Bound everything.

  • Trusting the cache or index. Redis, Elasticsearch and the vector store are derived data; an invalidation or indexing failure produces stale answers. Set expiries, and keep a re-index path.

  • Secrets in code. URIs with passwords, API keys and tokens belong in environment variables or secret files (see Settings, Lifespan & Background Tasks); the examples read them from the environment. Do not log connection strings.

  • Testing only against fakes. fakeredis, mongomock and local mode are excellent for unit tests but are not the real engines (query semantics, consistency and error behaviour differ); keep integration tests against real servers.

  • Returning driver objects. ObjectId, ObjectApiResponse and Qdrant ScoredPoint do not belong in your response models; convert to Pydantic models at the boundary (see Response Models & Return Types).

  • Error mapping. Map driver failures to meaningful statuses (503 for an unavailable store, 404 for a missing document) with exception handlers; see Status Codes & Error Handling.

What Was Run Against a Real Server

Everything above was executed on Python 3.13 with TestClient, but not every engine was real:

Table 4. How each example was verified
Example Engine used Notes

Lifespan state

fake Store class

Real FastAPI/Starlette; includes the dependency override and the missing-lifespan failure.

MongoDB

mongomock-motor in memory (no mongod available)

Real PyMongo AsyncMongoClient was exercised only for construction, serverSelectionTimeoutMS and the 503 path against an unreachable address; method names and which calls are coroutines were checked on the real class.

Redis

fakeredis

Real redis.asyncio client constructed to check max_connections, pool type and the error raised without a server.

Elasticsearch

Real AsyncElasticsearch client against a small fake HTTP server

No Elasticsearch server was available; the requests shown are what the client really sent.

Qdrant

Real AsyncQdrantClient in :memory: local mode

Filtering and scoring ran for real; the embedder is a toy.

pgvector

SQL compiled for the PostgreSQL dialect

Not run against PostgreSQL.

JSON column

SQLite for execution, PostgreSQL dialect compiled

JSONB behaviour on PostgreSQL itself was not run.

Treat the queries as a starting point and test them against the real engines before relying on them.