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.
| Need | Store | Driver | Why |
|---|---|---|---|
Reviews with a free-form shape (tags, reactions, edits) |
MongoDB |
PyMongo async API ( |
Documents map to Pydantic models; no migrations for new optional fields. |
Hot book pages, rate limiting, short-lived tokens |
Redis |
|
In-memory, sub-millisecond reads, atomic counters and expiry. |
Full-text search over titles and descriptions with relevance ranking |
Elasticsearch |
|
Analyzers, scoring, facets, paging; not a primary data store. |
"Reviews that feel like this one" (semantic search) |
Qdrant, or pgvector inside PostgreSQL |
|
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 |
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:
-
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.
-
Hand it to the requests through lifespan state: the lifespan yields a dictionary and Starlette copies it into
request.statefor every request. -
Close the client after the
yield, once the server has finished handling requests. -
Expose it through a small dependency that reads
request.state, so path functions ask for a typed argument and tests can replace it.
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()}
Source: FastAPI: Lifespan Events and Starlette: Lifespan State
The pieces:
-
@asynccontextmanagerlifespan. Everything beforeyieldruns before the first request is served; everything after runs after the last one. If the code beforeyieldraises, the application does not start, which is what you want when a required database is unreachable (the examples belowpingor create indexes there on purpose). -
The yielded dictionary is the lifespan state. It is exposed as
request.state.store; theStateTypedDictonly documents the keys for type checkers, sincerequest.stateitself is untyped. Hence the dependency (get_store) declares the precise return type once, and the rest of the code stays typed and never touchesrequest.statedirectly. -
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. Thestartup/shutdownhandlers are deprecated; the lifespan replaces them (FastAPI 0.93.0, 2023-03-07); once you passlifespan, 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
BaseSettingsclass), never from source code. The examples reados.environto 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, aTestClientused withoutwithdoes not run the lifespan, so without an overriderequest.state.storedoes not exist and the request fails with anAttributeError. -
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:
| Motor | PyMongo async API | Notes |
|---|---|---|
|
|
The |
|
|
|
|
|
In the new API closing is a coroutine (checked with |
|
|
|
|
|
|
|
not available |
Use |
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:
-
ObjectIdand_id. MongoDB assigns anObjectIdprimary key named_id. Pydantic cannot validate that type out of the box, and JSON cannot carry it, so theReviewmodel reads_idwithvalidation_aliasand converts it tostrwithAnnotated[str, BeforeValidator(str)]; the response then exposes it asid. In the other directionparse_object_idconverts a path string back toObjectId; an invalid string raisesbson.errors.InvalidId, which becomes a404rather than a500. -
Which calls are coroutines.
insert_one,find_one,find_one_and_update,delete_one,create_indexandaggregateare awaited;find()returns a cursor immediately, and the cursor is consumed withawait cursor.to_list(length)orasync for. Always bound a cursor (limit()andto_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) withServerSelectionTimeoutError; a request that hits a server that went away later raises the same family of errors, which the exception handler turns into503instead 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=Truereturnsstrinstead ofbytes. -
Close it with
await client.aclose(); the internal pool is closed with it. To share one pool between several clients, create aConnectionPooland pass it withRedis.from_pool(pool)or theconnection_poolargument, 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=2to force the old protocol. -
Errors derive from
redis.RedisError; an unreachable server raisesredis.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}
Source: redis-py: asyncio examples and Redis: redis-py guide
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 andmodel_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
PUTdeletes 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
404above 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-ControlandETagheaders 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
limitrequests at the end of one window andlimitmore 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.hostis 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=(orbasic_auth=,bearer_auth=) takes secrets from the environment; Elastic Cloud usescloud_id=with an API key.request_timeoutbounds every call so a slow cluster cannot hold requests forever. -
Keyword-only arguments. Modern client calls take
index=,id=,document=,query=,size=andfrom_=(the trailing underscore avoids the Python keyword; it is sent asfrom). The older singlebody=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,_scoreand_source. -
Errors. HTTP error statuses raise
ApiErrorsubclasses such asNotFoundError(theDELETEabove maps it to404); network problems raiseTransportErrorsubclasses such asConnectionError.ApiErrorandTransportErrorare separate hierarchies, so a handler for one does not catch the other. To treat a status as a normal result usees.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_plussizeis fine for the first pages; deep paging needssearch_after(see Search API and Pagination). TheQuery(le=50)bound onsizekeeps clients from asking for huge pages. -
Query builders.
elasticsearch.dsl(the formerelasticsearch-dsl, now part of the client) offersAsyncSearchandAsyncDocumentclasses 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:
-
A dedicated vector database such as Qdrant. Purpose-built indexes (HNSW), payload filtering and quantization; scale independently of your relational database. Site references: Qdrant Getting Started, Collections and Points, Filtering and Payload Indexing and Vector Indexing and HNSW.
-
pgvector inside PostgreSQL. One store, one transaction, joins with your relational data; see PostgreSQL pgvector (and SQLite Vector Search for small local setups).
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]
Source: Qdrant documentation and qdrant-client
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.
upsertinserts or replaces. Batch the upserts when ingesting many reviews. -
Query.
query_points(collection, query=vector, query_filter=…, limit=…, with_payload=True)returns a response whosepointshaveid,scoreandpayload. (The oldersearchmethod used in many tutorials is no longer available on the async client in current versions; usequery_points.) -
Filters.
Filter(must=[FieldCondition(…)])restricts the candidates by payload, hererating >= min_ratingand 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
ReviewInandReviewHitmodels 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()))
Source: pgvector-python: SQLAlchemy and pgvector
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.
| 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))
Source: SQLModel: Tutorial and SQLAlchemy: JSON
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=Trueskips Pydantic validation, so the shape ofextrasis enforced by the separateBookCreateandBookPublicmodels (BookExtrasinside), and the column storesmodel_dump(mode="json"). Always usemode="json"so dates, enums and sets become JSON-safe values. -
with_variant(JSONB(), "postgresql")keeps one model that is generic JSON on SQLite (tests) andJSONBon PostgreSQL (production).JSONBcan 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 showedbook.extras["awards"].append("Locus")followed bycommit()left the stored list at["Hugo", "Nebula"], while assigning a new list persisted it. Build a new value and assign it, or callsqlalchemy.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_versionkey) 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 standaloneaioredispackage is no longer maintained separately; useredis.asynciofromredis. Useawait client.aclose()to close it, and note the RESP3 default of redis-py 8.0. -
Elasticsearch. Older guides use separate "async" or
elasticsearch-dslpackages and thebody=argument. Installelasticsearch[async], call the API with keyword arguments, and useelasticsearch.dslif 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 throughrequest.stateand a dependency. -
Pydantic v2. Models read documents with
model_validateand write withmodel_dump/model_dump_json; the v1.dict()and.json()methods andparse_objare the old API. UseAnnotatedwithBeforeValidatorforObjectId, not the customget_validatorsclasses 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 classicpymongo.MongoClientorredis.Redis) called fromasync defblocks the event loop; use the async clients, ordefpath 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 withaggregate(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()withoutlimit,searchwithoutsize, 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,mongomockand 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,ObjectApiResponseand QdrantScoredPointdo 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 (
503for an unavailable store,404for 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:
| Example | Engine used | Notes |
|---|---|---|
Lifespan state |
fake |
Real FastAPI/Starlette; includes the dependency override and the missing-lifespan failure. |
MongoDB |
|
Real PyMongo |
Redis |
|
Real |
Elasticsearch |
Real |
No Elasticsearch server was available; the requests shown are what the client really sent. |
Qdrant |
Real |
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.