Caching in Django is one of the most important topics for improving performance in real-world applications. At the beginning of a Django journey, developers usually focus on getting the application to work correctly: models, views, templates, forms, authentication, and business logic. That is the right priority. But as soon as a project starts receiving more traffic, displaying more data, or performing more repeated calculations, performance becomes a serious concern. A page that loads many database records, calculates summaries, renders complex templates, or calls external services can quickly become slow. If the same expensive work is repeated again and again for many users, the server wastes time and resources doing identical operations. Caching solves this by storing the result of expensive work temporarily so the application can reuse it instead of recomputing it every time.

The main idea of caching is simple: if a value or result does not need to be recalculated for every request, store it somewhere fast and retrieve it when needed. In Django, that “something” might be an entire page, a fragment of a template, the result of a database-heavy query, a computed dashboard summary, or even data fetched from an external API. When used correctly, caching can make pages load much faster, reduce database load, lower CPU usage, and improve the overall user experience. But caching also introduces an important responsibility: you must think carefully about when cached data should expire or be refreshed. That is why caching is not only a technical optimization. It is also a design decision about freshness, consistency, and performance.

To understand caching clearly, let us begin with a simple example. Suppose you have a homepage that displays the latest published articles, featured categories, and some site statistics. Without caching, every request may execute several queries and render the same content repeatedly:

from django.shortcuts import render
from .models import Article, Category

def home(request):
    latest_articles = Article.objects.filter(published=True).order_by('-created_at')[:10]
    categories = Category.objects.all()
    total_articles = Article.objects.filter(published=True).count()

    return render(request, 'home.html', {
        'latest_articles': latest_articles,
        'categories': categories,
        'total_articles': total_articles,
    })

This works, but imagine hundreds or thousands of users visiting that page. Django repeats the same work again and again, even though the homepage data may change only every few minutes. Caching allows you to avoid that repetition.

Before looking at code, it is useful to understand the different levels of caching in Django. Django supports several caching strategies:

  1. Site-wide caching – cache the entire site or many full pages.
  2. Per-view caching – cache the response of a specific view.
  3. Template fragment caching – cache only part of a page.
  4. Low-level caching – manually store and retrieve Python values in the cache.
  5. Backend-specific caching – choose where cached data is stored, such as memory, file system, database, Memcached, or Redis.

Each of these has its own use cases. Full-page caching is very powerful for public pages that do not change often. Template fragment caching is useful when only part of a page is expensive. Low-level caching is useful when you want fine control over exactly what is stored. Professional Django development often combines several of these levels.

The first thing needed for caching is a cache backend. Django caching depends on the CACHES setting in settings.py. A very simple example is local memory caching:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'unique-django-cache',
    }
}

This stores cached data in memory on the Django process. It is easy to use and great for development or small projects, but it is not ideal for multi-process or multi-server production deployments because each process keeps its own separate memory cache. That means cached values are not shared between all workers.

Another backend is file-based caching:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/django_cache',
    }
}

This stores cached values in files. It can work for small cases, but it is usually slower than memory-based systems.

Django can also use the database as a cache backend:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'my_cache_table',
    }
}

This is possible, but it is often less efficient than dedicated caching systems because the database is already handling your main application data.

In production, the most common serious choices are Memcached and Redis. These are fast in-memory systems designed for caching. Django supports them well, and they are usually preferred for high-performance deployments.

Now let us move to one of the easiest caching techniques: per-view caching. Django provides a decorator called cache_page:

from django.views.decorators.cache import cache_page
from django.shortcuts import render
from .models import Article

@cache_page(60 * 15)
def home(request):
    latest_articles = Article.objects.filter(published=True).order_by('-created_at')[:10]
    return render(request, 'home.html', {'latest_articles': latest_articles})

This caches the entire response of the home view for 15 minutes. The first request builds the page normally. Subsequent requests within that time window will receive the cached response instead of re-running the database query and template rendering. This is one of the simplest and most effective performance improvements you can make for public pages.

