Caching with Flask-Caching

Reviewed & published by Brayan K

Caching stores the result of an expensive operation so repeated requests return the saved copy instantly instead of recomputing it every time.

Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll see why caching matters, build a working time-to-live cache, learn @cache.cached and @cache.memoize, compare backends, and invalidate stale entries.

What You'll Learn in This Lesson

1 Why Cache? A TTL Cache From Scratch

Some work is slow — a heavy database query, a call to a remote API, a complex calculation. If many users request the same thing, recomputing it every time wastes time and money. Caching remembers the answer so the next identical request returns instantly.

Every cache entry needs a timeout, also called a TTL (time to live). After it expires the entry is dropped and the next request recomputes a fresh value. The runnable example below builds exactly that — a dictionary mapping each key to its value and an expiry timestamp.

Notice the calls["count"] counter: the expensive function runs only on a cache miss. The second request is served from the cache, so the counter stays at 1. That single number is the whole point of caching.

# app.py — a runnable TTL (time-to-live) cache using a plain dict
import time
from flask import Flask

app = Flask(__name__)

_cache = {}  # key -> (value, expires_at)

def cache_get(key):
    entry = _cache.get(key)
    if entry is None:
        return None
    value, expires_at = entry
    if time.time() > expires_at:   # expired -> treat as a miss
        del _cache[key]
        return None
    return value

def cache_set(key, value, timeout):
    _cache[key] = (value, time.time() + timeout)

calls = {"count": 0}  # proves the expensive work runs only on a miss

def expensive_lookup(n):
    calls["count"] += 1            # only increments on a real computation
    return n * n

@app.route("/square/<int:n>")
def square(n):
    key = f"square:{n}"
    cached = cache_get(key)
    if cached is not None:
        return f"{cached} (cached)"
    result = expensive_lookup(n)
    cache_set(key, result, timeout=60)
    return f"{result} (computed)"

# Simulate two identical requests without starting a server:
with app.test_client() as client:
    print(client.get("/square/9").data.decode())   # 81 (computed)
    print(client.get("/square/9").data.decode())   # 81 (cached)
    print("expensive calls:", calls["count"])      # expensive calls: 1

# app.run() would serve this forever, so it goes last and stays commented
# out — the test client above is how Flask itself exercises routes:
#     if __name__ == "__main__":
#         app.run(debug=True)

# ✅ Expected output:
# 81 (computed)
# 81 (cached)
# expensive calls: 1

The first request prints 81 (computed), the second 81 (cached), and the expensive function ran exactly once. That is the speedup caching buys you.

2 Caching by Arguments: memoize

@cache.cached keys the cache on the request path, which is perfect for a whole view. But a helper function called with different arguments needs a different cached result per argument set. That is what memoization does — it keys the cache on the function name plus its arguments.

The runnable decorator below mirrors Flask-Caching's @cache.memoize. The runs list records every real computation: calling add(2, 3) twice computes once, but add(10, 1) is a new argument set and recomputes.

# memoize: cache keyed by the function's arguments
import time

_store = {}  # (name, args) -> (value, expires_at)

def memoize(timeout):
    def decorator(fn):
        def wrapper(*args):
            key = (fn.__name__, args)
            hit = _store.get(key)
            if hit and time.time() <= hit[1]:
                return hit[0]                    # cache hit
            value = fn(*args)                    # cache miss -> compute
            _store[key] = (value, time.time() + timeout)
            return value
        return wrapper
    return decorator

runs = []

@memoize(timeout=30)
def add(a, b):
    runs.append((a, b))     # records each real computation
    return a + b

print(add(2, 3))            # 5
print(add(2, 3))            # 5  (served from cache)
print(add(10, 1))           # 11 (different args -> recomputed)
print("real runs:", runs)   # real runs: [(2, 3), (10, 1)]

# ✅ Expected output:
# 5
# 5
# 11
# real runs: [(2, 3), (10, 1)]

3 Backends & Cache Invalidation

The hardest part of caching is keeping it fresh. When the underlying data changes, any cached copy is now wrong. The fix is invalidation: delete the affected key on every write so the next read recomputes.

The runnable example caches a user's name. After a rename writes to the "database", it deletes the cache key, so the next read shows the new value with source: db — proof the stale entry was busted.

