Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 65

Redis Caching for Database Optimization

Recommended resources

Welcome back! In our last lesson, you implemented rate limiting, a crucial step in protecting your API from being overwhelmed. You've built a defense mechanism. Now, let's switch from defense to offense and focus on making your application dramatically faster and more scalable.

This lesson addresses a core principle of building high-performance systems: caching. You'll learn how to configure and apply caching strategies using Redis to significantly reduce the load on your database. This directly supports the Infilect job description's need for engineers who can "build scalable backend/cloud applications" and have experience with "system and product development at scale." An application that can't efficiently manage its data access will never be able to scale.

By the end of this lesson, you will be able to integrate Redis as a cache backend for your Django application, apply caching to API endpoints, and implement strategies for both public and user-specific data to drastically improve performance.

1. Why Cache? The 100x Performance Gain

In a typical web application, the database is often the primary bottleneck. Every time a user requests a list of products, your application queries the database, the database finds the data on disk, processes it, and sends it back. This can take hundreds ofmilliseconds. Now imagine a thousand users requesting the same list. That's a thousand slow, repetitive database queries.

Caching solves this by storing the results of expensive operations (like database queries) in a much faster, in-memory data store. For subsequent requests, the application can retrieve the data from the cache in just a few milliseconds, bypassing the database entirely.

This diagram illustrates the performance difference. A request that misses the cache must wait for a slow database query (~200ms). A request that hits the cache gets a near-instant response from Redis (~2ms). This is the performance gain we are aiming for.

The most common caching strategy is called cache-aside. The logic is straightforward:

  1. When a request comes in, first check if the data exists in the cache.
  2. Cache Hit: If it does, return the data directly from the cache. Fast!
  3. Cache Miss: If it doesn't, perform the expensive operation (e.g., query the database), store the result in the cache for future requests, and then return the data.

The flowchart below visualizes this exact process for a DRF API. We will be implementing this pattern throughout the lesson.

The flow for an initial request (cache miss) involves the database, while subsequent requests (cache hit) are served directly and quickly from the cache (e.g., Redis).

2. Setting Up Redis and Django

To use Redis with Django, we need to install Redis itself, add a Python library to communicate with it, and configure Django to use it as its cache backend.

The following article provides a complete guide. We will follow its first three steps to get our project set up.

Django + Redis: The Complete Caching Guide That ...

This Medium article by Sizan Mahmud provides an excellent, practical guide to integrating Redis with Django. We'll use it to set up our environment.

Please read and follow the instructions in the first three sections: Step 1: Install Redis. You can follow the instructions for your OS. However, since we are using Docker in this course, I recommend running Redis in a container. You can achieve this with a single command: docker run --name infilytics-redis -p 6379:6379 --restart always -d redis. This is the same approach shown in the BugBytes video we'll see later. Step 2: Install Python Dependencies. Make sure your virtual environment is activated and add django-redis to your requirements.txt file. Step 3: Configure Django Settings. This is the most important part. Copy the CACHES dictionary into your infilytics/settings.py file.

Let's break down the configuration you just added to settings.py:




# in infilytics/settings.py

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1", # Use DB 1 for cache
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        },
        "KEY_PREFIX": "infilytics" # Good practice for multi-app environments
    }
}




# Also, move session storage to Redis to reduce DB load
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
  • BACKEND: Tells Django to use the django-redis library.
  • LOCATION: Specifies the address of your Redis server. The /1 at the end selects the second database inside Redis (they are 0-indexed), which is good practice to keep your cache data separate from other potential uses of Redis (like Celery, which often uses DB 0).
  • KEY_PREFIX: Prevents key collisions if multiple applications were to share the same Redis instance.
  • SESSION_ENGINE: This is a bonus optimization. By changing this, you are telling Django to store user session data in Redis instead of the database, further reducing database load for authenticated user actions.

3. Caching an API View (Per-View Caching)

Now for the exciting part. Let's apply caching to an API endpoint and see the performance difference. We'll target the product list endpoint (/api/products/), which is a perfect candidate as it's public data that doesn't change too frequently.

