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.
| Layer | Controlled with | Invalidated by |
|---|---|---|
Browser (private cache) |
|
Expiry, a revalidation request, or a new URL (fingerprinted file names) |
CDN or reverse proxy (shared cache) |
|
Expiry, the proxy’s purge API, a new URL |
Flask-Caching store ( |
|
Expiry, |
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 |
|---|---|
|
Any cache may reuse the response without asking for |
|
Like |
|
A shared cache may store the response even where it would normally refuse (for example, when the request carried an |
|
Only the browser’s own cache may store it. Use it for anything that depends on the signed-in user. |
|
A cache may store the response but must revalidate it with the origin before every reuse. |
|
Nothing may store the response at all. Use it for pages with sensitive data (account pages, tokens). |
|
Once stale, the response must not be served without a successful revalidation. |
|
The content at this URL never changes, so the browser need not revalidate on reload. Pair it with fingerprinted file names. |
|
A cache may serve a stale copy for |
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"
Source: Werkzeug: ResponseCacheControl
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"]
Source: Werkzeug: Response.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 |
|---|---|
|
Sets the |
|
Computes a hash of the response body and sets it as the |
|
A |
|
Compares the request’s validators with the response’s and, when they match, turns the response into an empty |
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"'
Source: Werkzeug: Response.set_etag
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()
Source: Flask-Caching documentation
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 |
|---|---|---|
|
|
Backend: the name of a class in |
|
|
Seconds an entry lives when no timeout is given. |
|
|
Maximum number of items before |
|
|
Prefix added to every key. Use a distinct prefix per application or environment when they share one Redis. |
|
|
Ignore failures when deleting several keys. |
|
|
Include the decorated function’s source in the key, so editing the function invalidates its entries. |
|
|
Hash constructor used to build keys; it must be a callable, not a string. |
|
|
Send the hit and miss signals described in Signals. |
|
|
A |
|
|
Extra keyword arguments for the backend constructor, for example an options dictionary for |
|
|
Extra positional arguments for the backend constructor. |
|
|
Directory for |
|
|
Connection settings for |
|
|
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 |
|---|---|---|
|
Nowhere; every |
The default, and tests that must not be influenced by cached state. |
|
A dictionary inside the Python process; not shared between processes, lost on restart. |
Development and single-process deployments. |
|
One file per entry under |
A single host with several workers, when Redis is not available. Slower; clean the directory out of band. |
|
A Redis server (standalone, behind Sentinel, or a cluster). |
Production with several processes or hosts. See Redis. |
|
A Memcached server. |
Existing Memcached infrastructure. |
A dotted path such as |
The corresponding |
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 |
|---|---|
|
Seconds (an integer, or a |
|
Default |
|
A callable; when it returns true the cache is bypassed completely (no read, no write). |
|
A callable; when true the value is recomputed and stored even if a fresh one exists. Useful for background refresh. |
|
Called on a hit with the cached value; when true the value is recomputed. |
|
Called with the computed response; when it returns |
|
Include the sorted query parameters in the key. |
|
A callable that builds the whole key; the decorated function also exposes |
|
Include the function’s source in the key (default from |
|
Hash constructor for the key (default from |
|
Cache |
|
Add a |
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
Source: Flask-Caching API: Cache.cached
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
GETandHEADviews should be cached. The extension does not look at the method; caching aPOSTview 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.setormemoize. -
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
Source: Flask-Caching: Memoization
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"]
Source: Flask-Caching API: Cache.memoize
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
reprproduces unstable keys or none at all; pass its id. -
On methods,
selfis part of the key through itsrepr, so the default objectrepr(which contains a memory address) never hits across instances. Memoize a module-level function, or astaticmethodorclassmethod, or give the class a stable__repr__. -
Never memoize a function whose result depends on
g,sessionorrequestunless the relevant value is one of its arguments. -
Return values must be serialisable by the backend (pickle by default for
SimpleCacheandRedisCache). 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
Source: cachelib documentation
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 |
Simple; data is stale until expiry. |
Delete on write |
After the commit, call |
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 |
|
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"]
Source: cachelib: BaseCache
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}
Source: Flask-Caching API: signals
Several Processes, Stampedes and Staleness
Three production problems appear only under load or with several workers.
-
Per-process caches diverge.
SimpleCacheis a dictionary in one worker, so workers disagree after a write. Deploy withRedisCache(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 withis_staleplus 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
privateorno_storefor anything tied to a session, and keep user identifiers in cache keys. A publicCache-Controlon an authenticated response lets a CDN serve it to strangers. -
Keys are built from the path, not from every input.
cachedignores headers, cookies and the host unless you add them to the key; any input that changes the output must be in the key or inVary. Unbounded key material (a free query string) lets an attacker fill the cache: bound it withCACHE_THRESHOLD, a Redismaxmemorypolicy 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,FileSystemCacheandRedisCachestore 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. UseCACHE_SERIALIZERwithcachelib.serializers.JSONSerializerwhere 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; theflask.extimport 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_PORTin code; useCACHE_REDIS_URLand read it from the environment, with credentials from a secrets store. -
Key hashing is configured with
CACHE_HASH_METHODand defaults to SHA-256, and__version__on the package is deprecated in favour ofimportlib.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.