# Cache invalidation: clear an entry when underlying data changes
import time
from flask import Flask, jsonify

app = Flask(__name__)

_cache = {}
_db = {"user:1": "Ada"}     # pretend database

def cache_get(key):
    e = _cache.get(key)
    if e and time.time() <= e[1]:
        return e[0]
    return None

def cache_set(key, value, timeout=60):
    _cache[key] = (value, time.time() + timeout)

def cache_delete(key):
    _cache.pop(key, None)   # invalidate one key

@app.route("/user/<int:uid>")
def get_user(uid):
    key = f"user:{uid}"
    cached = cache_get(key)
    if cached is not None:
        return jsonify(name=cached, source="cache")
    name = _db[key]
    cache_set(key, name)
    return jsonify(name=name, source="db")

@app.route("/user/<int:uid>/rename/<new>", methods=["POST"])
def rename(uid, new):
    key = f"user:{uid}"
    _db[key] = new
    cache_delete(key)       # bust the stale cache entry
    return jsonify(renamed=new)

with app.test_client() as c:
    print(c.get("/user/1").get_json())                 # {'name': 'Ada', 'source': 'db'}
    print(c.get("/user/1").get_json())                 # {'name': 'Ada', 'source': 'cache'}
    print(c.post("/user/1/rename/Grace").get_json())   # {'renamed': 'Grace'}
    print(c.get("/user/1").get_json())                 # {'name': 'Grace', 'source': 'db'}

# ✅ Expected output:
# {'name': 'Ada', 'source': 'db'}
# {'name': 'Ada', 'source': 'cache'}
# {'renamed': 'Grace'}
# {'name': 'Grace', 'source': 'db'}

🔬 Cache Backend Cheat Sheet

CACHE_TYPEWhere it lives / when to use
SimpleCacheIn-process dict. Fine for dev / a single worker.
FileSystemCacheOn disk. Survives restarts; slower than memory.
RedisCacheShared across processes/servers. Best for production.
MemcachedCacheShared, very fast, no persistence. Classic choice.

🎯 Your Turn: Fill in the Blanks

Complete the cache below. Replace each ___ so the route caches a computed value with a 5-second TTL and reports whether each response was fresh or cached.

# 🎯 YOUR TURN: replace each ___ to finish the TTL cache
import time
from flask import Flask

app = Flask(__name__)
_cache = {}

@app.route("/now/<label>")
def now(label):
    entry = _cache.get(label)
    if entry is not None and time.time() <= entry[___]:   # index of expires_at?
        return f"{entry[0]} (cached)"
    value = f"computed-{label}"
    _cache[label] = (value, time.time() + ___)            # 5-second TTL
    return f"{value} (fresh)"

with app.test_client() as client:
    print(client.get("/now/a").data.decode())   # computed-a (fresh)
    print(client.get("/now/a").data.decode())   # computed-a (cached)

Common Errors & Quick Reference

❌ Cached value never updates after a write

You forgot to invalidate. After any write that affects a cached entry, call cache.delete(key) (or cache.delete_memoized(func, *args)) so the next read recomputes.

❌ Each worker shows different cached data

You used SimpleCache with multiple workers, so each process has its own dict. Switch CACHE_TYPE to RedisCache so all workers share one store.

TaskCode
Cache a view@cache.cached(timeout=60)
Cache by args@cache.memoize(timeout=60)
Delete one keycache.delete("view//path")
Clear everythingcache.clear()

🏆 Mini Challenge: Hit/Miss Counter

Build a cached Fibonacci route that also tracks how often the cache helped.

# 🏆 Mini Challenge: count cache hits and misses
import time
from flask import Flask, jsonify

app = Flask(__name__)
_cache = {}
stats = {"hits": 0, "misses": 0}

@app.route("/fib/<int:n>")
def fib(n):
    key = str(n)
    if key in _cache and time.time() <= _cache[key][1]:
        stats["hits"] += 1
        return jsonify(n=n, value=_cache[key][0], cached=True)
    stats["misses"] += 1
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    _cache[key] = (a, time.time() + 60)
    return jsonify(n=n, value=a, cached=False)

