Skip to content

Caching

Caching can drastically improve the performance of your API by temporarily storing the results of expensive database queries or heavy serialization processes. When a client requests data that has been cached, the server can return the pre-computed response immediately, saving CPU cycles and reducing response times.

Default Behavior

By default, DRF does not cache any responses. Every request is processed dynamically, querying the database and serializing the results on the fly.

Caching Views

Since DRF is built directly on top of Django, it does not reinvent the wheel with its own caching system. Instead, you use Django's standard @cache_page decorator.

This decorator caches the entire HTTP response produced by the view, keyed by the requested URL and relevant headers. It only caches GET and HEAD requests that return a 200 OK or 304 Not Modified status.

Caching ViewSets

While you can technically apply cache_page to the dispatch method of an entire ViewSet (or wrap a router URL), the behavior is often unexpected. Because the decorator strictly targets GET and HEAD methods, write operations (POST, PUT, DELETE) will silently bypass the cache rather than raising an error or invalidating stored data.

Applying the Cache Decorator

Depending on whether you use Class-Based Views, ViewSets, or Function-Based Views, the application of the decorator differs slightly.

python
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework.views import APIView
from rest_framework.response import Response

class TimeView(APIView):
    @method_decorator(cache_page(60 * 15)) # Cache for 15 minutes
    def get(self, request):
        return Response({'status': 'cached'})
python
from django.views.decorators.cache import cache_page
from rest_framework.decorators import api_view
from rest_framework.response import Response

@cache_page(60 * 15) # Must be ABOVE @api_view
@api_view(['GET'])
def get_time(request):
    return Response({'status': 'cached'})
python
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import viewsets
from rest_framework.response import Response

class TimeViewSet(viewsets.ViewSet):
    @method_decorator(cache_page(60 * 15))
    def list(self, request):
        return Response({'status': 'cached'})

Decorator Order Matters

When stacking caching decorators, order is strictly enforced:

  1. @cache_page must be ABOVE @api_view: cache_page expects a plain Django HttpRequest. If placed below @api_view, it receives DRF's wrapped Request and will fail.
  2. @vary_on_ must be BELOW @cache_page: vary_on_ adds headers to the response that cache_page needs to generate the cache key. If placed above, the cache is keyed incorrectly before the headers are added.

Varying the Cache

By default, @cache_page caches the response based on the requested URL. If your API returns different data to different users at the exact same URL (for example, user-specific private data or language-specific content), you must tell the cache to store separate versions of the response.

You can do this using Django's vary_on_cookie and vary_on_headers decorators.

The Risks of Caching Per-User Data

Caching private user data at the view level using @cache_page is highly prone to data leaks and poor hit rates. If you must do it:

  1. Vary on all auth methods: If you support sessions and tokens, you must explicitly use both @vary_on_cookie and @vary_on_headers('Authorization'). Omitting one will leak data.
  2. Prevent CDN caching: Add @cache_control(private=True) to stop downstream proxies from caching private responses.
  3. Beware of Token Churn: Varying on Authorization with short-lived JWTs means every refreshed token generates a new cache key, rendering the cache useless.

Best Practice: Do not use @cache_page for user-specific data. Instead, use Django's low-level caching API to cache querysets or serialized data explicitly by user ID.

python
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.views import APIView
from rest_framework.response import Response

class MultiLingualView(APIView):
    # Create separate cache entries based on the Accept-Language header
    @method_decorator(cache_page(60 * 15))
    @method_decorator(vary_on_headers('Accept-Language'))
    def get(self, request):
        return Response({'message': 'Hello!'})

Content Negotiation

APIView automatically sets the Vary: Accept header whenever a view has more than one renderer configured. This ensures that content negotiation is handled correctly by default and clients requesting JSON won't receive a cached HTML browsable API response.

The Query String Trap

Because the cache key includes the full requested URL, every unique query string generates a separate cache entry. A ViewSet list endpoint that supports filtering, sorting, or pagination (e.g., ?page=2, ?ordering=name) will fragment its cache extremely quickly.

This leads to two common pitfalls:

  1. Low Hit Rates: Since users often request distinct filter combinations, you may cache far more responses than you actually serve from the cache.
  2. Cache Eviction: Malicious (or buggy) clients can append arbitrary query parameters (?rand=1, ?rand=2) to generate an unbounded number of distinct keys, filling up your cache and evicting legitimate entries.

To protect your default cache, you can configure cache_page to use a dedicated cache instance that has strict memory limits and a sane eviction policy:

python
# Route view caching to a dedicated 'api_responses' cache backend
@method_decorator(cache_page(60 * 15, cache='api_responses'))

Cache Invalidation

By default, the @cache_page decorator only invalidates the cache when the specified time limit expires. It does not automatically invalidate when the underlying database records change.

If a user updates a record via a PUT or POST request, the cached GET endpoint will continue to serve stale data until the timeout expires. If your application requires real-time data accuracy, you must either handle cache invalidation manually using Django's low-level cache API, or use a third-party package designed for model-based invalidation.

TIP

When time-based expiry isn't accurate enough, the standard HTTP solution is to use conditional requests. Django provides the @condition decorator to evaluate ETag and Last-Modified headers, allowing the server to return a 304 Not Modified response if the data hasn't changed.

Keeping Views DRY

Applying caching decorators directly to view classes or functions couples your business logic to your caching strategy. A more DRY and flexible approach is to apply the cache in your URL configuration (urls.py).

This allows you to reuse the exact same APIView in different contexts: for instance, cached on public-facing endpoints but un-cached on an internal admin dashboard.

python
from django.urls import path
from django.views.decorators.cache import cache_page
from .views import TimeView

urlpatterns = [
    # Cache this specific route for 15 minutes
    path('time/', cache_page(60 * 15)(TimeView.as_view())),
]

NOTE

This URL-routing approach works cleanly for APIView. However, if you are using ViewSets registered via a DefaultRouter, you do not have direct access to as_view() in your URL configuration. For ViewSets, you generally need to fall back to using @method_decorator directly on the class methods.

Configure Your Cache

Django defaults to a local-memory cache (LocMemCache). Because it is per-process, it is unsuitable for production (and even local development the moment you run more than one worker), since different requests will hit different cache stores. Always configure a centralized backend like Redis or Memcached in your CACHES setting for multi-process environments.

TIP

Need more advanced caching? If you want to cache individual querysets, partial serialized objects, or implement automatic cache invalidation when models change, check out third-party packages like drf-extensions or django-cacheops.