However, caching a full view works best when the page is the same for all users. If a page contains user-specific information, such as “Welcome, أحمد” or personal notifications, then full-page caching can become dangerous because you may accidentally serve one user’s content to another. That is why per-view caching is most suitable for public pages, category pages, documentation pages, blog articles, landing pages, and similar content that is mostly identical for all visitors.

You can also apply per-view caching in URL configuration instead of directly decorating the view:

from django.urls import path
from django.views.decorators.cache import cache_page
from .views import home

urlpatterns = [
    path('', cache_page(60 * 15)(home), name='home'),
]

This is useful if you want to keep the view code clean or control caching behavior from the URL layer.

Django also supports site-wide caching using middleware. This caches complete pages for the whole site. To enable it, Django uses special middleware in MIDDLEWARE and some cache settings:

MIDDLEWARE = [
    'django.middleware.cache.UpdateCacheMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.cache.FetchFromCacheMiddleware',
]

And in settings:

CACHE_MIDDLEWARE_ALIAS = 'default'
CACHE_MIDDLEWARE_SECONDS = 600
CACHE_MIDDLEWARE_KEY_PREFIX = ''

This approach can be very powerful for mostly static public sites, but it must be used carefully. Middleware-based site caching is blunt compared to more targeted strategies. It works best when most pages are cacheable and not user-specific. For many modern applications, developers prefer more selective per-view or low-level caching rather than caching every request globally.

Now let us discuss template fragment caching, which is one of the most practical and elegant tools in Django. Suppose your page has some dynamic user-specific parts, but also includes an expensive “popular articles” sidebar that is the same for everyone. You do not want to cache the whole page, but you do want to cache that fragment. Django makes this possible in templates:

{% load cache %}

{% cache 600 popular_articles %}
    <div class="sidebar-box">
        <h3>Popular Articles</h3>
        <ul>
            {% for article in popular_articles %}
                <li>{{ article.title }}</li>
            {% endfor %}
        </ul>
    </div>
{% endcache %}

This caches that block for 600 seconds. The fragment name here is popular_articles. Template fragment caching is extremely useful because it lets you optimize only the expensive parts while leaving the rest of the page dynamic.

You can also vary fragment caching by some parameter. For example, suppose the sidebar differs by category:

{% cache 600 category_sidebar category.id %}
    ...
{% endcache %}

Now each category gets its own cached fragment. This prevents the wrong content from being reused across different contexts.

Low-level caching gives the most control. Django provides a cache API through:

from django.core.cache import cache

This allows you to manually set, get, and delete values. For example:

from django.core.cache import cache
from .models import Article

def get_popular_articles():
    articles = cache.get('popular_articles')

    if articles is None:
        articles = list(
            Article.objects.filter(published=True).order_by('-views')[:5]
        )
        cache.set('popular_articles', articles, 600)

    return articles

This is a classic caching pattern. First, try to get the value from cache. If it does not exist, calculate it, store it, and return it. This is often called the cache-aside pattern. It is one of the most common approaches in Django because it is simple and flexible.

Why did I convert the QuerySet to a list here? Because caching raw QuerySets can sometimes be risky or confusing, especially if evaluation happens later in an unexpected context. It is often safer to cache concrete evaluated results such as lists, dictionaries, numbers, or serialized data rather than lazy QuerySets.

Low-level caching is very useful for expensive summaries. Suppose your dashboard calculates statistics:

from django.core.cache import cache
from .models import Article, Category

def dashboard_stats():
    stats = cache.get('dashboard_stats')

    if stats is None:
        stats = {
            'total_articles': Article.objects.count(),
            'published_articles': Article.objects.filter(published=True).count(),
            'total_categories': Category.objects.count(),
        }
        cache.set('dashboard_stats', stats, 300)

    return stats

Now your application avoids repeating those count queries on every request for five minutes.

Django’s cache API supports many helpful methods beyond get() and set(). For example:

cache.add('key', 'value', 60)

This adds a value only if the key does not already exist.