The following video provides a fantastic walkthrough of the entire process, from setting up Redis with Docker to applying the cache decorator and seeing the result.

Caching with Redis and Django!

This video from BugBytes is a perfect practical demonstration of everything we've discussed so far.

Please watch the following segments. The code in the video is very similar to what you will be writing. Setting up Redis with Docker. This is the Docker-based approach I recommended earlier. Configuring Django. This covers installing the packages and setting up the CACHES dictionary in settings.py. Caching the Product List View. This is the core of our task. It shows how to use the @cache_page decorator and simulates a slow database query with time.sleep() to make the caching benefit obvious.

Task: Cache the Product List View

Inspired by the video, let's implement this in your infilytics project.

  1. Simulate a Slow Query: Open your products/views.py file. To make the impact of caching obvious, let's add a time.sleep() in the ProductViewSet. This simulates a slow database operation.

    
    
    
    # in products/views.py
    import time
    from rest_framework import viewsets
    
    
    
    # ... other imports
    
    class ProductViewSet(viewsets.ModelViewSet):
        queryset = Product.objects.all()
        serializer_class = ProductSerializer
    
        def get_queryset(self):
    
    
    
            # Simulate a slow database query
            time.sleep(2) 
            return super().get_queryset()
    
  2. Run and Test (Before): Start your server (python manage.py runserver). Navigate to http://127.0.0.1:8000/api/products/. Notice it takes 2 seconds to load. Refresh the page. It takes another 2 seconds. Every request is slow.

  3. Apply the @cache_page Decorator: Now, let's add the cache. Django's @cache_page decorator is the easiest way to implement per-view caching. We'll apply it to the dispatch method of our class-based view.

    
    
    
    # in products/views.py
    import time
    from django.utils.decorators import method_decorator
    from django.views.decorators.cache import cache_page
    from rest_framework import viewsets
    
    
    
    # ... other imports
    
    @method_decorator(name='dispatch', decorator=cache_page(60 * 15)) # Cache for 15 mins
    class ProductViewSet(viewsets.ModelViewSet):
    
    
    
        # ... (rest of the class is the same)
        queryset = Product.objects.all()
        serializer_class = ProductSerializer
    
        def get_queryset(self):
    
    
    
            # Simulate a slow database query
            time.sleep(2) 
            return super().get_queryset()
    

    Note: We are applying the decorator to the dispatch method because it's the entry point for all requests to this ViewSet.

  4. Run and Test (After): Restart your server. Navigate to http://127.0.0.1:8000/api/products/ again.

    • The first request will take 2 seconds. This is a cache miss. DRF is running your view, hitting the time.sleep(), generating the response, and storing it in Redis.
    • Refresh the page. It loads instantly! This is a cache hit. Django intercepted the request, found a cached version for this URL in Redis, and returned it immediately, never even calling your get_queryset method.

You have just implemented caching and witnessed the dramatic performance improvement firsthand.


4. Cache Invalidation: Keeping Data Fresh

We have a fast API, but we also have a new problem: what if a product's name is updated, or a new product is added? The cache is now stale, serving old data for the next 15 minutes. This is unacceptable.

The solution is cache invalidation: we must programmatically delete the relevant cache entries whenever the underlying data changes. Django signals are perfect for this.

The BugBytes video you watched earlier demonstrates exactly how to solve this.

Caching with Redis and Django!

Now, let's focus on the crucial part of making our cache correct: invalidation.

Watch this final segment of the video, which explains how to use post_save and post_delete signals to clear the cache whenever a product is created, updated, or deleted. Pay close attention to the use of cache.delete_pattern. Cache Invalidation with Signals

