Caching

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, Werkzeug 3.1.9, Flask-Caching 2.5.1, cachelib 0.17.0 and Python 3.13, checked on 2026-10-07; every Python example was executed with those versions through Flask’s test client (Redis is replaced by an in-process fake, fakeredis). Flask 3.2 is not released yet, and nothing on this page depends on it. Caching is done in two places that complement each other. HTTP caching tells the browser, a CDN or a reverse proxy that they may reuse a response, so the request never reaches Python. Server-side caching keeps the result of expensive work (a query, a rendered fragment, a remote call) inside the application’s process or in a shared store such as Redis, so Python still answers but does less. Flask itself ships only the first kind, through Werkzeug’s response helpers; the second kind comes from the Flask-Caching extension, which builds on the cachelib library. The running example is the Bookshelf: a catalogue that is read often and written rarely, which is the shape of workload caching helps.

Where a Response Can Be Cached

A request can be answered from four places before the database is touched, and each has a different owner and a different way to invalidate it.

flowchart LR B[Browser cache] -->|miss or stale| C[CDN or reverse proxy] C -->|miss or stale| V[Flask view] V -->|Flask-Caching hit| K[(Cache store)] V -->|miss| D[(Database)] D -->|result| K
Figure 1. The layers a Bookshelf request can stop at. The further left, the cheaper the answer and the harder to invalidate.
Layer Controlled with Invalidated by

Browser (private cache)

Cache-Control, ETag, Last-Modified

Expiry, a revalidation request, or a new URL (fingerprinted file names)

CDN or reverse proxy (shared cache)

Cache-Control: public and s-maxage, Vary

Expiry, the proxy’s purge API, a new URL

Flask-Caching store (SimpleCache, RedisCache, …​)

@cache.cached, @cache.memoize, cache.get / cache.set

Expiry, cache.delete, cache.delete_memoized, cache.clear, a versioned key

The database itself

Query planner, indexes (see Flask-SQLAlchemy)

Not a cache you manage here

The rule that keeps caching safe is to decide, per response, who is allowed to reuse it. A public catalogue page can live in a shared cache; a page showing the signed-in reader’s shelf must never leave the browser’s private cache, and the sections below return to this at every step. Static files, whose cache lifetime Flask controls through SEND_FILE_MAX_AGE_DEFAULT, are covered in Static Files & Assets.

HTTP Caching with Cache-Control

Flask returns a flask.Response, which is Werkzeug’s Response subclass. Werkzeug models the Cache-Control header as response.cache_control, an object whose attributes read and write the directives: max_age, s_maxage, public, private, no_cache, no_store, must_revalidate, proxy_revalidate, immutable, stale_while_revalidate, stale_if_error, no_transform and must_understand. Setting a numeric attribute writes the directive with its value; setting a flag attribute to True writes the bare token. The standard that defines the directives is RFC 9111 (HTTP caching), which replaced RFC 7234.

Directive Meaning for the Bookshelf

max_age=N

Any cache may reuse the response without asking for N seconds.

s_maxage=N

Like max_age, but only for shared caches (CDN, proxy); it overrides max_age there.

public

A shared cache may store the response even where it would normally refuse (for example, when the request carried an Authorization header).

private

Only the browser’s own cache may store it. Use it for anything that depends on the signed-in user.

no_cache

A cache may store the response but must revalidate it with the origin before every reuse.

no_store

Nothing may store the response at all. Use it for pages with sensitive data (account pages, tokens).

must_revalidate

Once stale, the response must not be served without a successful revalidation.

immutable

The content at this URL never changes, so the browser need not revalidate on reload. Pair it with fingerprinted file names.

stale_while_revalidate=N, stale_if_error=N

A cache may serve a stale copy for N more seconds while it refreshes it, or when the origin fails.

The first example sets a public lifetime on the catalogue listing and no_store on an account page, and checks the resulting header.

from flask import Flask, jsonify

app = Flask("bookshelf")

BOOKS = [{"id": 1, "title": "Dune"}, {"id": 2, "title": "Hyperion"}]


@app.get("/books")
def list_books():
    response = jsonify(BOOKS)
    response.cache_control.public = True
    response.cache_control.max_age = 60
    response.cache_control.s_maxage = 300
    return response


@app.get("/account")
def account():
    response = jsonify({"user": "reader"})
    response.cache_control.no_store = True
    return response


client = app.test_client()
cache_control = client.get("/books").headers["Cache-Control"]
assert set(cache_control.split(", ")) == {"public", "max-age=60", "s-maxage=300"}
assert client.get("/account").headers["Cache-Control"] == "no-store"

The Expires header is the older, absolute-time way to say the same thing as max-age. Werkzeug exposes it as response.expires (a datetime); when both are present, Cache-Control: max-age wins, so set only max-age unless an old intermediary must be served. A response with no caching headers at all is still cacheable by heuristics: a cache may guess a lifetime from Last-Modified. State the intent explicitly (no_cache or no_store when in doubt).

