Skip to main content

Welcome to a Grasp lesson

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

Lesson 73

Versioning DRF APIs with URLs

Recommended resources

Hello! Welcome back to our module on API Security and Best Practices.

In our last session, we took a high-level look at API versioning, discussing why it's crucial for managing change and describing the three common strategies: URL, header, and query parameter. We concluded that URL path versioning is often preferred for its clarity and ease of use.

Today, we move from theory to practice. Our goal is to implement URL-based versioning for a DRF API to support evolving requirements. You'll learn how Django REST Framework's built-in tools can be configured to manage different API versions cleanly and effectively, allowing your API to evolve without breaking existing clients.

A Quick Recap: Two URL-Based Strategies in DRF

As we touched on previously, DRF offers two main schemes for implementing URL-based versioning. From a client's perspective, they look identical—the version is in the URL path (e.g., /api/v1/resource/). The difference lies in how you configure it on the backend.

To understand these two approaches, let's turn to the official Django REST Framework documentation.

Versioning

Please read these two sections from the DRF documentation on Versioning. They introduce the two main classes we will be working with for URL-based versioning.

Read the sections titled 'URLPathVersioning' and 'NamespaceVersioning'. Pay close attention to the urlpatterns examples in each to see how the server-side configuration differs.

To summarize the key difference:

  • URLPathVersioning: This scheme requires you to explicitly capture a version keyword argument in your URL pattern (e.g., re_path(r'^(?P<version>(v1|v2))/...')). It's direct but can make your URL patterns more complex if you have many routes.
  • NamespaceVersioning: This scheme determines the version from the URL's namespace. You create different URL prefixes (/v1/, /v2/) and include your app's URLs under different namespaces. This is often considered cleaner and more manageable for larger projects.

For the remainder of this lesson, we will focus on implementing NamespaceVersioning due to its superior organization and scalability.


Implementing NamespaceVersioning: A Step-by-Step Guide

Let's imagine a practical scenario. We have a Product API.

Version 1 (v1) of our API returns product data with a simple name field and a stock_count field.

Version 2 (v2) introduces a breaking change. The business decides to rename stock_count to inventory_level for clarity and add a is_active boolean field.

  • v1 Response: {"name": "Laptop Pro", "stock_count": 150}
  • v2 Response: {"name": "Laptop Pro", "inventory_level": 150, "is_active": true}

Our goal is to serve both versions simultaneously from URLs /api/v1/products/{id}/ and /api/v2/products/{id}/.

Step 1: Configure DRF for Versioning

First, we need to tell DRF which versioning scheme to use. In your project's settings.py, add the DEFAULT_VERSIONING_CLASS.

# settings.py

REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
    # ... other settings
}

This single line instructs DRF to inspect the URL namespace of incoming requests to determine the version.

Step 2: Create Version-Specific Serializers

Since v1 and v2 have different data structures, we need two different serializers.

# products/serializers.py

from rest_framework import serializers
from .models import Product