Task: Implement Cache Invalidation

  1. Create signals.py: In your products app directory, create a new file signals.py.

  2. Define the Signal Handler: Add the code to listen for post_save and post_delete on the Product model and clear the cache.

    
    
    
    # in products/signals.py
    from django.core.cache import cache
    from django.db.models.signals import post_save, post_delete
    from django.dispatch import receiver
    from .models import Product
    
    @receiver([post_save, post_delete], sender=Product)
    def invalidate_product_cache(sender, instance, **kwargs):
        """
        Invalidates the cache for the product list view when a product is
        created, updated, or deleted.
        """
    
    
    
        # The pattern needs to match how django-redis constructs keys.
        # It's usually: <prefix>:1:views.decorators.cache.cache_page.*
        # Our KEY_PREFIX is 'infilytics'. We'll clear anything related to views caching.
        # A more specific pattern could be used if needed.
        cache.delete_pattern("infilytics:1:views.decorators.cache.cache_page.*")
        print(f"Cache invalidated for products.")
    

    The cache.delete_pattern function is a powerful feature from django-redis. It deletes all keys that match a certain pattern. This is essential because @cache_page creates keys that include URL query parameters, so a simple cache.delete('key') wouldn't be enough to clear all variations (e.g., paginated pages).

  3. Register the Signals: Open products/apps.py and register your signals in the ready() method.

    
    
    
    # in products/apps.py
    from django.apps import AppConfig
    
    class ProductsConfig(AppConfig):
        default_auto_field = 'django.db.models.BigAutoField'
        name = 'products'
    
        def ready(self):
            import products.signals
    
  4. Test Invalidation:

    • Restart your server and load http://127.0.0.1:8000/api/products/. The first load is slow (2s), subsequent loads are fast.
    • Now, go to your Django admin http://127.0.0.1:8000/admin/, log in, and add a new product or change an existing one.
    • Go back to the product list API and refresh. The page now takes 2 seconds to load again! Your signal handler worked, invalidated the cache, and forced the view to re-fetch from the database. The next refresh will be fast again, with the fresh data.

5. Advanced: Per-User Caching

Caching public data is relatively simple. Caching data that is specific to a user, like their order history, is more complex and dangerous if done incorrectly.

The Problem: Imagine you cache the /api/orders/ endpoint. User A requests their orders, the response is cached. Then User B requests their orders. If the cache key is just based on the URL, the cache will incorrectly serve User B the cached response belonging to User A. This is a critical data leak.

The Solution: We need to tell the cache to create different versions of the cached page based on a request header, specifically the Authorization header that contains the user's token. The vary_on_headers decorator is designed for this.

Django & Redis - Vary Headers to Control Caching Behavior

This next video from BugBytes explains this problem and demonstrates the solution beautifully.

Please watch these segments carefully: The problem with caching user-specific data: This demonstrates the data leak scenario. Introducing vary_on_headers: Shows the decorator that solves the problem. Demonstrating the correct behavior: Confirms that different users now get their own correct, cached data.

Task: Implement Per-User Caching on Orders

Assuming you have an OrderViewSet for an Order model linked to a user, you can apply this pattern.




# in an orders/views.py for example




# ... imports
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers




# ...

@method_decorator(name='dispatch', decorator=cache_page(60 * 15))
@method_decorator(name='dispatch', decorator=vary_on_headers('Authorization'))
class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):



        # Return orders only for the current user
        return Order.objects.filter(user=self.request.user)

By adding @vary_on_headers('Authorization'), you instruct Django's caching system to consider the content of the Authorization header when creating the cache key. This ensures that every unique user (with their unique token) gets their own separate cache entry, preventing data leaks while still providing a significant performance boost.


Conclusion

In this lesson, you've taken a massive step towards building a truly scalable application. You've gone beyond just making your code work; you've made it performant.

Here are the key takeaways:

  • Caching is essential for scalability, reducing database load and dramatically improving response times.
  • Redis is an excellent choice for a cache backend due to its speed as an in-memory key-value store.
  • Per-view caching using @cache_page is a powerful and easy way to cache public API endpoints.
  • Cache invalidation is not optional; you must have a strategy (like using signals) to clear stale data and ensure correctness.
  • Per-user caching requires extra care. Use vary_on_headers('Authorization') to prevent dangerous data leaks when caching user-specific content.

You have made your application both faster and more efficient. However, as our system grows more complex with load balancers, multiple instances, and caches, how do we keep track of what's happening inside? In the next lesson, we will address this by setting up centralized logging with AWS CloudWatch, moving from performance to observability—another critical pillar of production-ready systems.