Two Flask behaviours matter when you add these headers. First, a view that touches the session makes Flask add Vary: Cookie, so shared caches keep one copy per cookie value; this protects per-user pages but makes shared caching of such a page useless, which is the correct trade-off (see Sessions, Cookies & Flashing). Second, Response.vary is a header set you extend yourself when the response depends on another request header, for example the language:

from flask import Flask, jsonify, request

app = Flask("bookshelf-vary")


@app.get("/greeting")
def greeting():
    language = request.accept_languages.best_match(["en", "es"], default="en")
    response = jsonify({"language": language})
    response.vary.add("Accept-Language")
    response.cache_control.public = True
    response.cache_control.max_age = 600
    return response


client = app.test_client()
response = client.get("/greeting", headers={"Accept-Language": "es"})
assert response.json == {"language": "es"}
assert "Accept-Language" in response.headers["Vary"]

Forgetting Vary is the classic shared-cache bug: a proxy stores the Spanish response and serves it to an English reader. Vary: * and Vary: Cookie effectively disable shared caching, which is often what you want.

ETag, Last-Modified and Conditional Responses

Expiry-based caching has a blind spot: when the lifetime ends, the browser must download the whole body again even if nothing changed. Validators close that gap. The server labels each response with an ETag (an opaque version string) or a Last-Modified date; the client sends it back in If-None-Match or If-Modified-Since; and if the resource is unchanged the server answers 304 Not Modified with no body. Werkzeug implements this on the response:

Member Behaviour

response.set_etag(value, weak=False)

Sets the ETag header; weak=True writes the W/ prefix, meaning "semantically equal" rather than byte-identical.

response.add_etag(overwrite=False, weak=False)

Computes a hash of the response body and sets it as the ETag. It reads the whole body, so it saves bandwidth, not server work.

response.last_modified

A datetime written as the Last-Modified header.

response.make_conditional(request_or_environ, accept_ranges=False, complete_length=None)

Compares the request’s validators with the response’s and, when they match, turns the response into an empty 304. Call it last.

The cheap way to benefit is to compute the validator before doing the expensive work, so a match skips the work too. The example derives the ETag from an update counter that stands in for the Bookshelf’s latest-change marker:

from flask import Flask, jsonify, request

app = Flask("bookshelf-etag")

STATE = {"version": 7, "books": [{"id": 1, "title": "Dune"}]}
WORK_DONE = []


@app.get("/books")
def list_books():
    etag = f"books-{STATE['version']}"
    if etag in request.if_none_match:
        response = app.response_class(status=304)
        response.set_etag(etag)
        return response
    WORK_DONE.append(etag)  # the expensive query would run here
    response = jsonify(STATE["books"])
    response.set_etag(etag)
    response.cache_control.no_cache = True
    return response


client = app.test_client()
first = client.get("/books")
assert first.status_code == 200
etag = first.headers["ETag"]
assert etag == '"books-7"'

second = client.get("/books", headers={"If-None-Match": etag})
assert second.status_code == 304 and second.data == b""
assert len(WORK_DONE) == 1  # the second request did no work

STATE["version"] = 8
third = client.get("/books", headers={"If-None-Match": etag})
assert third.status_code == 200 and third.headers["ETag"] == '"books-8"'

request.if_none_match is a Werkzeug ETags object; etag in request.if_none_match handles the quoting and the weak comparison for you. The no_cache directive in the example means "store it, but ask me first", which is the right default for data that changes at unknown moments. For the simpler case where the body is the cost of knowing, call add_etag() and make_conditional() together:

from flask import Flask, jsonify, request

app = Flask("bookshelf-conditional")


@app.get("/books")
def list_books():
    response = jsonify([{"id": 1, "title": "Dune"}])
    response.add_etag()
    return response.make_conditional(request)


client = app.test_client()
first = client.get("/books")
assert first.status_code == 200
validator = first.headers["ETag"]
again = client.get("/books", headers={"If-None-Match": validator})
assert again.status_code == 304

make_conditional() only acts on GET and HEAD requests, and it also implements range requests when accept_ranges is set. flask.send_file() and send_from_directory() already do all of this for files: they set ETag and Last-Modified from the file, answer conditional requests, and take max_age and conditional arguments (the static page lists them). So a file download needs no hand-written validators.

Pitfalls: an ETag must change whenever the representation changes, including when the same data is rendered by a different template version, so include something like the deployment version if the markup can change between releases; a content-based ETag computed after rendering (as add_etag() does) is always correct but never cheaper than rendering; and a gzip layer in front of Flask changes the bytes, so servers usually rewrite the ETag to a weak one.

Server-Side Caching: Why and When

