Caching

Some things TaskFlow computes are expensive relative to how often they actually change — a dashboard count that scans thousands of tasks, a sidebar widget that queries several related tables just to render the same numbers it rendered a minute ago. Caching means storing the result of that work the first time it's computed, then reusing the stored result for a while instead of recomputing it on every single request. Django has a full caching framework built in for exactly this, and this lesson covers it end to end.

Choosing a Cache Backend

Django's CACHES setting decides where cached values actually live. Which backend makes sense depends entirely on how the project is deployed:

BackendWhen it makes sense
LocMemCacheStores cached values in your Django process's own memory. Simple, no extra service to run, fine for local development or a small single-server app — but each server process has its own separate cache, so it doesn't scale across multiple processes or machines.
RedisThe typical production choice. Runs as its own separate service that every Django process (and every worker, across every server) can share, so all of them see the same cached values consistently.
MemcachedSimilar role to Redis — a shared, external cache store built specifically for this purpose, and still common in older or simpler production setups.
# settings.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
    }
}

The Low-Level Cache API

The most direct way to use the cache is the low-level API — read and write individual values by key, anywhere in your code, with an explicit expiration time:

from django.core.cache import cache

def get_dashboard_stats():
    stats = cache.get('dashboard_stats')
    if stats is None:
        # Expensive: touches every Project, annotated with a task count
        # exactly like django-aggregation-annotation.php's Count() example
        stats = list(
            Project.objects.annotate(task_count=Count('tasks')).values('name', 'task_count')
        )
        cache.set('dashboard_stats', stats, timeout=300)
    return stats

cache.get('dashboard_stats') returns None the first time — nothing's been stored under that key yet — so the expensive annotated query runs once and the result is saved with cache.set(), alongside a timeout of 300 seconds. Every call within the next five minutes gets the stored list back instantly, with no query at all; after that, the key expires and the next call recomputes it fresh.

Per-View Caching with cache_page

Rather than caching one value manually, @cache_page caches an entire view's rendered output for a set number of seconds. It's a natural fit for a view that's read heavily but changes rarely — a public project list, say, that most visitors just browse without editing:

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def public_project_list(request):
    projects = Project.objects.all()
    return render(request, 'taskflow/public_project_list.html', {'projects': projects})

60 * 15 is just seconds written as an obvious expression — 15 minutes. The first request after the cache expires runs the view normally, including its database queries; every request in the 15 minutes after that gets back the exact same rendered HTML with no view code running at all.

Template Fragment Caching

Sometimes caching the whole view is too blunt — the page has one slow piece (say, a sidebar widget listing each project's busiest tags) sitting alongside content that genuinely does need to be fresh every request. The {% cache %} template tag caches just that one fragment:

{% load cache %}

{% cache 600 project_tag_widget %}
  
    {% for tag in popular_tags %}
  • {{ tag.name }} ({{ tag.task_count }})
  • {% endfor %}
{% endcache %}

600 is the fragment's timeout in seconds, and project_tag_widget is just a name identifying this particular cached fragment — everything between {% cache %} and {% endcache %} is rendered once, stored, and reused directly for every request within that window, while the rest of the template around it still renders normally every single time.

The Real Caveat: Stale Data

There's an old joke that there are only two hard things in computer science: cache invalidation, naming things, and off-by-one errors. The practical version of that problem, stripped of the joke: a cached value can go stale — wrong, out of date — the moment the underlying data changes, right up until the cache's timeout finally expires. A dashboard stat cached for five minutes might show a task count that's already three tasks behind reality.

One common, practical mitigation is deleting the specific cache key the instant the data behind it changes, rather than waiting out the timeout. Signals covered post_save as a hook that fires right after a model instance is saved — a natural place to clear a now-outdated cache entry:

from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.cache import cache

@receiver(post_save, sender=Task)
def clear_dashboard_cache(sender, instance, **kwargs):
    cache.delete('dashboard_stats')

This isn't a complete solution to cache invalidation in general — just one common, honest pattern: when in doubt about whether a cached value is still accurate, clear it proactively the moment the data it depends on changes, rather than hoping the timeout is short enough not to matter.

Note: All the caching code above is correct Django usage — but this website's own project doesn't run a live cache backend or server, so nothing here actually executes on this page.
Next up: Background Tasks with Celery looks at offloading work too slow to run during a request at all — even a cached one.