NoSQL, Redis, MongoDB & Search

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 Flask 3.1.3 (released 2026-02-18) and Werkzeug 3.1.9, together with redis-py 8.1.0, PyMongo 4.18.2, MongoEngine 0.29.3, Flask-PyMongo 3.0.1, the Elasticsearch Python client 9.5.1, Flask-Limiter 4.1.1, Whoosh-Reloaded 2.7.5 and SQLite 3.53.1, checked on 2026-10-07. Flask 3.2 and Werkzeug 3.2 were not released on that date, so nothing here depends on them. The code uses Python 3.10+ syntax and every Python block was executed on Python 3.13 with app.test_client(). No block talks to a real server: Redis is replaced by fakeredis 2.39.0, MongoDB by mongomock 4.3.0 and Elasticsearch by a 40-line in-memory fake that is shown on the page, while Whoosh and SQLite FTS5 run for real because they are libraries that live inside the process. The blocks build on each other and share one namespace, so they are meant to be read, and run, in order. The running scenario is the Bookshelf catalogue that the other pages of this section build on: view counters and a "most read" list, expiring download links, rate limiting, readers' reviews as free-form documents and full-text search over the catalogue.

This page covers only the Flask side of the wiring: where the clients are created, how views reach them, how they are tested and which mistakes are specific to a web application. What Redis, MongoDB and Elasticsearch are, how their data models, indexes and query languages work and how they are operated is described on the engine pages of this site: the Redis section (see especially Clients and Client-Side Caching and Use Cases and Patterns), the MongoDB section (see Drivers and Tooling and Indexes) and the Elasticsearch section (see Clients and REST Conventions and Query DSL: Full-Text Queries). Relational data is the subject of the SQLAlchemy and migrations pages of this section, which come later; here the relational database stays the source of truth and the three engines on this page are fast, specialised neighbours of it.

What Each Store Is For in Bookshelf

Flask has no data layer of its own, so adding a NoSQL store is not a framework feature: it is a library, a client object and a few lines of glue. The decision that matters is which kind of data goes where.

Store Good for in Bookshelf Not a good fit for

Redis

View counters, a "most read" sorted set, one-time download links, short-lived caches, rate-limit windows, locks, Celery broker and result backend, server-side sessions

The catalogue itself: by default it keeps data in memory, so the dataset is bounded by RAM and durability is a configuration choice

MongoDB

Readers' reviews and other documents whose shape varies or grows (ratings, free text, tags, reading progress), event logs, content that is read and written as a whole

Data with many cross-document relations and multi-row invariants, which is what a relational database is for

Elasticsearch or OpenSearch

Relevance-ranked full-text search with stemming, typo tolerance and facets over the catalogue

The primary store: it is eventually consistent and is a derived index of data that lives elsewhere

Whoosh-Reloaded, SQLite FTS5

Search for small catalogues, prototypes and single-server deployments, without running another service

Large corpora, many writers, clustering and anything that needs the operational features of a search engine

The Flask Framework Cookbook (3rd edition) touches all three: its chapter 3 keeps recently visited products with an expiry in Redis and rewrites the catalogue on MongoDB through Flask-MongoEngine, and its chapter 14 adds Elasticsearch search (the 2nd edition uses Whoosh as well). The ideas are sound; several of the details are not, and a section near the end of this page lists which, with the current replacement for each.

Wiring Clients in the Application Factory

The rule for every external service is the same: create the client once per process, in the application factory, and let views reach it through the application, never through a module-level global that was created at import time. The reasons are the ones that the factory pattern gives for everything else: the configuration is known only inside create_app(), tests can build an app with a different configuration, and importing a module must not open sockets.

Flask offers a place for exactly this: app.extensions is a plain dictionary in which an extension stores its state, keyed by the extension name. Flask-SQLAlchemy, Flask-Login and Flask-Limiter all use it. A hand-written integration does the same, which means a view looks the client up with current_app.extensions[...] and is therefore tied to whichever application is handling the request.