HTTP caching cannot help when each response is different (per-user pages), when the cost lives behind the view (a slow aggregate query used by many pages), or when the client is not a browser (a service calling your API). For those, cache the result of the work in the server. The trade is always the same: faster answers in exchange for the possibility of stale data, plus the work of invalidating it. A value is worth caching when it is expensive to compute, read much more often than written, and tolerant of being a little old. A value is not worth caching when it is cheap, unique per request, or must be exactly current (a balance, a stock count at checkout).

Flask has no built-in server-side cache. The maintained extension is Flask-Caching, developed under the pallets-eco organisation, a fork of the abandoned Flask-Cache. It wraps the cachelib library (the same library the Flask-Session extension uses for its server-side stores), so the backends, the timeout rules and the serialisation come from there. Install it with pip install Flask-Caching; it needs redis or pymemcache only for those backends. The older books recommend Flask-Cache or configure it with the lower-case type names; both are obsolete (see Where the Books Are Outdated).

Setting Up Flask-Caching

flask_caching.Cache follows the usual extension pattern: construct it with the app (Cache(app, config=...)) or create it unbound and call cache.init_app(app) inside the application factory (see Application Factory & Project Layout). The configuration comes from app.config, or from the config dictionary passed to the constructor, which wins.

from flask import Flask
from flask_caching import Cache

cache = Cache()  # unbound, created at import time


def create_app():
    app = Flask("bookshelf-cache")
    app.config.from_mapping(
        CACHE_TYPE="SimpleCache",
        CACHE_DEFAULT_TIMEOUT=120,
        CACHE_THRESHOLD=200,
        CACHE_KEY_PREFIX="bookshelf_",
    )
    cache.init_app(app)
    return app


app = create_app()
with app.app_context():
    assert cache.set("greeting", "hello", timeout=30)
    assert cache.get("greeting") == "hello"
    assert cache.get("missing") is None
    assert cache.add("greeting", "ignored") is False  # add only stores a new key
    cache.delete("greeting")
    assert cache.get("greeting") is None
    cache.set_many({"a": 1, "b": 2})
    assert cache.get_many("a", "b") == [1, 2]
    assert cache.get_dict("a", "b") == {"a": 1, "b": 2}
    cache.delete_many("a", "b")
    assert cache.has("a") is False
    cache.clear()

cache.cache returns the backend object that serves the current app, so one Cache instance can be used by several apps, each with its own store. The low-level calls above (get, set, add, delete, get_many, set_many, delete_many, get_dict, has, clear) mirror the BaseCache API of cachelib, and the stores accept any value the backend can serialise. A timeout of 0 means "never expire" for cachelib stores; None falls back to CACHE_DEFAULT_TIMEOUT. The Jinja extension that adds the cache tag is registered by default; pass with_jinja2_ext=False to switch it off.

Configuration Reference

All keys are optional except CACHE_TYPE in practice: its default is NullCache, which stores nothing and logs a warning at start-up (silence it with CACHE_NO_NULL_WARNING=True for tests).

Key Default Meaning

CACHE_TYPE

"NullCache"

Backend: the name of a class in flask_caching.backends ("SimpleCache", "FileSystemCache", "RedisCache", …​), or a dotted import path to a cachelib or custom backend class or factory function.

CACHE_DEFAULT_TIMEOUT

300

Seconds an entry lives when no timeout is given.

CACHE_THRESHOLD

500

Maximum number of items before SimpleCache and FileSystemCache start evicting.

CACHE_KEY_PREFIX

"flask_cache_"

Prefix added to every key. Use a distinct prefix per application or environment when they share one Redis.

CACHE_IGNORE_ERRORS

False

Ignore failures when deleting several keys.

CACHE_SOURCE_CHECK

False

Include the decorated function’s source in the key, so editing the function invalidates its entries.

CACHE_HASH_METHOD

hashlib.sha256

Hash constructor used to build keys; it must be a callable, not a string.

CACHE_ENABLE_SIGNALS

False

Send the hit and miss signals described in Signals.

CACHE_SERIALIZER

None

A cachelib.serializers.BaseSerializer instance replacing the backend’s serialiser (see Security).

CACHE_OPTIONS

None

Extra keyword arguments for the backend constructor, for example an options dictionary for redis.

CACHE_ARGS

[]

Extra positional arguments for the backend constructor.

CACHE_DIR

None

Directory for FileSystemCache (required for that backend); CACHE_FILE_HASH_METHOD selects its file-name hash.

CACHE_REDIS_URL, CACHE_REDIS_HOST, CACHE_REDIS_PORT, CACHE_REDIS_DB, CACHE_REDIS_PASSWORD

None, "localhost", 6379, 0, None

Connection settings for RedisCache. A URL takes precedence over host and port.

CACHE_MEMCACHED_SERVERS

None

List of server addresses for the Memcached backends.

The password and URL values are credentials: read them from the environment or a secrets manager, never from source code (see Configuration).

Backends

CACHE_TYPE Where entries live Use it for

NullCache

Nowhere; every get misses.

The default, and tests that must not be influenced by cached state.

SimpleCache

A dictionary inside the Python process; not shared between processes, lost on restart.