# Try it:
with app.test_client() as c:
    print(c.get("/fib/10").get_json())   # {'cached': False, 'n': 10, 'value': 55}
    print(c.get("/fib/10").get_json())   # {'cached': True, 'n': 10, 'value': 55}
    print(stats)                         # {'hits': 1, 'misses': 1}

❓ Frequently Asked Questions

Lesson complete — your slow routes just got fast!

You built a TTL cache by hand, learned @cache.cached and @cache.memoize, compared the simple, filesystem, and redis backends, and invalidated stale entries on write.

Practice quiz

What does caching do?

  • Encrypts responses
  • Stores an expensive result so repeats return instantly
  • Validates form input
  • Compresses images

Answer: Stores an expensive result so repeats return instantly. Caching stores an expensive result so repeated requests skip recomputation.

What does @cache.cached key the cache on?

  • The request path
  • The function's arguments
  • The current user
  • The HTTP method

Answer: The request path. @cache.cached keys on the request path, ideal for whole view functions.

What does @cache.memoize key the cache on?

  • The request path
  • A fixed timeout only
  • The function's arguments
  • The response status code

Answer: The function's arguments. @cache.memoize keys on the function's arguments, caching each arg set separately.

Which backend is shared across multiple workers and best for production?

  • SimpleCache
  • NullCache
  • FileSystemCache
  • RedisCache

Answer: RedisCache. RedisCache is shared across processes/servers, best for production.

How do you select the cache backend?

  • CACHE_TYPE in the config
  • @cache.backend
  • Cache(backend=...) only
  • An environment file required

Answer: CACHE_TYPE in the config. You set CACHE_TYPE in app.config to choose the backend.

What does TTL (timeout) control?

  • The maximum cache size
  • How long an entry lives before expiring
  • The number of workers
  • The Redis port

Answer: How long an entry lives before expiring. TTL is how long a cached entry lives before it expires and is recomputed.

How do you invalidate a specific memoized result?

  • cache.clear() only
  • cache.flush(func)
  • cache.delete_memoized(func, *args)
  • cache.expire(func)

Answer: cache.delete_memoized(func, *args). cache.delete_memoized(func, *args) invalidates one memoized argument set.

Why does SimpleCache misbehave with multiple workers?

  • It needs Redis to start
  • Each process has its own dict, so invalidation isn't shared
  • It only caches GET requests
  • It expires instantly

Answer: Each process has its own dict, so invalidation isn't shared. SimpleCache lives in one process; each worker has a separate copy.

When should you invalidate a cached entry?

  • Never, rely on TTL only
  • On every read
  • At server startup
  • Whenever you write data the entry depends on

Answer: Whenever you write data the entry depends on. Delete the entry on any write that affects it so the next read recomputes.

When does the expensive function run with a working cache?

  • Only on a cache miss
  • On every request
  • Only on a cache hit
  • Never after the first deploy

Answer: Only on a cache miss. The expensive work runs only on a cache miss; hits are served from the cache.

Continue this course

Frequently asked questions

What is caching in a Flask app?

Caching stores the result of an expensive operation — a database query, an external API call, a rendered page — so that repeated requests can return the saved copy instantly instead of recomputing it. Flask-Caching adds decorators like @cache.cached and @cache.memoize on top of a configurable backend.

What is the difference between cache.cached and cache.memoize?

@cache.cached keys the cache on the request path, so the same URL returns the same cached response regardless of function arguments. @cache.memoize keys the cache on the function's arguments, so calling a function with different arguments caches each result separately. Use cached for view functions and memoize for helper functions with parameters.

Which cache backend should I use?

SimpleCache (an in-process dict) is fine for a single development process. FileSystemCache persists to disk. For production with multiple workers or servers, use RedisCache or MemcachedCache so every process shares one cache. You select the backend with CACHE_TYPE in the config.

How do I invalidate a cached value when the data changes?

Call cache.delete(key) for a specific @cache.cached key, or cache.delete_memoized(func, *args) for a memoized function. You can also cache.clear() everything. The rule is: whenever you write data that a cached entry depends on, delete that entry so the next read recomputes it.

Does caching cause stale data?

It can, which is why every cached entry has a timeout (TTL). A short timeout keeps data fresher but caches less; a long timeout caches more but risks serving outdated values. Combine a sensible timeout with explicit invalidation on writes to balance freshness and speed.