class ProductSerializerV1(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['name', 'stock_count']

class ProductSerializerV2(serializers.ModelSerializer):
    # We use source='stock_count' to map the model field to a different serializer field
    inventory_level = serializers.IntegerField(source='stock_count')
    
    class Meta:
        model = Product
        fields = ['name', 'inventory_level', 'is_active']

Step 3: Create a Version-Aware View

Instead of creating two separate views, we can create a single, "version-aware" view that selects the correct serializer based on the requested version. This is a very clean and common pattern. The request.version attribute, populated by our versioning scheme, is the key.

# products/views.py

from rest_framework import generics
from .models import Product
from .serializers import ProductSerializerV1, ProductSerializerV2

class ProductDetailView(generics.RetrieveAPIView):
    queryset = Product.objects.all()

    def get_serializer_class(self):
        # The 'version' is determined by the URL namespace
        if self.request.version == 'v1':
            return ProductSerializerV1
        # Default to v2 if not v1 or if version is not specified
        return ProductSerializerV2

By overriding get_serializer_class, we can dynamically return the correct serializer class based on the version DRF detected from the URL namespace.

Step 4: Configure URL Namespacing

This is where NamespaceVersioning comes to life. First, define the URLs for your app's endpoints as you normally would.

# products/urls.py

from django.urls import path
from .views import ProductDetailView

# NO versioning is mentioned here. These are the reusable URL patterns.
urlpatterns = [
    path('products/<int:pk>/', ProductDetailView.as_view(), name='product-detail'),
]

Now, in your project's root urls.py, you include this set of patterns twice, each under a different path and a different namespace.

# project/urls.py

from django.urls import path, include

urlpatterns = [
    # Requests to /api/v1/... will be routed with the namespace 'v1'
    path('api/v1/', include('products.urls', namespace='v1')),
    
    # Requests to /api/v2/... will be routed with the namespace 'v2'
    path('api/v2/', include('products.urls', namespace='v2')),
    
    # ... other project urls like admin
]

This is the magic of NamespaceVersioning. When a request comes in for /api/v1/products/1/, Django's URL resolver matches the v1 namespace. DRF then sets request.version = 'v1', which our ProductDetailView uses to select ProductSerializerV1. The same logic applies to /api/v2/.

This approach is highly organized. If you want to add a v3, you don't have to touch the products app at all. You just add a new ProductSerializerV3, update the logic in the view, and add one line to the root urls.py.

The following resource provides a similar, complete example. While it uses separate views for each version (another valid approach), notice the core similarity in the urls.py setup, which is the key to URL-based versioning.

REST API Versioning in Django: Best Practices for ...

This article provides a concise, full example of implementing URL versioning. Focus on the urls.py section to reinforce what we just discussed.

Read the sections 'Code Example: Implementing URL Versioning in Django' and 'Update urls.py'. This demonstrates how distinct URL paths are created for v1 and v2, which is the fundamental mechanism for this strategy.

Test your understanding!

Your Product API, versioned with NamespaceVersioning for v1 and v2, is a success. Now, a new requirement has come in for a v3. In this new version, the name field must be renamed to product_title.

Describe the three specific code changes you would need to make to add this v3 endpoint while leaving v1 and v2 completely unaffected.

Show answer

Here are the three necessary steps:

  1. Create a new serializer (products/serializers.py): You would define a ProductSerializerV3 that maps the model's name field to the new product_title field.

    class ProductSerializerV3(serializers.ModelSerializer):
        product_title = serializers.CharField(source='name')
    
        class Meta:
            model = Product
            fields = ['product_title', 'inventory_level', 'is_active']
    
  2. Update the view logic (products/views.py): You would add a condition to the get_serializer_class method to handle the new 'v3' version.

    def get_serializer_class(self):
        if self.request.version == 'v1':
            return ProductSerializerV1
        if self.request.version == 'v3': # Add this condition
            return ProductSerializerV3
        return ProductSerializerV2
    
  3. Add the new URL namespace (project/urls.py): You would add a new path to your root urls.py to expose the v3 endpoints.

    urlpatterns = [
        path('api/v1/', include('products.urls', namespace='v1')),
        path('api/v2/', include('products.urls', namespace='v2')),
        path('api/v3/', include('products.urls', namespace='v3')), # Add this line
    ]
    

Conclusion

Congratulations! You've successfully moved from understanding API versioning concepts to implementing a robust, scalable versioning strategy in a real-world Django project. This skill is essential for any backend developer working on APIs that need to evolve over time.

Key Takeaways:

  • API versioning in DRF is enabled via the DEFAULT_VERSIONING_CLASS setting.
  • URL-based versioning is a clear and popular strategy where the version is part of the URL path (e.g., /api/v1/).
  • NamespaceVersioning is a highly organized way to implement this, using URL namespaces to separate versions.
  • The request.version attribute becomes available in your views, allowing you to create version-aware logic, typically by overriding get_serializer_class() to select the appropriate serializer for the requested version.

Preview of the Next Lesson:
We've spent the last few lessons securing different aspects of our API's logic and structure. Now, we'll shift our focus to securing the data itself as it travels between the client and the server. In our next lesson, we will explore the role of TLS/SSL in providing encryption and integrity for data in transit, which is the technology that powers HTTPS and is fundamental to all modern web security.