Cache an Expensive Dashboard Fragment with Django's Template Cache Tag and Signal-Based Invalidation
{% load cache %}
<section class="dashboard">
<h1>{{ team.name }} — Overview</h1>
{% cache 3600 team_order_stats team.id stats_version %}
<div class="stat-grid">
<div class="stat">
<span class="label">Orders</span>
<span class="value">{{ stats.total_orders }}</span>
</div>
<div class="stat">
<span class="label">Revenue</span>
<span class="value">${{ stats.total_revenue|floatformat:2 }}</span>
</div>
<div class="stat">
<span class="label">Avg. Order</span>
<span class="value">${{ stats.avg_order|floatformat:2 }}</span>
</div>
</div>
{% endcache %}
</section>
from decimal import Decimal
from django.core.cache import cache
from django.db.models import Avg, Count, Sum
from django.shortcuts import get_object_or_404
from django.views.generic import TemplateView
from .models import Order, Team
def stats_version_key(team_id):
return "team:%s:stats_version" % team_id
class TeamDashboardView(TemplateView):
template_name = "dashboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team = get_object_or_404(Team, pk=self.kwargs["team_id"])
version = cache.get(stats_version_key(team.id), 0)
rows = (
Order.objects.filter(team=team, status="paid")
.aggregate(
total_orders=Count("id"),
total_revenue=Sum("amount"),
avg_order=Avg("amount"),
)
)
context.update(
team=team,
stats_version=version,
stats={
"total_orders": rows["total_orders"] or 0,
"total_revenue": rows["total_revenue"] or Decimal("0"),
"avg_order": rows["avg_order"] or Decimal("0"),
},
)
return context
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Order
from .views import stats_version_key
def bump_stats_version(team_id):
key = stats_version_key(team_id)
try:
cache.incr(key)
except ValueError:
# Key missing or non-numeric; seed it so future incr() calls work.
cache.set(key, 1, timeout=None)
@receiver(post_save, sender=Order)
def order_saved(sender, instance, **kwargs):
bump_stats_version(instance.team_id)
@receiver(post_delete, sender=Order)
def order_deleted(sender, instance, **kwargs):
bump_stats_version(instance.team_id)
This snippet shows how to cache an expensive dashboard fragment using Django's {% cache %} template tag, and then keep that cache correct with signal-based invalidation rather than relying on time-to-live expiry alone.
In dashboard.html, the fragment that aggregates order statistics is wrapped in {% cache %}. The tag takes a timeout, a fragment name, and a set of vary-on keys — here the team.id and a version token from the context. The version token is the trick that makes precise invalidation possible: {% cache %} builds its key by hashing all vary-on arguments, so changing the token effectively points the template at a fresh cache slot without needing to compute and delete the exact key.
In views.py, TeamDashboardView computes stats_version by reading a small integer from the cache under a per-team key. This read is cheap compared to the aggregate query it guards. The heavy annotate/aggregate work happens inside the cached block, so on a warm cache the ORM query never runs. make_naive timeouts and the vary_on list keep tenants isolated so one team never sees another's numbers.
In signals.py, a post_save and post_delete handler on Order calls bump_stats_version, which uses cache.incr (falling back to cache.set when the key is absent) to advance the per-team token. Because the template's vary-on now includes a new value, the next render misses the cache and recomputes, while every other team's fragment stays warm. This is far cheaper and safer than trying to reconstruct the exact make_template_fragment_key and delete it.
The approach trades a tiny extra cache read per request for correctness: data-changing events, not clock time, drive invalidation. A generous timeout acts only as a safety net. Pitfalls to watch include ensuring the signal fires for bulk operations — QuerySet.update() and bulk_create() bypass post_save, so those paths must bump the version explicitly. Reaching for this pattern makes sense when a fragment is expensive to build, changes infrequently relative to reads, and must reflect writes promptly.
Related snips
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
Share this code
Here's the card — post it anywhere.