Development and single-process deployments.

FileSystemCache

One file per entry under CACHE_DIR.

A single host with several workers, when Redis is not available. Slower; clean the directory out of band.

RedisCache, RedisSentinelCache, RedisClusterCache

A Redis server (standalone, behind Sentinel, or a cluster).

Production with several processes or hosts. See Redis.

MemcachedCache, SASLMemcachedCache, SpreadSASLMemcachedCache

A Memcached server.

Existing Memcached infrastructure.

A dotted path such as cachelib.UWSGICache or cachelib.MongoDbCache

The corresponding cachelib backend.

Special environments (uWSGI shared memory, DynamoDB, MongoDB, Valkey).

The default timeout and key handling are identical across them; only durability and visibility differ. Because SimpleCache lives in one process, a Gunicorn deployment with four workers has four independent caches: each warms up separately, and deleting a key in one worker leaves it in the other three. Use a shared backend as soon as there is more than one process (see Several Processes).

To point at Redis, take the connection data from the environment. The example builds the configuration that way (the block that creates the real client is not run), and then runs the same code against fakeredis, an in-process stand-in:

# norun
import os

from flask import Flask
from flask_caching import Cache

app = Flask("bookshelf-redis")
app.config.from_mapping(
    CACHE_TYPE="RedisCache",
    CACHE_REDIS_URL=os.environ["BOOKSHELF_REDIS_URL"],  # e.g. rediss://host:6379/0, with credentials from a secrets store
    CACHE_DEFAULT_TIMEOUT=300,
    CACHE_KEY_PREFIX="bookshelf:prod:",
)
cache = Cache(app)
import fakeredis
from flask import Flask
from flask_caching import Cache
from flask_caching.backends import RedisCache

app = Flask("bookshelf-fakeredis")
app.config["CACHE_TYPE"] = "SimpleCache"
cache = Cache(app)

# In a test, swap the backend for one that talks to an in-process fake Redis.
fake = fakeredis.FakeRedis()
app.extensions["cache"][cache] = RedisCache(host=fake, key_prefix="bookshelf:test:", default_timeout=60)

with app.app_context():
    cache.set("book:1", {"title": "Dune"})
    assert cache.get("book:1") == {"title": "Dune"}
assert fake.exists("bookshelf:test:book:1") == 1
assert 0 < fake.ttl("bookshelf:test:book:1") <= 60

The Redis backend stores each value with a TTL, so expiry is Redis’s job rather than Python’s. Redis itself, its persistence and eviction policies (maxmemory-policy) are covered in the Redis section; for a cache you normally want an eviction policy such as allkeys-lru and no persistence.

Caching Views with @cache.cached

cache.cached decorates a view (or any function called in a request context) and caches its return value under a key built from the request path. Put it below @app.route, so the route registers the cached function.

from flask import Flask, jsonify, request
from flask_caching import Cache

app = Flask("bookshelf-cached")
app.config.from_mapping(CACHE_TYPE="SimpleCache", CACHE_DEFAULT_TIMEOUT=60)
cache = Cache(app)

CALLS = {"count": 0}


def expensive_catalogue():
    CALLS["count"] += 1
    return [{"id": 1, "title": "Dune"}, {"id": 2, "title": "Hyperion"}]


@app.get("/catalogue")
@cache.cached(timeout=120)
def catalogue():
    return jsonify(expensive_catalogue())


@app.get("/search")
@cache.cached(timeout=120, query_string=True)
def search():
    term = request.args.get("q", "")
    CALLS["count"] += 1
    return jsonify({"q": term})


client = app.test_client()
client.get("/catalogue")
client.get("/catalogue")
assert CALLS["count"] == 1  # the second call came from the cache

client.get("/search?q=dune&page=1")
client.get("/search?page=1&q=dune")  # same query, different order: same key
assert CALLS["count"] == 2
client.get("/search?q=hyperion")
assert CALLS["count"] == 3

By default the key is view/<request.path>, so ?q=dune and ?q=hyperion would share one entry and the second user would get the first user’s search. query_string=True hashes the sorted query parameters into the key, which fixes that and makes parameter order irrelevant. The decorator’s other parameters:

Parameter Meaning

timeout

Seconds (an integer, or a timedelta rounded up to whole seconds); None uses CACHE_DEFAULT_TIMEOUT.

key_prefix

Default "view/%s", where %s is the request path. A string without %s is used as-is, which suits non-view functions. It may be a zero-argument callable returning the key, evaluated per call.

unless

A callable; when it returns true the cache is bypassed completely (no read, no write).

forced_update

A callable; when true the value is recomputed and stored even if a fresh one exists. Useful for background refresh.

is_stale

Called on a hit with the cached value; when true the value is recomputed.

response_filter

Called with the computed response; when it returns False the response is not stored. Use it to keep errors out of the cache.

query_string

Include the sorted query parameters in the key.

make_cache_key