cache.get_or_set('key', 'value', 60)

This is a convenient shortcut that retrieves a key if present, otherwise stores and returns the given default.

cache.delete('key')

This removes a cached value.

cache.clear()

This clears the whole cache, which can be useful during development or administrative operations, though it should be used carefully in production.

You can also increment or decrement numeric values:

cache.set('visits', 1, 300)
cache.incr('visits')

These operations can be helpful for simple counters, though for critical counters you should still think carefully about concurrency and durability.

A very important part of caching is cache invalidation, which is often said to be one of the hardest problems in software engineering. The reason is simple: caching improves speed by reusing old results, but old results may eventually become stale. So you need a strategy for when cached values should expire or be refreshed. There are two broad approaches:

  1. Time-based expiration – set a timeout such as 60 seconds, 10 minutes, or 1 hour.
  2. Event-based invalidation – clear or update the cache when relevant data changes.

Time-based expiration is easy and works well when some staleness is acceptable. For example, a homepage list of articles cached for 5 minutes is usually fine. Event-based invalidation is more precise. For example, when a new article is published, you may want to delete the cached homepage data immediately so the next request rebuilds it.

Here is a simple example:

from django.core.cache import cache

def publish_article(article):
    article.published = True
    article.save()
    cache.delete('homepage_articles')

This ensures that the homepage cache is invalidated when content changes.

If your application has many related cache keys, naming conventions become very important. For example:

  • homepage_articles
  • popular_articles
  • category_sidebar_3
  • dashboard_stats
  • article_detail_25

A good naming scheme makes cache management much easier. It also helps you avoid collisions and understand what each key represents.

Now let us discuss a very practical caching example for article detail pages. Suppose each article detail page includes the article itself, related posts, and a view counter. Caching the whole page might be risky if the view counter changes constantly. But caching parts of the logic can still help. For example:

from django.core.cache import cache
from django.shortcuts import get_object_or_404, render
from .models import Article

def article_detail(request, slug):
    cache_key = f'article_detail_{slug}'
    article_data = cache.get(cache_key)

    if article_data is None:
        article = get_object_or_404(Article, slug=slug, published=True)
        related_articles = list(
            Article.objects.filter(category=article.category, published=True)
            .exclude(id=article.id)[:5]
        )
        article_data = {
            'article': article,
            'related_articles': related_articles,
        }
        cache.set(cache_key, article_data, 600)
    else:
        article = article_data['article']
        related_articles = article_data['related_articles']

    return render(request, 'article_detail.html', {
        'article': article,
        'related_articles': related_articles,
    })

This reduces repeated work for article retrieval and related-content queries. If the article is edited, you can delete the key article_detail_{slug} so the cache refreshes.

Another major caching concept is that caching should target expensive repeated work, not random values. Beginners sometimes cache too aggressively without a clear reason. But caching itself has a cost: storing values, managing invalidation, and introducing complexity. So it is most valuable when:

  • the work is expensive,
  • the same result is requested repeatedly,
  • the result does not change every second,
  • some temporary staleness is acceptable or manageable.

If a query is already fast and rarely repeated, caching it may not be worth the complexity.

Django also allows per-site or per-view cache variation by headers or request properties. For example, different language versions of a page may need separate cache entries. The same can be true for mobile vs desktop or authenticated vs anonymous users. Django’s caching system can take headers and request path into account when constructing keys. But here you must think carefully: if a page varies by many factors, full-page caching may become far less efficient because each variation produces a separate cache entry.

An important production topic is choosing the right backend. Local memory is fine for development, but in production a dedicated shared backend like Redis or Memcached is usually much better. Redis is especially popular because it is fast, flexible, and widely used. It can store cache data centrally so all Django workers share the same cache. This is essential when you have multiple Gunicorn workers, multiple containers, or multiple servers.

For example, with Redis you might configure Django using a suitable backend package or built-in support depending on your stack. The general idea is that the cache becomes shared and persistent across workers, which makes your application caching consistent.

