Flask Caching Strategies — Core Concepts

What Flask-Caching provides

Flask-Caching is the standard caching extension for Flask. It wraps cachelib and provides decorators for caching view functions, arbitrary functions, and template fragments. It supports multiple backends: in-memory, Redis, Memcached, and filesystem.

The core idea: store the result of expensive computations and serve the stored version until it expires or you explicitly clear it.

Setting up

from flask_caching import Cache

cache = Cache()

def create_app():
    app = Flask(__name__)
    app.config['CACHE_TYPE'] = 'RedisCache'
    app.config['CACHE_REDIS_URL'] = 'redis://localhost:6379/0'
    app.config['CACHE_DEFAULT_TIMEOUT'] = 300  # 5 minutes
    cache.init_app(app)
    return app

Backend options:

  • SimpleCache — In-memory dictionary. Single process only. Good for development.
  • RedisCache — Shared across workers and servers. The production default.
  • MemcachedCache — Alternative to Redis. Simpler but fewer features.
  • FileSystemCache — Stores on disk. Useful when Redis isn’t available.

Caching view functions

The @cache.cached() decorator stores the entire response:

@app.route('/api/products')
@cache.cached(timeout=60, query_string=True)
def list_products():
    # This database query only runs once per minute
    products = Product.query.all()
    return jsonify([p.to_dict() for p in products])

query_string=True means different query parameters get different cache entries. /api/products?page=1 and /api/products?page=2 are cached separately.

Without query_string=True, all requests to /api/products share one cache entry regardless of parameters — a common bug.

Caching arbitrary functions

Use @cache.memoize() for functions with arguments:

@cache.memoize(timeout=120)
def get_user_stats(user_id):
    # Expensive aggregation query
    return {
        'post_count': Post.query.filter_by(user_id=user_id).count(),
        'comment_count': Comment.query.filter_by(user_id=user_id).count(),
        'avg_rating': db.session.query(func.avg(Rating.score))
                        .filter_by(user_id=user_id).scalar()
    }

memoize creates separate cache entries based on the function arguments. get_user_stats(1) and get_user_stats(2) are cached independently. The cache key includes the function name and arguments.

Cache invalidation

The hardest problem in caching. Three approaches:

Time-based expiration

Set a timeout and let entries expire naturally. Simple but imprecise — data can be stale until the timer runs out.

Explicit deletion

Clear specific entries when data changes:

@app.route('/api/products', methods=['POST'])
def create_product():
    product = Product(name=request.json['name'])
    db.session.add(product)
    db.session.commit()
    cache.delete('view//api/products')  # Clear the cached list
    return jsonify(product.to_dict()), 201

For memoized functions:

def update_user(user_id, data):
    user = User.query.get(user_id)
    user.name = data['name']
    db.session.commit()
    cache.delete_memoized(get_user_stats, user_id)  # Clear specific entry

Pattern-based deletion

Clear all entries matching a prefix:

# Requires Redis backend
cache.delete_many('product_*')

This is useful when you can’t enumerate all cache keys affected by a change.

When to cache

Not everything benefits from caching:

Good candidates:

  • Database queries that rarely change (product catalog, settings)
  • Expensive computations (aggregations, reports)
  • External API responses (weather, exchange rates)
  • Pages that are the same for every user (landing page, about page)

Poor candidates:

  • User-specific data that changes frequently (shopping cart)
  • Data that must be real-time (live chat, notifications)
  • Cheap queries (single row by primary key with an index)

Common misconception

“Caching always makes things faster.” Caching adds complexity. A cache miss (entry not found) is slower than no caching at all — the app checks the cache, finds nothing, then does the original work anyway. If your data changes constantly and cache hits are rare, you’re paying the overhead without the benefit. Profile first, cache the bottlenecks.

Template fragment caching

Cache expensive parts of templates without caching the whole page:

{% cache 300, 'sidebar', current_user.id %}
    <div class="sidebar">
        {{ render_notifications(current_user) }}
        {{ render_recommendations(current_user) }}
    </div>
{% endcache %}

This caches the sidebar for 5 minutes per user, while the rest of the page renders fresh each time.

One thing to remember: Caching trades freshness for speed. Every caching decision is choosing how stale you can tolerate your data being. Match the timeout to the business requirement: real-time data gets no cache, hourly reports get 60-minute cache, and static content gets cached for days.

pythonflaskcachingperformance

See Also