flowchart LR env["Environment variables
REDIS_URL, MONGO_URI, ELASTICSEARCH_URL"] --> factory["create_app()"] factory --> ext["app.extensions"] ext --> redisc["redis.Redis"] ext --> mongoc["pymongo.MongoClient"] ext --> searchc["Elasticsearch"] view["View function"] -->|"current_app.extensions"| ext redisc --> rs[("Redis")] mongoc --> ms[("MongoDB")] searchc --> es[("Elasticsearch")] test["Test fixture"] -.->|"injects fakes"| factory
Figure 1. The clients live in app.extensions and are reached through current_app

All three clients are lazy: constructing them does not open a connection, so create_app() succeeds even when a service is down and the failure appears at the first command, where a view can turn it into a clean 503. The factory below accepts optional, ready-made clients, which is the whole testing story: production code passes nothing and gets real clients built from the configuration, a test passes fakes.

import os
from collections.abc import Iterable

import redis
from elasticsearch import Elasticsearch
from flask import Blueprint, Flask, current_app
from pymongo import MongoClient


def create_app(test_config=None, *, redis_client=None, mongo_client=None,
               search_client=None, blueprints: Iterable[Blueprint] = ()):
    app = Flask(__name__)
    app.config.from_mapping(
        REDIS_URL=os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
        MONGO_URI=os.environ.get("MONGO_URI", "mongodb://localhost:27017"),
        MONGO_DBNAME=os.environ.get("MONGO_DBNAME", "bookshelf"),
        ELASTICSEARCH_URL=os.environ.get("ELASTICSEARCH_URL", "http://localhost:9200"),
    )
    if test_config:
        app.config.from_mapping(test_config)

    if redis_client is None:
        redis_client = redis.Redis.from_url(
            app.config["REDIS_URL"],
            decode_responses=True,
            socket_connect_timeout=2,
            socket_timeout=2,
            health_check_interval=30,
        )
    if mongo_client is None:
        mongo_client = MongoClient(
            app.config["MONGO_URI"], tz_aware=True, serverSelectionTimeoutMS=3000
        )
    if search_client is None:
        options = {"request_timeout": 5}
        if api_key := os.environ.get("ELASTICSEARCH_API_KEY"):
            options["api_key"] = api_key
        search_client = Elasticsearch(app.config["ELASTICSEARCH_URL"], **options)

    app.extensions["redis"] = redis_client
    app.extensions["mongo"] = mongo_client
    app.extensions["search"] = search_client
    for blueprint in blueprints:
        app.register_blueprint(blueprint)
    return app


def get_redis() -> redis.Redis:
    return current_app.extensions["redis"]


def get_mongo_db():
    return current_app.extensions["mongo"][current_app.config["MONGO_DBNAME"]]


def get_search() -> Elasticsearch:
    return current_app.extensions["search"]


app = create_app({"TESTING": True})
assert isinstance(app.extensions["redis"], redis.Redis)
assert isinstance(app.extensions["mongo"], MongoClient)
assert isinstance(app.extensions["search"], Elasticsearch)
print("clients built without connecting:", sorted(app.extensions))

The configuration keys are ordinary Flask configuration (see Configuration), and every value that can differ between environments, or that carries a credential, is read from the environment instead of the source tree.

Key or variable Meaning Example value

REDIS_URL

Redis connection URL. The scheme rediss:// switches TLS on; a password goes in the URL’s user-info part and comes from a secret store, never from the repository

redis://localhost:6379/0

MONGO_URI

MongoDB connection string, mongodb:// or mongodb+srv://, including credentials and options such as replicaSet when needed

mongodb://localhost:27017

MONGO_DBNAME

Database that the application uses

bookshelf

ELASTICSEARCH_URL

One node URL, or several separated in code; HTTPS in production

http://localhost:9200

ELASTICSEARCH_API_KEY

Environment variable read by the factory and passed to the client as api_key, so that it never appears in configuration files

set by the deployment platform

The same factory is what makes the tests fast and hermetic. A pytest fixture builds the application with fakes: fakeredis speaks the Redis protocol in-process, mongomock imitates the PyMongo API on an in-memory store and a hand-written fake covers the few Elasticsearch calls the application makes. The fakes are an approximation, which the page comes back to in the testing remarks.

import fakeredis
import mongomock
import pytest


@pytest.fixture
def make_app():
    def factory(*blueprints, search_client=None, **config):
        return create_app(
            {"TESTING": True, **config},
            redis_client=fakeredis.FakeRedis(decode_responses=True),
            mongo_client=mongomock.MongoClient(tz_aware=True),
            search_client=search_client or object(),
            blueprints=blueprints,
        )
    return factory


app = create_app(
    {"TESTING": True},
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(tz_aware=True),
    search_client=object(),
)
with app.app_context():
    get_redis().set("hello", "world")
    assert get_redis().get("hello") == "world"
    get_mongo_db().books.insert_one({"title": "Flask Web Development"})
    assert get_mongo_db().books.count_documents({}) == 1
print("fakes injected through the factory")

Four pitfalls apply to all three clients:

  • Do not close a client in teardown_appcontext. That hook runs after every request, and these clients are process-wide objects with their own connection pools; closing them per request throws the pool away.

  • Do not create a client per request. A new TCP connection (and a new TLS handshake) for each request is slow and exhausts the server’s connection limit under load.

  • Do not create a client before the server process forks. redis-py detects a changed process id and resets its pool, but PyMongo’s documentation says a MongoClient must not be used across a fork, and its background monitor threads do not survive it. With Gunicorn’s --preload the factory runs in the master, so either do not preload or create the clients lazily in each worker (a post_fork hook or a first-use helper). Flask-PyMongo and the factory above both build the client without connecting, which makes the lazy approach cheap.

  • A client is not a transaction and not a unit of work. Nothing ties a Redis or MongoDB write to the SQL transaction of the same request, a point that matters in the search section below.

Redis

redis-py (the redis package, imported as redis) is the official Python client. redis.Redis holds a thread-safe connection pool, so the single instance in app.extensions is shared by every request thread. Redis.from_url() builds it from a URL, and the options below are the ones a web application sets deliberately.

Option Why a Flask application cares

decode_responses=True

Returns str instead of bytes. Without it get() gives b"42" and a comparison with "42" is silently false. Turn it on once, at creation time, and keep binary payloads in a separate client if you have any

socket_connect_timeout, socket_timeout

Without timeouts a dead Redis blocks the worker thread indefinitely, and a few blocked requests take the whole worker pool down. Two seconds is a common starting point for a cache

health_check_interval

Sends a PING on a connection that has been idle for that many seconds before reusing it, which avoids using connections that a firewall or load balancer dropped silently

max_connections

Upper bound for the pool; match it to the worker’s thread count rather than leaving it unlimited

retry, retry_on_timeout, retry_on_error

Retry policy for transient failures. Retrying a non-idempotent command such as INCR can double-count, so it is a deliberate decision

rediss:// URL scheme, or the ssl_* options

TLS, mandatory for any Redis that is not on a private loopback or a trusted network, including managed offerings

Counters and Leaderboards

Redis commands on one key are atomic, and INCR creates the key at zero when it does not exist. That makes it the idiomatic page-view counter: no read, no race, no lock. A sorted set (ZINCRBY) keeps a ranking up to date with the same single call. A pipeline() sends several commands in one round trip and, by default, wraps them in MULTI and EXEC, so the counter and the ranking change together.

from flask import Blueprint, jsonify

stats_bp = Blueprint("stats", __name__)


@stats_bp.post("/books/<int:book_id>/views")
def record_view(book_id):
    pipe = get_redis().pipeline()
    pipe.incr(f"book:{book_id}:views")
    pipe.zincrby("books:popular", 1, book_id)
    views, _ = pipe.execute()
    return jsonify(book_id=book_id, views=views)


@stats_bp.get("/popular")
def popular():
    top = get_redis().zrange("books:popular", 0, 2, desc=True, withscores=True)
    return jsonify([{"book_id": int(member), "views": int(score)} for member, score in top])


app = create_app(
    {"TESTING": True},
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(),
    search_client=object(),
    blueprints=[stats_bp],
)
client = app.test_client()
for book_id in (1, 2, 2, 3, 2):
    client.post(f"/books/{book_id}/views")
assert client.post("/books/1/views").get_json() == {"book_id": 1, "views": 2}
assert client.get("/popular").get_json() == [
    {"book_id": 2, "views": 3},
    {"book_id": 1, "views": 2},
    {"book_id": 3, "views": 1},
]
print(client.get("/popular").get_json())

Key names are a convention, not a feature: use a prefix per concern (book:12:views, books:popular) so that keys are readable in redis-cli and several applications can share one database without collisions. Counting every anonymous POST also invites inflation by a script; counting unique readers needs a set or a HyperLogLog (PFADD) per day instead, and the data structures are described in Lists, Sets, Sorted Sets, Arrays and Geospatial.

Expiry and One-Time Tokens

Every key can carry a time to live (SET key value EX seconds), after which Redis deletes it. This is the primitive behind caches, short-lived tokens and rate-limit windows, and it replaces the cleanup job that a relational table would need. For a single-use token GETDEL (Redis 6.2 and later) reads and deletes in one atomic step, so two concurrent requests cannot both redeem the same link. The token itself comes from the secrets module, never from random.

import secrets

from flask import abort, url_for

tokens_bp = Blueprint("tokens", __name__)
DOWNLOAD_TTL = 300


@tokens_bp.post("/books/<int:book_id>/download-links")
def create_download_link(book_id):
    token = secrets.token_urlsafe(16)
    get_redis().set(f"dl:{token}", book_id, ex=DOWNLOAD_TTL)
    return jsonify(url=url_for("tokens.download", token=token), expires_in=DOWNLOAD_TTL), 201


@tokens_bp.get("/download/<token>")
def download(token):
    book_id = get_redis().getdel(f"dl:{token}")
    if book_id is None:
        abort(404)
    return jsonify(book_id=int(book_id), note="the file would be streamed here")


app = create_app(
    {"TESTING": True},
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(),
    search_client=object(),
    blueprints=[tokens_bp],
)
client = app.test_client()
link = client.post("/books/7/download-links").get_json()
with app.app_context():
    ttl = get_redis().ttl("dl:" + link["url"].rsplit("/", 1)[1])
assert 0 < ttl <= DOWNLOAD_TTL
assert client.get(link["url"]).get_json()["book_id"] == 7
assert client.get(link["url"]).status_code == 404  # already redeemed
print("token redeemed once, ttl was", ttl)

Source: Redis: GETDEL

Give every key that is created from user activity an expiry. A key without a TTL lives until memory pressure or the eviction policy removes it, which turns a cache into a slow memory leak; the eviction policies are explained in Architecture, Keyspace and Eviction.

A Cache-Aside Helper

The pattern most applications start with is cache-aside: look in Redis, on a miss load from the real store, write the result back with a TTL, and delete the key when the underlying data changes. Store JSON, not pickles: unpickling data from a shared Redis is code execution for anyone who can write to it. Flask-Caching wraps the same idea behind @cache.cached and @cache.memoize and can use a Redis backend; it is the subject of the caching page of this section, and the hand-written version here shows what it does underneath.

import json

BOOK_TTL = 60


def get_book_cached(book_id, loader):
    key = f"cache:book:{book_id}"
    redis_conn = get_redis()
    if (hit := redis_conn.get(key)) is not None:
        return json.loads(hit)
    book = loader(book_id)
    if book is not None:
        redis_conn.set(key, json.dumps(book), ex=BOOK_TTL)
    return book


def invalidate_book(book_id):
    get_redis().delete(f"cache:book:{book_id}")


calls = []


def load_from_database(book_id):
    calls.append(book_id)
    return {"id": book_id, "title": "Flask Web Development"}


with app.app_context():
    assert get_book_cached(1, load_from_database)["title"] == "Flask Web Development"
    assert get_book_cached(1, load_from_database)["id"] == 1
    assert calls == [1]  # the second call was a cache hit
    invalidate_book(1)
    get_book_cached(1, load_from_database)
    assert calls == [1, 1]
print("loader calls:", calls)

The usual traps are caching an error or an empty result for a long time, forgetting to invalidate on every write path (including admin tools and migrations), and the stampede that happens when a hot key expires and many requests rebuild it at once; add a little random jitter to TTLs, or take a short lock, for keys that are both hot and expensive.

Rate Limiting

A fixed-window limiter needs one key per client and window: increment it on each request and refuse the request when the count passes the limit; the key expires by itself when the window ends. The three commands below go through one pipeline, so they are sent together and applied atomically. SET ... NX EX creates the key with its expiry only when it is absent, which removes the classic bug of an INCR followed by a separate EXPIRE that never runs when the process dies in between and leaves a key that counts forever.

import functools
import logging

from flask import make_response, request
from redis.exceptions import RedisError

log = logging.getLogger(__name__)


def rate_limit(limit: int, window: int):
    def decorator(view):
        @functools.wraps(view)
        def wrapper(*args, **kwargs):
            key = f"rl:{request.endpoint}:{request.remote_addr}"
            try:
                pipe = get_redis().pipeline()
                pipe.set(key, 0, ex=window, nx=True)
                pipe.incr(key)
                pipe.ttl(key)
                _, count, ttl = pipe.execute()
            except RedisError:
                log.exception("rate limiter unavailable, letting the request through")
                return view(*args, **kwargs)
            if count > limit:
                response = make_response(jsonify(error="rate limit exceeded"), 429)
                response.headers["Retry-After"] = str(max(ttl, 1))
                return response
            return view(*args, **kwargs)
        return wrapper
    return decorator


limited_bp = Blueprint("limited", __name__)


@limited_bp.get("/search-suggestions")
@rate_limit(limit=3, window=60)
def suggestions():
    return jsonify(suggestions=["flask", "flask web development"])


app = create_app(
    {"TESTING": True},
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(),
    search_client=object(),
    blueprints=[limited_bp],
)
client = app.test_client()
statuses = [client.get("/search-suggestions").status_code for _ in range(5)]
assert statuses == [200, 200, 200, 429, 429]
blocked = client.get("/search-suggestions")
assert 1 <= int(blocked.headers["Retry-After"]) <= 60
print(statuses, "Retry-After:", blocked.headers["Retry-After"])

Source: Redis: SET

Decisions that the code makes and that you should make on purpose:

  • Fail open or closed. The except RedisError branch lets the request through when Redis is down. For a public search box that is right; for a login endpoint protecting against password guessing, failing closed (answer 503) may be safer.

  • Who is "the client". request.remote_addr is the address of the last proxy unless the application sits behind ProxyFix configured with the right number of trusted hops. Trusting X-Forwarded-For blindly lets a client choose its own identity and bypass the limit. Authenticated endpoints should key on the user id instead.

  • The algorithm. A fixed window allows a burst of twice the limit across a window boundary. Sliding-window and token-bucket limiters smooth that and are what libraries implement.

Flask-Limiter packages all of this. Limiter(key_func, app=app) reads RATELIMIT_STORAGE_URI from the configuration, so the same code uses memory:// in development and tests and a Redis URL in production. It also adds the X-RateLimit-* and Retry-After headers when RATELIMIT_HEADERS_ENABLED is set. The in-memory storage is per process, so with several Gunicorn workers each one counts separately; a shared limit needs a shared storage such as Redis.

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

app = Flask(__name__)
app.config["RATELIMIT_STORAGE_URI"] = os.environ.get("RATELIMIT_STORAGE_URI", "memory://")
app.config["RATELIMIT_HEADERS_ENABLED"] = True
limiter = Limiter(get_remote_address, app=app, default_limits=["200 per hour"])


@app.get("/ping")
@limiter.limit("2 per minute")
def ping():
    return {"pong": True}


client = app.test_client()
statuses = [client.get("/ping").status_code for _ in range(3)]
assert statuses == [200, 200, 429]
assert "Retry-After" in client.get("/ping").headers
print(statuses)

Redis does more for a Flask application than the three jobs above. It can hold server-side sessions (the Flask-Session extension of the Sessions, Cookies & Flashing page), back the Flask-Caching extension and serve as the Celery broker and result backend, both of which have their own pages (Caching and Background Jobs, Celery & Email), and it offers publish/subscribe and Streams for messaging between processes (see Streams and Pub/Sub). Redis-protocol-compatible servers such as Valkey work with the same client; see Redis Software, Cloud, Kubernetes and Valkey. The old Flask-Redis extension (version 0.4.0, last released in 2019) only wraps redis.Redis in an init_app function; the factory above does the same in a dozen lines without an unmaintained dependency. Asynchronous code uses redis.asyncio from the same package.

MongoDB

PyMongo is the official driver. A MongoClient is thread-safe, owns a connection pool and a background topology monitor, and is meant to be created once. The factory above already did it; the options that matter for a web application:

Option Why a Flask application cares

tz_aware=True

Returns timezone-aware datetime objects (in UTC) instead of naive ones. Pair it with datetime.now(timezone.utc) when writing, and avoid datetime.utcnow(), which is deprecated since Python 3.12

serverSelectionTimeoutMS

How long a command waits for a usable server before raising ServerSelectionTimeoutError. The default of 30 seconds is far longer than a web request should wait

maxPoolSize, minPoolSize

Pool bounds per server; size maxPoolSize for the number of threads that may query at once

connect=False

Delays the background connection until first use, which is what a pre-fork server needs (Flask-PyMongo sets it for you)

retryWrites, w, readPreference

Durability and routing, normally set in the connection string. Their semantics are on the MongoDB pages linked above

appname

Shows up in the server’s logs and profiler, so slow queries can be traced to the service

Credentials belong in MONGO_URI, read from the environment, with the user name and password percent-encoded (urllib.parse.quote_plus) when they contain reserved characters. Never build the URI by string-pasting a password into code.

Documents, ObjectId and JSON

A MongoDB document gets an _id of type bson.ObjectId unless you provide one, and two Flask features trip over it:

  • The default JSON provider cannot serialise an ObjectId, so jsonify(document) fails with a TypeError. The provider is replaceable (app.json, a subclass of flask.json.provider.JSONProvider), and a small subclass of DefaultJSONProvider that overrides default is enough.

  • /reviews/<review_id> would hand a string to the view. A custom URL converter (a werkzeug.routing.BaseConverter subclass registered in app.url_map.converters) turns it into an ObjectId and answers 404 for a malformed id, instead of the InvalidId exception and a 500 that parsing inside the view would produce. Converters are described on the Routing & URL Building page.

The Bookshelf reviews are a natural document: a rating, a text, an optional list of tags and a timestamp, attached to a book by ISBN. The view validates the types of the payload before it touches the database, for a reason that the injection section below explains.

from datetime import datetime, timezone

from bson import ObjectId
from bson.errors import InvalidId
from flask.json.provider import DefaultJSONProvider
from werkzeug.routing import BaseConverter, ValidationError


class ObjectIdConverter(BaseConverter):
    regex = r"[0-9a-fA-F]{24}"

    def to_python(self, value):
        try:
            return ObjectId(value)
        except InvalidId:
            raise ValidationError()

    def to_url(self, value):
        return str(value)


class MongoJSONProvider(DefaultJSONProvider):
    @staticmethod
    def default(o):
        if isinstance(o, ObjectId):
            return str(o)
        return DefaultJSONProvider.default(o)


reviews_bp = Blueprint("reviews", __name__)


def serialise(review):
    document = dict(review)
    document["id"] = str(document.pop("_id"))
    return document


@reviews_bp.post("/books/<isbn>/reviews")
def add_review(isbn):
    data = request.get_json(silent=True) or {}
    rating, text, tags = data.get("rating"), data.get("text"), data.get("tags", [])
    if not (isinstance(rating, int) and not isinstance(rating, bool) and 1 <= rating <= 5):
        return jsonify(error="rating must be an integer from 1 to 5"), 422
    if not isinstance(text, str) or not 1 <= len(text) <= 2000:
        return jsonify(error="text must be a string of 1 to 2000 characters"), 422
    if not (isinstance(tags, list) and all(isinstance(tag, str) for tag in tags)):
        return jsonify(error="tags must be a list of strings"), 422
    document = {
        "isbn": isbn, "rating": rating, "text": text, "tags": tags,
        "created_at": datetime.now(timezone.utc),
    }
    result = get_mongo_db().reviews.insert_one(document)
    return jsonify(id=result.inserted_id), 201  # an ObjectId, serialised by the provider


@reviews_bp.get("/books/<isbn>/reviews")
def list_reviews(isbn):
    cursor = get_mongo_db().reviews.find({"isbn": isbn}).sort("created_at", -1).limit(20)
    return jsonify([serialise(review) for review in cursor])


@reviews_bp.get("/reviews/<objectid:review_id>")
def get_review(review_id):
    review = get_mongo_db().reviews.find_one({"_id": review_id})
    if review is None:
        abort(404)
    return jsonify(serialise(review))


def create_reviews_app():
    app = create_app(
        {"TESTING": True},
        redis_client=fakeredis.FakeRedis(decode_responses=True),
        mongo_client=mongomock.MongoClient(tz_aware=True),
        search_client=object(),
    )
    app.url_map.converters["objectid"] = ObjectIdConverter
    app.json = MongoJSONProvider(app)
    app.register_blueprint(reviews_bp)
    return app


app = create_reviews_app()
client = app.test_client()
created = client.post("/books/9781098135195/reviews", json={"rating": 5, "text": "Clear and current", "tags": ["flask"]})
assert created.status_code == 201
review_id = created.get_json()["id"]
assert client.get(f"/reviews/{review_id}").get_json()["rating"] == 5
assert client.get("/reviews/not-an-object-id").status_code == 404
assert client.post("/books/x/reviews", json={"rating": 9, "text": "?"}).status_code == 422
print(client.get("/books/9781098135195/reviews").get_json())

Two details of the code deserve a comment. The converter has to be registered on the URL map before a route that uses it is registered, which is why create_reviews_app() does it ahead of register_blueprint(). And the provider is assigned with app.json = MongoJSONProvider(app); setting the class attribute app.json_provider_class before the app is created works too. Flask-PyMongo, below, ships both pieces.

Indexes and Aggregation

MongoDB does not create indexes for you apart from _id, so every query the application runs often needs one, and a unique index is the only reliable way to enforce uniqueness. Creating indexes is a deployment step, not something to do on each request: a Click command registered with app.cli.command() is the natural home, run once per release as flask --app bookshelf ensure-indexes. create_index() is idempotent when the specification is unchanged. The aggregation pipeline then does in the database what would otherwise be a loop in Python, here the average rating and review count of a book.

import click
from pymongo import ASCENDING, DESCENDING


reviews_tools_bp = Blueprint("reviews_tools", __name__, cli_group=None)


@reviews_tools_bp.cli.command("ensure-indexes")
def ensure_indexes():
    """Create the indexes the reviews queries rely on."""
    reviews = get_mongo_db().reviews
    reviews.create_index([("isbn", ASCENDING), ("created_at", DESCENDING)])
    click.echo("indexes ready")


@reviews_tools_bp.get("/books/<isbn>/rating")
def rating(isbn):
    pipeline = [
        {"$match": {"isbn": isbn}},
        {"$group": {"_id": "$isbn", "average": {"$avg": "$rating"}, "count": {"$sum": 1}}},
    ]
    result = next(get_mongo_db().reviews.aggregate(pipeline), None)
    if result is None:
        return jsonify(average=None, count=0)
    return jsonify(average=round(result["average"], 2), count=result["count"])


app = create_reviews_app()
app.register_blueprint(reviews_tools_bp)
runner = app.test_cli_runner()
result = runner.invoke(args=["ensure-indexes"])
assert result.exit_code == 0 and "indexes ready" in result.output, result.output
client = app.test_client()
for stars in (5, 4, 4):
    client.post("/books/9781098135195/reviews", json={"rating": stars, "text": "ok"})
assert client.get("/books/9781098135195/rating").get_json() == {"average": 4.33, "count": 3}
print(result.output.strip(), client.get("/books/9781098135195/rating").get_json())

By default a blueprint’s commands are grouped under the blueprint’s name (flask reviews_tools ensure-indexes); cli_group=None puts them at the top level, which is what the block does. The mongomock fake accepts index creation but does not enforce or optimise with indexes, so what the test proves is that the command runs; verifying the plan of a query (explain()) needs a real server, for example in a container started by CI.

NoSQL Injection

Query documents are data structures, and a Flask view that puts a request value inside one inherits a second kind of injection. request.get_json() returns whatever structure the client sent, so a login view that does users.find_one({"name": data["name"], "password": data["password"]}) can be given {"name": "admin", "password": {"$ne": null}}, and the query then reads "the password is not null". The defence is the same as for SQL: never let untrusted input choose the structure of the query. Check that the value is a string (or int, or whatever the field is) before it enters the filter; a schema library such as Pydantic or marshmallow does this declaratively, and the reviews view above does it by hand. Passwords are never compared in a query anyway: the stored hash is loaded by user name and verified with check_password_hash, as the authentication page of this section shows.

users = mongomock.MongoClient().bookshelf.users
users.insert_one({"name": "admin", "password_hash": secrets.token_hex(16)})

# Vulnerable shape: the client controls the whole value of "password_hash".
attack = {"name": "admin", "password_hash": {"$ne": None}}
assert users.find_one(attack) is not None  # matches without knowing the hash


def safe_find_user(name):
    if not isinstance(name, str):
        return None
    return users.find_one({"name": name})


assert safe_find_user({"$ne": None}) is None
assert safe_find_user("admin")["name"] == "admin"
print("operator injection blocked by type checks")

The same applies to $where and to server-side JavaScript: never build either from request data, and keep security.javascriptEnabled off on servers that do not need it (see MongoDB Security).

Flask-PyMongo

Flask-PyMongo 3.0.1 is a thin wrapper around the same driver. PyMongo(app) reads MONGO_URI from the configuration (or takes a URI argument), builds the client with connect=False, exposes it as mongo.cx and the database named in the URI as mongo.db, adds find_one_or_404() on collections, registers the ObjectId URL converter under the name ObjectId (case matters) and installs a JSON provider that encodes BSON types. It also has send_file() and save_file() helpers for GridFS. It does everything the hand-written pieces above do, and init_app() works with the factory pattern. The block below exercises only the setup, because the extension builds its own MongoClient subclass and so cannot be pointed at mongomock; its queries are those of the driver.

from flask_pymongo import PyMongo

mongo = PyMongo()


def create_flask_pymongo_app():
    app = Flask(__name__)
    app.config["MONGO_URI"] = os.environ.get("MONGO_URI", "mongodb://localhost:27017/bookshelf")
    mongo.init_app(app)

    @app.get("/reviews/<ObjectId:review_id>")
    def review(review_id):
        return jsonify(mongo.db.reviews.find_one_or_404({"_id": review_id}))

    return app


app = create_flask_pymongo_app()
assert mongo.db.name == "bookshelf"
assert "ObjectId" in app.url_map.converters
assert app.test_client().get("/reviews/zzz").status_code == 404  # rejected by the converter, no database access
print("Flask-PyMongo configured; database:", mongo.db.name)

MongoEngine and Flask-MongoEngine

MongoEngine is an object-document mapper in the style of an ORM: classes derive from Document, fields have types, validation and indexes are declared on the class, and queries go through Review.objects(...). It is worth considering when the data has a stable schema that you want enforced in Python, and it needs no Flask-specific package: a single connect() call in the factory is enough. connect() registers a process-wide default connection (not one per application), so tests that build many apps call disconnect() first. In current MongoEngine a mock server is selected with mongo_client_class=mongomock.MongoClient; the old mongomock:// URI is rejected with an exception that says so.

from mongoengine import Document, IntField, ListField, StringField, connect, disconnect


class Review(Document):
    isbn = StringField(required=True)
    rating = IntField(min_value=1, max_value=5, required=True)
    text = StringField(max_length=2000)
    tags = ListField(StringField(max_length=30))
    meta = {"indexes": ["isbn"]}


def init_mongoengine(app, **options):
    disconnect()
    return connect(host=app.config["MONGO_URI"], db=app.config["MONGO_DBNAME"], **options)


app = create_app({"TESTING": True}, redis_client=object(), mongo_client=object(), search_client=object())
init_mongoengine(app, mongo_client_class=mongomock.MongoClient)

mongoengine_bp = Blueprint("mongoengine_reviews", __name__)


@mongoengine_bp.post("/books/<isbn>/reviews")
def add_review_odm(isbn):
    data = request.get_json(silent=True) or {}
    review = Review(isbn=isbn, rating=data.get("rating"), text=data.get("text"), tags=data.get("tags", []))
    try:
        review.validate()
    except Exception as error:  # mongoengine.ValidationError
        return jsonify(error=str(error)), 422
    review.save()
    return jsonify(id=str(review.id)), 201


app.register_blueprint(mongoengine_bp)
client = app.test_client()
assert client.post("/books/x/reviews", json={"rating": 5, "text": "good"}).status_code == 201
assert client.post("/books/x/reviews", json={"rating": 6}).status_code == 422
assert Review.objects(isbn="x").count() == 1
print("documents:", Review.objects.count())

The Cookbook’s recipe uses Flask-MongoEngine, and that is where the book is out of date. The release on PyPI is 1.0.0, uploaded in 2020, and on Flask 3.1.3 importing it fails with ImportError: cannot import name 'JSONEncoder' from 'flask.json', because JSONEncoder was removed from Flask in 2.3. The package cannot be used with a current Flask. Use connect() from MongoEngine directly, as above, or PyMongo or Flask-PyMongo.

Operational remarks that apply to all three Python access styles:

  • Multi-document transactions need a replica set or a sharded cluster (see Transactions); a standalone development server cannot run them, so tests that rely on them need a replica set in a container.

  • PyMongo is synchronous. In an async def Flask view or in Quart, use PyMongo’s own asynchronous client (AsyncMongoClient) rather than Motor, which MongoDB deprecated in 2025 in favour of it.

  • Always bound the work a request can trigger: limit() on cursors, a maxTimeMS on expensive queries and indexes on the sort fields. An unbounded find() on a growing collection is the NoSQL version of SELECT * without LIMIT.

Search is a different problem from lookup: the user types words, the engine tokenises, stems and ranks. The relational LIKE '%word%' does not scale and has no ranking, which is why this section has a small table of options before the main one.

Option Fits Cost

Elasticsearch (client 8.x or 9.x) or OpenSearch (opensearch-py)

Large or growing catalogues, relevance tuning, facets, several writers, clustering

A separate stateful service to run, secure, size and keep in sync with the database

PostgreSQL full-text search (tsvector, GIN)

Moderate catalogues when the data is already in PostgreSQL; no second system to synchronise

Fewer relevance features and no typo tolerance without extensions

SQLite FTS5

Prototypes, tests, single-file deployments

One writer at a time; basic ranking (bm25)

Whoosh-Reloaded

Pure-Python index in a directory, no server

A community fork of a library whose original has been unmaintained for years; keep it to small catalogues

Elasticsearch

The official Python client is the elasticsearch package, and the major version of the client should match the major version of the server (client 8.x for an 8.x cluster, 9.x for 9.x). The calls on this page are the same in both. The client constructor Elasticsearch(hosts, api_key=..., ca_certs=..., request_timeout=..., verify_certs=...) is what the factory already built; for production the relevant settings are an API key (or basic_auth) read from the environment, HTTPS with the cluster’s CA through ca_certs, and never verify_certs=False. Elastic Cloud deployments use cloud_id instead of a URL. The official clients are built for Elasticsearch; OpenSearch has its own client package with the same shape.

Three changes since the 2nd edition decide how the code looks today:

  • Request bodies are passed as keyword arguments. es.index(index=..., document=...) and es.search(index=..., query=..., size=...) replace es.index(index=..., body=...) and es.search(body={"query": ...}). The body parameter still exists in the signature but is deprecated.

  • The offset of a search is the keyword from_, because from is a Python keyword.

  • Document types are gone (doc_type= was removed from the client in 8), and a mapping is given as mappings= to indices.create().

An index is declared with a mapping (which fields are analysed text and which are exact keyword values; see Mapping and Field Types), documents are indexed under an id that you choose (the ISBN is a natural one, which makes re-indexing idempotent) and a multi_match query searches several fields at once, with title^2 boosting the title. Total hits come back as hits.total.value; to page through results use from_ and size, and search_after for deep paging (see Search API and Pagination).

The fake below implements only the methods and response shape that the service uses, so the service code that follows is exactly what runs against a real cluster, and the tests need no Docker.

import re


class FakeIndices:
    def __init__(self, store):
        self.store = store

    def exists(self, *, index):
        return index in self.store.docs

    def create(self, *, index, mappings=None, **_):
        self.store.docs.setdefault(index, {})

    def refresh(self, *, index):
        pass


class FakeSearch:
    """Just enough of the Elasticsearch client for the tests on this page: not a search engine."""

    def __init__(self):
        self.docs = {}
        self.indices = FakeIndices(self)

    def index(self, *, index, document, id=None, **_):
        self.docs.setdefault(index, {})[id] = dict(document)
        return {"result": "created", "_id": id}

    def delete(self, *, index, id, **_):
        self.docs.get(index, {}).pop(id, None)
        return {"result": "deleted"}

    def bulk(self, *, operations, **_):
        for action, document in zip(operations[::2], operations[1::2]):
            meta = action["index"]
            self.index(index=meta["_index"], id=meta["_id"], document=document)
        return {"errors": False}

    def search(self, *, index, query, from_=0, size=10, **_):
        spec = query["multi_match"]
        terms = re.findall(r"\w+", spec["query"].lower())
        scored = []
        for doc_id, doc in self.docs.get(index, {}).items():
            score = 0.0
            for field in spec["fields"]:
                name, _, boost = field.partition("^")
                words = re.findall(r"\w+", str(doc.get(name, "")).lower())
                score += sum(term in words for term in terms) * float(boost or 1)
            if score:
                scored.append((score, doc_id, doc))
        scored.sort(key=lambda row: (-row[0], str(row[1])))
        page = scored[from_:from_ + size]
        return {"hits": {
            "total": {"value": len(scored), "relation": "eq"},
            "hits": [{"_id": i, "_score": s, "_source": d} for s, i, d in page],
        }}


fake = FakeSearch()
fake.index(index="books", id="1", document={"title": "Flask Web Development", "author": "Miguel Grinberg"})
assert fake.search(index="books", query={"multi_match": {"query": "flask", "fields": ["title^2", "author"]}})["hits"]["total"]["value"] == 1
print("fake search client ready")

The service layer is a handful of functions that take the client as an argument (so a test passes the fake) and a blueprint that exposes them. The search endpoint is public and read-only; the two endpoints that change the catalogue are protected by a bearer token that the deployment provides through configuration, compared in constant time. A full authentication system is the subject of the authentication page of this section; the point of the guard here is that a destructive endpoint is never shown without one. The books themselves live in SQLite (the source of truth); Elasticsearch holds a copy that can be rebuilt at any time.

import hmac
import sqlite3
import tempfile
from pathlib import Path

from elastic_transport import TransportError
from elasticsearch import ApiError
from flask import g

BOOKS_INDEX = "books"
BOOKS_MAPPINGS = {"properties": {
    "title": {"type": "text"},
    "author": {"type": "text"},
    "isbn": {"type": "keyword"},
    "year": {"type": "integer"},
}}


def ensure_books_index(es):
    if not es.indices.exists(index=BOOKS_INDEX):
        es.indices.create(index=BOOKS_INDEX, mappings=BOOKS_MAPPINGS)


def index_book(es, book):
    es.index(index=BOOKS_INDEX, id=book["isbn"], document={
        "title": book["title"], "author": book["author"],
        "isbn": book["isbn"], "year": book["year"],
    })


def search_books(es, text, page=1, per_page=10):
    response = es.search(
        index=BOOKS_INDEX,
        query={"multi_match": {"query": text, "fields": ["title^2", "author"]}},
        from_=(page - 1) * per_page,
        size=per_page,
    )
    hits = response["hits"]
    return {
        "total": hits["total"]["value"],
        "results": [{"score": h["_score"], **h["_source"]} for h in hits["hits"]],
    }


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(current_app.config["DATABASE"])
        g.db.row_factory = sqlite3.Row
    return g.db


def admin_required(view):
    @functools.wraps(view)
    def wrapper(*args, **kwargs):
        expected = current_app.config.get("ADMIN_TOKEN")
        supplied = request.headers.get("Authorization", "").removeprefix("Bearer ")
        if not expected or not hmac.compare_digest(supplied, expected):
            abort(401)
        return view(*args, **kwargs)
    return wrapper


catalogue_bp = Blueprint("catalogue", __name__)


@catalogue_bp.teardown_app_request
def close_db(_exc):
    if db := g.pop("db", None):
        db.close()


@catalogue_bp.get("/search")
def search():
    text = request.args.get("q", "").strip()
    if not text:
        return jsonify(error="q is required"), 400
    page = request.args.get("page", 1, type=int)
    try:
        return jsonify(search_books(get_search(), text, page=max(page, 1)))
    except (ApiError, TransportError):
        current_app.logger.exception("search backend unavailable")
        return jsonify(error="search is temporarily unavailable"), 503


@catalogue_bp.post("/books")
@admin_required
def add_book():
    data = request.get_json(silent=True) or {}
    try:
        book = {"isbn": str(data["isbn"]), "title": str(data["title"]),
                "author": str(data["author"]), "year": int(data["year"])}
    except (KeyError, TypeError, ValueError):
        return jsonify(error="isbn, title, author and year are required"), 422
    db = get_db()
    db.execute("INSERT OR REPLACE INTO books (isbn, title, author, year) VALUES (:isbn, :title, :author, :year)", book)
    db.commit()                      # the database is the source of truth: commit first ...
    try:
        index_book(get_search(), book)  # ... then update the derived index
    except (ApiError, TransportError):
        current_app.logger.exception("indexing failed; run the reindex command to repair")
    return jsonify(book), 201


@catalogue_bp.delete("/books/<isbn>")
@admin_required
def delete_book(isbn):
    db = get_db()
    db.execute("DELETE FROM books WHERE isbn = ?", (isbn,))
    db.commit()
    try:
        get_search().delete(index=BOOKS_INDEX, id=isbn)
    except (ApiError, TransportError):
        current_app.logger.exception("removing from the index failed; run the reindex command")
    return "", 204


workdir = Path(tempfile.mkdtemp())
admin_token = secrets.token_hex(16)
fake = FakeSearch()
app = create_app(
    {"TESTING": True, "DATABASE": str(workdir / "bookshelf.db"), "ADMIN_TOKEN": admin_token},
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(),
    search_client=fake,
    blueprints=[catalogue_bp],
)
with sqlite3.connect(app.config["DATABASE"]) as connection:
    connection.execute("CREATE TABLE books (isbn TEXT PRIMARY KEY, title TEXT, author TEXT, year INTEGER)")
ensure_books_index(fake)

client = app.test_client()
auth = {"Authorization": f"Bearer {admin_token}"}
assert client.post("/books", json={"isbn": "1", "title": "Flask Web Development", "author": "Miguel Grinberg", "year": 2018}).status_code == 401
for isbn, title, author, year in [
    ("1", "Flask Web Development", "Miguel Grinberg", 2018),
    ("2", "Flask Framework Cookbook", "Shalabh Aggarwal", 2023),
    ("3", "Designing Data-Intensive Applications", "Martin Kleppmann", 2017),
]:
    response = client.post("/books", json={"isbn": isbn, "title": title, "author": author, "year": year}, headers=auth)
    assert response.status_code == 201
found = client.get("/search", query_string={"q": "flask cookbook"}).get_json()
assert found["total"] == 2 and found["results"][0]["isbn"] == "2"
assert client.get("/search").status_code == 400
assert client.delete("/books/2", headers=auth).status_code == 204
assert client.get("/search", query_string={"q": "cookbook"}).get_json()["total"] == 0
print(found["results"][0]["title"], "ranked first")

Keeping the Index in Step With the Database

Writing to two systems is the weak point of every search integration. There is no transaction across a SQL database and Elasticsearch, so the two writes can disagree when the second one fails or when the process dies between them.

sequenceDiagram participant C as Client participant F as Flask view participant D as SQL database participant E as Elasticsearch C->>F: POST /books F->>D: INSERT and commit D-->>F: committed F->>E: index document alt indexing fails E--xF: error or timeout F->>F: log, keep the 201 Note over F,E: the flask reindex command repairs it later else indexing succeeds E-->>F: created end F-->>C: 201 Created
Figure 2. Write path: database first, index second, repair by reindexing

The page’s choice is the pragmatic one: commit to the database first, index afterwards, log a failure instead of failing the request, and provide a reindex command that rebuilds the index from the database. That command is also how a new mapping is rolled out. Stronger designs exist for when a stale index is not acceptable: do the indexing in a background task (the Celery page of this section) after the commit, or write an outbox row in the same SQL transaction and let a worker publish it. SQLAlchemy offers after_commit session events for the "index after commit" hook; indexing in before_commit or inside the request, before the transaction ends, indexes rows that may still be rolled back. A bulk load uses the bulk API (es.bulk(operations=[...]), or elasticsearch.helpers.bulk() which batches and reports per-document errors), which is what the command uses below; the real client’s bulk takes the action and document lines alternately.

reindex_bp = Blueprint("reindex", __name__, cli_group=None)


@reindex_bp.cli.command("reindex")
def reindex():
    """Rebuild the books index from the database."""
    es = get_search()
    ensure_books_index(es)
    rows = get_db().execute("SELECT isbn, title, author, year FROM books").fetchall()
    operations = []
    for row in rows:
        operations.append({"index": {"_index": BOOKS_INDEX, "_id": row["isbn"]}})
        operations.append(dict(row))
    if operations:
        es.bulk(operations=operations, refresh=True)
    click.echo(f"reindexed {len(rows)} books")


app2 = create_app(
    app.config,
    redis_client=fakeredis.FakeRedis(decode_responses=True),
    mongo_client=mongomock.MongoClient(),
    search_client=fake,
    blueprints=[catalogue_bp, reindex_bp],
)
fake.docs.clear()           # simulate an index that lost its data
result = app2.test_cli_runner().invoke(args=["reindex"])
assert result.exit_code == 0, result.output
assert "reindexed 2 books" in result.output
assert client.get("/search", query_string={"q": "grinberg"}).get_json()["total"] == 1
print(result.output.strip())

Security and operations of the search service: keep it on a private network, enable TLS and authentication (they are on by default from Elasticsearch 8; the engine page Security covers roles and API keys), give the application an API key limited to the books index instead of an administrator account, and never pass the user’s raw input as a query body: build the query on the server, as search_books() does, so that a user cannot ask for scripts, other indices or unbounded result windows. Cap per_page as you would any pagination.

Whoosh-Reloaded

The second edition of the Cookbook uses Whoosh through Flask-WhooshAlchemy. That extension is abandoned and its installation instruction, a git+git:// URL, no longer works because GitHub removed the unauthenticated git:// protocol. The original Whoosh library has not been maintained for years; Whoosh-Reloaded is a community fork that keeps it running on current Python and is imported as whoosh. Because it is a pure-Python library, an index is a directory, which makes it a convenient teaching and prototype tool: no service, nothing to secure on the network. Treat it as a stop-gap, not a foundation.

from whoosh.fields import ID, TEXT, Schema
from whoosh.index import create_in
from whoosh.qparser import MultifieldParser

schema = Schema(isbn=ID(stored=True, unique=True), title=TEXT(stored=True), author=TEXT(stored=True))
index_dir = Path(tempfile.mkdtemp())
ix = create_in(index_dir, schema)

writer = ix.writer()
for isbn, title, author in [
    ("1", "Flask Web Development", "Miguel Grinberg"),
    ("2", "Flask Framework Cookbook", "Shalabh Aggarwal"),
    ("3", "Designing Data-Intensive Applications", "Martin Kleppmann"),
]:
    writer.update_document(isbn=isbn, title=title, author=author)  # upsert by the unique field
writer.commit()

with ix.searcher() as searcher:
    query = MultifieldParser(["title", "author"], ix.schema).parse("cookbook")
    titles = [hit["title"] for hit in searcher.search(query, limit=10)]
assert titles == ["Flask Framework Cookbook"]
print(titles)

A Whoosh index allows one writer at a time (a lock file in the directory), so with several Gunicorn workers all writes must go through one process or a queue.

SQLite FTS5

When the application already uses SQLite, FTS5 is a full-text index that needs no dependency at all: a virtual table that you fill and query with MATCH, ranked by bm25() (a smaller value is a better match). It is a good fit for tests and small single-server deployments, and parameter binding keeps it injection-safe like any other query. A raw user string can still be a syntax error in FTS5 query language (an unbalanced quote, for example), so quote user input as a phrase or catch sqlite3.OperationalError and answer 400.

connection = sqlite3.connect(":memory:")
connection.execute("CREATE VIRTUAL TABLE book_fts USING fts5(title, author, tokenize='porter')")
connection.executemany(
    "INSERT INTO book_fts (title, author) VALUES (?, ?)",
    [
        ("Flask Web Development", "Miguel Grinberg"),
        ("Flask Framework Cookbook", "Shalabh Aggarwal"),
        ("Designing Data-Intensive Applications", "Martin Kleppmann"),
    ],
)


def fts_search(text):
    phrase = '"' + text.replace('"', '""') + '"'   # a quoted phrase is always valid FTS5 syntax
    return connection.execute(
        "SELECT title FROM book_fts WHERE book_fts MATCH ? ORDER BY bm25(book_fts)", (phrase,)
    ).fetchall()


assert [row[0] for row in fts_search("developments")] == ["Flask Web Development"]  # the porter stemmer matches
assert fts_search('"unbalanced') == []
print(fts_search("flask"))

Pitfalls and Security Checklist

  • Secrets. Connection strings, passwords and API keys are read from the environment or a secret manager, never from the repository or a config.py that is committed. The examples above contain none; the only "token" generated is secrets.token_hex() inside a test.

  • No client per request, none closed per request, none created before a fork (see the wiring section).

  • Timeouts everywhere. socket_timeout for Redis, serverSelectionTimeoutMS for MongoDB and request_timeout for Elasticsearch. A slow dependency must degrade one request, not the whole worker pool.

  • Redis is not a database by default. Persistence (RDB, AOF) is a deployment setting; do not keep the only copy of something important in a cache-configured instance, and give cache keys a TTL.

  • Never unpickle shared data. JSON in, JSON out.

  • Validate types before building a query, for MongoDB (operator injection) as much as for SQL.

  • Bounded results. limit, size and a maximum page size on every list endpoint.

  • Authentication on mutating endpoints, and a network position that keeps Redis, MongoDB and Elasticsearch off the public internet. An unauthenticated Redis or MongoDB exposed to the internet is a recurring cause of data loss and ransom incidents.

  • Fakes are approximations. fakeredis, mongomock and a hand-written search fake catch wiring and logic errors, but not differences in indexing, ranking, collation or concurrency. Cover those with a small integration suite against real containers (see the Docker page of this site, Docker) in continuous integration.

  • Async. None of the blocking clients above belongs in the event loop of an ASGI deployment; redis-py ships redis.asyncio, PyMongo an asynchronous client and the Elasticsearch package an AsyncElasticsearch, and the async, gevent and Quart page of this section covers when to use them.

Where the Books Are Outdated

The two editions of the Flask Framework Cookbook (2023 and 2019) both have a Redis recipe and a MongoDB recipe in chapter 3 and a search recipe in chapter 14, and they are the only books of this section that touch this page’s subject. The recipes work as demonstrations; these are the points on which they should not be copied.

In the books Today

Flask-MongoEngine for MongoDB (both editions, chapter 3)

The PyPI release is 1.0.0 from 2020 and does not import on Flask 3 (JSONEncoder was removed from flask.json in Flask 2.3). Use MongoEngine’s connect(), PyMongo or Flask-PyMongo

A Redis() object created once at module level in the application package, with no timeouts (chapter 3)

Create the client in the application factory with timeouts, decode_responses=True and a health check, and keep it in app.extensions

Recent-product tracking with set() followed by a separate expire(), and listing with keys('product-*') and .decode('utf-8') (chapter 3)

Set the value and its TTL in one command (SET ... EX); never use KEYS on a shared server, because it scans the whole keyspace and blocks it (keep a set or sorted set, or iterate with SCAN); decode_responses=True removes the manual decoding

es.index(index=..., body=...) and es.search(index=..., body=...) (2nd edition, chapter 14)

document= for index() and query= for search(). The 3rd edition already uses these, together with HTTPS and ca_certs, which is the form to follow

Flask-WhooshAlchemy installed from a git+git:// URL (2nd edition, chapter 14)

Dead extension, and GitHub no longer serves the unauthenticated git:// protocol; use Elasticsearch, PostgreSQL full-text search, SQLite FTS5 or Whoosh-Reloaded directly

Indexing in the view right after creating the object, with no repair path (chapter 14)

Commit first, then index; log failures; offer a reindex command; use a background task or an outbox when the index must not lag

For the relational side of those recipes, see the SQLAlchemy and migrations pages of this section, and for the engines themselves the three engine sections linked at the top.