A callable that builds the whole key; the decorated function also exposes make_cache_key, cache_timeout and uncached attributes.

source_check

Include the function’s source in the key (default from CACHE_SOURCE_CHECK).

hash_method

Hash constructor for the key (default from CACHE_HASH_METHOD).

cache_none

Cache None results too; documented as not recommended because a concurrent miss can return None wrongly.

response_hit_indication

Add a hit_cache header to responses served from the cache; handy while debugging.

The example below uses unless, response_filter, response_hit_indication and the uncached attribute, and shows that an exception raised through abort() is stored like a response (from Flask-Caching 2.5.0), so filter it out:

from flask import Flask, abort, jsonify, request
from flask_caching import Cache

app = Flask("bookshelf-cached-options")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

CALLS = []


def is_admin_request():
    return request.headers.get("X-Role") == "admin"


@app.get("/stats")
@cache.cached(timeout=300, unless=is_admin_request, response_hit_indication=True)
def stats():
    CALLS.append(1)
    return jsonify({"books": 2})


@app.get("/books/<int:book_id>")
@cache.cached(timeout=300, response_filter=lambda rv: getattr(rv, "status_code", 200) == 200)
def book(book_id):
    if book_id != 1:
        abort(404)
    return jsonify({"id": 1})


client = app.test_client()
client.get("/stats")
hit = client.get("/stats")
assert len(CALLS) == 1 and "hit_cache" in hit.headers

client.get("/stats", headers={"X-Role": "admin"})  # bypasses the cache entirely
assert len(CALLS) == 2

assert client.get("/books/1").status_code == 200
assert client.get("/books/9").status_code == 404

assert stats.uncached.__name__ == "stats"  # the undecorated function

Rules that avoid the worst mistakes:

  • Decorator order decides who sees what. In @app.route, then @login_required, then @cache.cached, the cache sits behind the authentication check, so an anonymous reader is rejected before the cache is consulted. Reversed, an authenticated reader’s response could be served to anyone who asks for the same path.

  • Never cache a response that depends on the user under a user-independent key. Either skip it with unless, or put the user id into the key (the example after this list).

  • Only GET and HEAD views should be cached. The extension does not look at the method; caching a POST view replays the first result and skips the side effect.

  • Cached views need a request context to compute the key. Outside a request (a Celery task, a CLI command) use cache.get / cache.set or memoize.

  • Responses with cookies are cached with their cookies. A cached response that sets a session cookie hands that cookie to the next reader. Do not cache views that write the session.

The per-user case, using a callable key_prefix and a signed-in user stand-in:

from flask import Flask, g, jsonify
from flask_caching import Cache

app = Flask("bookshelf-per-user")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

CALLS = []


@app.before_request
def load_user():
    from flask import request

    g.user_id = request.headers.get("X-User", "anonymous")


def per_user_key():
    from flask import request

    return f"shelf/{g.user_id}{request.path}"


@app.get("/shelf")
@cache.cached(timeout=60, key_prefix=per_user_key)
def shelf():
    CALLS.append(g.user_id)
    return jsonify({"owner": g.user_id})


client = app.test_client()
assert client.get("/shelf", headers={"X-User": "ana"}).json == {"owner": "ana"}
assert client.get("/shelf", headers={"X-User": "ben"}).json == {"owner": "ben"}
assert client.get("/shelf", headers={"X-User": "ana"}).json == {"owner": "ana"}
assert CALLS == ["ana", "ben"]  # the third call hit the cache

Class-based views (see Class-Based Views) are cached by listing the decorator in the class’s decorators attribute. If you do, remember that it runs for every HTTP method of the view, so restrict it to a method or separate view when the class also handles writes.

Caching Function Results with @cache.memoize

cached keys on the request path; memoize keys on the function and its arguments, so it works anywhere, with or without a request, and different arguments get different entries. It is the right tool for the expensive helper that several views call.

from flask import Flask
from flask_caching import Cache

app = Flask("bookshelf-memoize")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

CALLS = []


@cache.memoize(timeout=300)
def books_by_author(author, limit=10):
    CALLS.append((author, limit))
    return [f"{author} book {n}" for n in range(limit)]


with app.app_context():
    first = books_by_author("Herbert", limit=2)
    again = books_by_author("Herbert", limit=2)
    assert first == again == ["Herbert book 0", "Herbert book 1"]
    assert len(CALLS) == 1

    books_by_author("Herbert", limit=3)  # different arguments, different entry
    books_by_author("Simmons", limit=2)
    assert len(CALLS) == 3

    # Invalidate exactly one argument combination ...
    cache.delete_memoized(books_by_author, "Herbert", limit=2)
    books_by_author("Herbert", limit=2)
    assert len(CALLS) == 4

    # ... or every cached result of the function.
    cache.delete_memoized(books_by_author)
    books_by_author("Simmons", limit=2)
    assert len(CALLS) == 5