Another useful performance pattern is combining caching with database optimization. Caching should not be used as an excuse for poor queries. For example, if your view suffers from an N+1 query problem, the first step is often to optimize the QuerySet using select_related() or prefetch_related(). Then, if the result is still expensive and repeated often, add caching. Good performance usually comes from layering strategies: better queries, less repeated work, and then caching where appropriate.

It is also important to understand what not to cache. Highly sensitive user-specific data should be handled very carefully. For example, you should not casually full-page-cache a logged-in dashboard containing private information. You should also avoid caching things whose correctness must always be immediate unless you have a precise invalidation strategy. Financial balances, permission checks, and security-critical state often require much more caution.

Let us look at a good example of template fragment caching for a mostly static homepage with a dynamic user greeting:

<h2>Welcome {{ request.user.username }}</h2>

{% load cache %}
{% cache 900 homepage_featured %}
    <section>
        <h3>Featured Articles</h3>
        {% for article in featured_articles %}
            <article>{{ article.title }}</article>
        {% endfor %}
    </section>
{% endcache %}

This is a smart design. The personal greeting stays dynamic, while the expensive featured section is cached.

Another advanced concept is cache versioning. Sometimes instead of deleting keys manually, developers include a version or timestamp in the cache key pattern. For example:

cache_key = f'homepage_articles_v2'

If you change the data structure or logic, you can update the version to create fresh keys automatically. This is a simple but effective strategy when cache format changes over time.

Django also supports multiple named caches, which can be helpful if you want to separate different types of cached data:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'default-cache',
    },
    'special': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'special-cache',
    }
}

Then:

from django.core.cache import caches

special_cache = caches['special']
special_cache.set('key', 'value', 60)

This can be useful when you want different cache backends or policies for different parts of the application.

There is also an operational side to caching. In development, caches can confuse you because changes may not appear immediately. If you edit a template or publish new content and still see the old result, it may simply be cached. Developers often forget this and think the code is broken. Good debugging habits include temporarily lowering cache timeouts, clearing caches when testing, or disabling certain caching layers during development.

Testing cached code is also important. If a function behaves differently when data is cached versus uncached, your tests should verify both paths when relevant. For example, test that the correct value is computed when the cache is empty, and test that it is reused when the cache is filled.

Now let us bring everything together with a realistic example:

from django.core.cache import cache
from django.shortcuts import render
from .models import Article

def homepage(request):
    featured_articles = cache.get('featured_articles')

    if featured_articles is None:
        featured_articles = list(
            Article.objects.filter(published=True, featured=True)
            .select_related('category', 'author')
            .order_by('-created_at')[:6]
        )
        cache.set('featured_articles', featured_articles, 900)

    return render(request, 'homepage.html', {
        'featured_articles': featured_articles
    })

This is a strong pattern because:

  • it targets repeated expensive work,
  • it uses a clear cache key,
  • it sets a reasonable timeout,
  • it combines query optimization and caching,
  • it keeps the view readable.

If an article changes, you could invalidate featured_articles explicitly in your publish or update workflow.

In conclusion, caching in Django is a powerful technique for making applications faster and more efficient by avoiding repeated expensive work. Django provides multiple layers of caching, including full-page caching, per-view caching, template fragment caching, and low-level manual caching. The real power of caching comes not just from storing data, but from using it thoughtfully: choosing the right level, the right timeout, the right keys, and the right invalidation strategy. Good caching can dramatically improve performance and reduce database load, but poor caching can create stale data, debugging confusion, and architectural complexity. That is why professional Django caching is not about caching everything. It is about caching the right things in the right way.

What you learned in this tutorial

In this tutorial, you learned what caching is, why it matters for performance, how Django cache backends work, how to configure caching in settings.py, how to use per-view caching with cache_page, how to use site-wide caching with middleware, how to use template fragment caching, how to use the low-level cache API with cache.get() and cache.set(), how cache invalidation works, why key naming matters, how to choose what to cache, and why production backends like Redis or Memcached are often better than local memory.