delete_memoized(f, *args, **kwargs) deletes the entry for those arguments, or, with no arguments, every entry of the function by changing a stored version token. It must be called with the arguments in the same form they were called with (positional versus keyword); the extension normalises them, but a mismatched value is simply a different key. The memoize parameters are those of cached without the view-specific ones: timeout, make_name (rename the function part of the key), unless, forced_update, is_stale, response_filter, hash_method, cache_none, source_check and args_to_ignore. args_to_ignore lists argument names that must not affect the key (a request id, a logger).

from flask import Flask
from flask_caching import Cache

app = Flask("bookshelf-memoize-ignore")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

CALLS = []


@cache.memoize(60, args_to_ignore=["trace_id"])
def book_count(genre, trace_id=None):
    CALLS.append(genre)
    return 42


with app.app_context():
    book_count("sci-fi", trace_id="a1")
    book_count("sci-fi", trace_id="b2")  # same key: trace_id is ignored
    assert CALLS == ["sci-fi"]

Pitfalls specific to memoize:

  • Arguments become part of the key through their representation. Use simple, stable arguments (strings, numbers, ids). An ORM object or a dataclass without a stable repr produces unstable keys or none at all; pass its id.

  • On methods, self is part of the key through its repr, so the default object repr (which contains a memory address) never hits across instances. Memoize a module-level function, or a staticmethod or classmethod, or give the class a stable __repr__.

  • Never memoize a function whose result depends on g, session or request unless the relevant value is one of its arguments.

  • Return values must be serialisable by the backend (pickle by default for SimpleCache and RedisCache). Treat cached values as read-only: an in-process store can hand back a shared object, and mutating it changes what the next reader sees.

Template Fragment Caching

Often the expensive part of a page is one block, such as a "popular books" sidebar, while the rest of the page is personal. Flask-Caching registers a Jinja extension that adds a cache block tag: {% cache 300, "sidebar", genre %} ... {% endcache %}. The first argument is the timeout in seconds, the second the fragment name, and any further expressions are vary-on values appended to the key. The block is rendered once per distinct key and the rendered string is reused. Jinja itself and its extension mechanism are described in Jinja Templates.

from flask import Flask, render_template_string
from flask_caching import Cache
from flask_caching import make_template_fragment_key

app = Flask("bookshelf-fragment")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

RENDERED = []


def popular(genre):
    RENDERED.append(genre)
    return f"popular {genre} books"


PAGE = (
    "{% cache 300, 'popular', genre %}"
    "{{ popular(genre) }}"
    "{% endcache %}"
)


@app.get("/home/<genre>")
def home(genre):
    return render_template_string(PAGE, genre=genre, popular=popular)


client = app.test_client()
assert client.get("/home/scifi").get_data(as_text=True) == "popular scifi books"
client.get("/home/scifi")
assert RENDERED == ["scifi"]  # the second render came from the cache
client.get("/home/fantasy")
assert RENDERED == ["scifi", "fantasy"]  # a different vary-on value, a different entry

# Invalidate one fragment from Python by rebuilding its key.
key = make_template_fragment_key("popular", vary_on=["scifi"])
with app.app_context():
    assert cache.get(key) == "popular scifi books"
    cache.delete(key)
client.get("/home/scifi")
assert RENDERED == ["scifi", "fantasy", "scifi"]

make_template_fragment_key(fragment_name, vary_on=None) rebuilds the key the tag uses, so a write path can delete a fragment. Inside a template, {% cache 'del', 'popular', genre %} deletes it. Without an explicit name the key is made from the template path and the line of the tag, which breaks silently when the template is edited; always name fragments. A fragment is cached as the rendered text, after autoescaping, so anything user-specific inside it leaks to the next reader: keep the cached block free of the current_user and CSRF tokens (Flask-WTF’s csrf_token() in a cached form would hand one visitor’s token to another).

cachelib Underneath

cachelib is a small, framework-independent library of cache backends with one interface (BaseCache: get, set, add, delete, get_many, set_many, delete_many, clear, has, inc, dec). Flask-Caching adds the Flask integration (configuration, decorators, the Jinja tag, signals) and re-exports several backends from flask_caching.backends. You can use cachelib directly where Flask-Caching’s decorators are not needed, for example in a script or in a non-Flask library:

from cachelib import SimpleCache

store = SimpleCache(threshold=100, default_timeout=30)
store.set("isbn:1", "Dune")
assert store.get("isbn:1") == "Dune"
assert store.inc("hits") == 1 and store.inc("hits") == 2
store.delete("isbn:1")
assert store.get("isbn:1") is None

A custom backend is a subclass of BaseCache (from cachelib or flask_caching.backends) referenced from CACHE_TYPE by dotted path; a class that has a factory(app, config, args, kwargs) classmethod is built through it, as RedisCache is.

Invalidation in the Bookshelf

"There are only two hard things in computer science: cache invalidation and naming things." The strategies below, from simplest to most precise, all apply to the same scenario: a cached book list and a cached per-book page that must reflect an edit.

Strategy How Trade-off

Time to live

Choose a timeout the business tolerates (a catalogue may be five minutes old).

Simple; data is stale until expiry.

Delete on write

After the commit, call cache.delete, cache.delete_cached(view, path) or cache.delete_memoized.

Precise; every writer must remember, and the delete must happen after a successful commit.

Versioned keys

Store a counter in the cache; include it in keys; bump it to invalidate a whole group at once.

No need to enumerate keys; old entries expire by TTL.

Clear all

cache.clear().

Blunt; on a shared Redis it also clears other applications' keys unless the backend is isolated.

from flask import Flask, jsonify, request
from flask_caching import Cache

app = Flask("bookshelf-invalidate")
app.config.from_mapping(CACHE_TYPE="SimpleCache", CACHE_DEFAULT_TIMEOUT=300)
cache = Cache(app)

DB = {1: {"id": 1, "title": "Dune"}}
QUERIES = []


@cache.memoize()
def load_book(book_id):
    QUERIES.append(book_id)
    return DB[book_id]


@app.get("/books/<int:book_id>")
def get_book(book_id):
    return jsonify(load_book(book_id))


@app.get("/books")
@cache.cached(query_string=True)
def list_books():
    return jsonify(sorted(DB.values(), key=lambda b: b["id"]))


@app.put("/books/<int:book_id>")
def update_book(book_id):
    DB[book_id] = {"id": book_id, "title": request.get_json()["title"]}
    # The "commit" succeeded: now invalidate what depends on it.
    cache.delete_memoized(load_book, book_id)
    cache.delete_cached(list_books, "/books")
    return jsonify(DB[book_id])


client = app.test_client()
assert client.get("/books/1").json["title"] == "Dune"
assert client.get("/books/1").json["title"] == "Dune"
assert QUERIES == [1]
assert client.get("/books").json[0]["title"] == "Dune"

client.put("/books/1", json={"title": "Dune Messiah"})
assert client.get("/books/1").json["title"] == "Dune Messiah"
assert client.get("/books").json[0]["title"] == "Dune Messiah"
assert QUERIES == [1, 1]

delete_cached(view, path, query_args=...) rebuilds the key from the path (and, for query_string=True views, the exact query arguments), so you must delete each cached query variant you want gone; this is why a version token is easier for listings with many filters. The versioned-key pattern:

from flask import Flask
from flask_caching import Cache

app = Flask("bookshelf-version")
app.config.from_mapping(CACHE_TYPE="SimpleCache")
cache = Cache(app)

COMPUTED = []


def catalogue_version():
    return cache.get("catalogue:version") or 1


def bump_catalogue():
    cache.set("catalogue:version", catalogue_version() + 1, timeout=0)


def books_for(genre):
    key = f"catalogue:v{catalogue_version()}:{genre}"
    value = cache.get(key)
    if value is None:
        COMPUTED.append(key)
        value = [f"{genre} book"]
        cache.set(key, value, timeout=300)
    return value


with app.app_context():
    books_for("scifi")
    books_for("scifi")
    assert COMPUTED == ["catalogue:v1:scifi"]
    bump_catalogue()  # one write invalidates every genre at once
    books_for("scifi")
    assert COMPUTED == ["catalogue:v1:scifi", "catalogue:v2:scifi"]

With the Flask-SQLAlchemy extension (see Flask-SQLAlchemy) the delete belongs after db.session.commit(), or in an after_commit session event, never before: deleting first lets a concurrent request repopulate the cache with the old row. Even after the commit there is a race window where a reader that started before the commit stores a stale value after your delete; keep TTLs short as the final safety net.

Hit and Miss Signals

With CACHE_ENABLE_SIGNALS=True Flask-Caching sends four Blinker signals, defined in flask_caching.signals: cache_view_hit, cache_view_miss, cache_memoize_hit and cache_memoize_miss. Each carries the cache instance, the cache_key, and the call’s args and kwargs (the memoize signals add the function f). They are the cleanest way to count hit ratios for metrics. The general signal mechanism is described in the Flask signals page of this section.

from flask import Flask, jsonify
from flask_caching import Cache
from flask_caching.signals import cache_view_hit, cache_view_miss

app = Flask("bookshelf-signals")
app.config.from_mapping(CACHE_TYPE="SimpleCache", CACHE_ENABLE_SIGNALS=True)
cache = Cache(app)

counts = {"hit": 0, "miss": 0}


def on_hit(sender, **extra):
    counts["hit"] += 1


def on_miss(sender, **extra):
    counts["miss"] += 1


cache_view_hit.connect(on_hit, weak=False)
cache_view_miss.connect(on_miss, weak=False)


@app.get("/top")
@cache.cached(timeout=60)
def top():
    return jsonify(["Dune"])


client = app.test_client()
client.get("/top")
client.get("/top")
client.get("/top")
assert counts == {"hit": 2, "miss": 1}

Several Processes, Stampedes and Staleness

Three production problems appear only under load or with several workers.

  • Per-process caches diverge. SimpleCache is a dictionary in one worker, so workers disagree after a write. Deploy with RedisCache (or Memcached) as soon as you run more than one process; see the deployment page of this section. A shared store also survives restarts, which keeps a rolling deployment from starting cold.

  • Stampede (dogpile). When a hot entry expires, every request that arrives before the first one has recomputed it recomputes it too. Mitigations: refresh in the background with forced_update (a callable that is true when a refresh job requests it), serve slightly stale data with is_stale plus a refresh task, add a random jitter to timeouts so related keys do not expire together, and for truly heavy values take a short Redis lock around the recomputation. Flask-Caching does not lock for you.

  • Staleness vs correctness. Never cache what must be exact. Prices at checkout, permission checks and "has this token been revoked" belong in the database or a strongly consistent store.

Testing Cached Code

Tests should not depend on what a previous test cached. Either run the test configuration with CACHE_TYPE="NullCache" (every get misses, so the code always takes the slow path), or use SimpleCache and clear it between tests. With NullCache, silence the start-up warning with CACHE_NO_NULL_WARNING.

import pytest
from flask import Flask, jsonify
from flask_caching import Cache

cache = Cache()


def create_app(test_config=None):
    app = Flask("bookshelf-testing")
    app.config.from_mapping(CACHE_TYPE="SimpleCache")
    if test_config:
        app.config.update(test_config)
    cache.init_app(app)

    @app.get("/n")
    @cache.cached(timeout=60)
    def counter():
        app.config["CALLS"] = app.config.get("CALLS", 0) + 1
        return jsonify(app.config["CALLS"])

    return app


def test_cache_hit():
    app = create_app({"TESTING": True})
    client = app.test_client()
    assert client.get("/n").json == client.get("/n").json == 1


def test_no_cache_in_tests():
    app = create_app({"TESTING": True, "CACHE_TYPE": "NullCache", "CACHE_NO_NULL_WARNING": True})
    client = app.test_client()
    assert client.get("/n").json == 1
    assert client.get("/n").json == 2  # nothing was cached


test_cache_hit()
test_no_cache_in_tests()

Security Considerations

  • Do not cache per-user data under shared keys or shared HTTP headers. Use private or no_store for anything tied to a session, and keep user identifiers in cache keys. A public Cache-Control on an authenticated response lets a CDN serve it to strangers.

  • Keys are built from the path, not from every input. cached ignores headers, cookies and the host unless you add them to the key; any input that changes the output must be in the key or in Vary. Unbounded key material (a free query string) lets an attacker fill the cache: bound it with CACHE_THRESHOLD, a Redis maxmemory policy and validation before the cached function runs.

  • Cache poisoning. If a cached response embeds an unvalidated header (Host, X-Forwarded-*), one request can poison it for everyone. Build URLs from configuration, not from request headers (see Security Considerations).

  • Serialisation. SimpleCache, FileSystemCache and RedisCache store values as Python pickles by default. Unpickling data an attacker can write is code execution, so the store must only be writable by your own application: Redis with authentication and TLS on a private network, a cache directory that no other account can write to. Use CACHE_SERIALIZER with cachelib.serializers.JSONSerializer where values are JSON-compatible, and the Flask-Caching documentation’s serializer section for unpicklable values.

  • Credentials. The Redis URL, password and Memcached credentials come from the environment or a secrets manager.

  • Sensitive values in the cache. A cache is a second copy of your data, with a different retention and access model; do not cache secrets or personal data without a short TTL and an encrypted, access-controlled store.

Where the Books Are Outdated

The Flask Cookbook chapter that covers Flask-Caching (the "tips and tricks" chapter in both editions) uses the cached and memoize decorators of the extension; the concepts still apply, and the APIs differ in these ways.

  • The package is Flask-Caching, imported as flask_caching, not the retired Flask-Cache (flask.ext.cache; the flask.ext import hook was removed in Flask 1.0).

  • CACHE_TYPE = "simple" (and the other lower-case names, "redis", "filesystem", "memcached") no longer works: use the class name, "SimpleCache", "RedisCache", "FileSystemCache", "MemcachedCache". In Flask-Caching 2.5.1 the old name fails at start-up with an import error rather than falling back.

  • The books configure CACHE_REDIS_HOST / CACHE_REDIS_PORT in code; use CACHE_REDIS_URL and read it from the environment, with credentials from a secrets store.

  • Key hashing is configured with CACHE_HASH_METHOD and defaults to SHA-256, and __version__ on the package is deprecated in favour of importlib.metadata.version("flask-caching").

  • Flask-Caching 2.5.1 requires Python 3.11 or newer, so a book’s Python 3.8 environment cannot install the current release.

  • Where a book caches inside a view by hand with a global dictionary, replace the dictionary with a real backend: a module-level dictionary is per-process, unbounded and not thread-safe to invalidate.