Django REST Framework JSON: Serializers, ViewSets, and API Design

Last updated:

Django REST Framework (DRF) is the standard library for building JSON APIs with Django. It provides serializers for converting model instances to JSON, class-based and function-based views for handling HTTP methods, routers for auto-generating URL patterns, and a rich validation system — all built on top of Django's ORM and request/response cycle. This guide covers the core patterns from basic serialization to production-ready ViewSets.

Serializers — JSON Serialization and Deserialization

Serializers are the heart of DRF: they convert Django model instances and QuerySets to JSON-serializable Python types, and validate and deserialize incoming JSON back into Python objects.

# models.py
from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)
    is_active = models.BooleanField(default=True)

# serializers.py
from rest_framework import serializers
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'created_at', 'is_active']
        read_only_fields = ['id', 'created_at']

# Usage
product = Product.objects.get(id=1)
serializer = ProductSerializer(product)
print(serializer.data)
# → {'id': 1, 'name': 'Widget', 'price': '9.99', 'created_at': '2026-01-15T10:00:00Z', 'is_active': True}

# Serialize many objects
products = Product.objects.all()
serializer = ProductSerializer(products, many=True)
json_data = serializer.data   # list of OrderedDicts

Function-Based Views with @api_view

The @api_view decorator wraps a plain Python function into a DRF view, providing request parsing, content negotiation, and Response rendering.

# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import Product
from .serializers import ProductSerializer

@api_view(['GET', 'POST'])
def product_list(request):
    if request.method == 'GET':
        products = Product.objects.filter(is_active=True)
        serializer = ProductSerializer(products, many=True)
        return Response(serializer.data)

    elif request.method == 'POST':
        serializer = ProductSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@api_view(['GET', 'PUT', 'PATCH', 'DELETE'])
def product_detail(request, pk):
    try:
        product = Product.objects.get(pk=pk)
    except Product.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == 'GET':
        serializer = ProductSerializer(product)
        return Response(serializer.data)

    elif request.method in ['PUT', 'PATCH']:
        partial = request.method == 'PATCH'
        serializer = ProductSerializer(product, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)

    elif request.method == 'DELETE':
        product.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

ModelViewSet — Full CRUD in 10 Lines

ModelViewSet combines list, create, retrieve, update, and destroy actions into a single class. Paired with a DefaultRouter, it auto-generates all six URL patterns.

# views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Product
from .serializers import ProductSerializer

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

# urls.py
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet

router = DefaultRouter()
router.register(r'products', ProductViewSet, basename='product')

urlpatterns = router.urls
# Auto-generates:
# GET    /products/          → list
# POST   /products/          → create
# GET    /products/{id}/     → retrieve
# PUT    /products/{id}/     → update
# PATCH  /products/{id}/     → partial_update
# DELETE /products/{id}/     → destroy

Validation and Custom Fields

DRF serializers support field-level validators, object-level validators, computed read-only fields via SerializerMethodField, and custom create() logic.

class ProductSerializer(serializers.ModelSerializer):
    # Read-only computed field
    price_with_tax = serializers.SerializerMethodField()

    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'price_with_tax']

    def get_price_with_tax(self, obj):
        return float(obj.price) * 1.2  # 20% VAT

    # Field-level validation
    def validate_price(self, value):
        if value <= 0:
            raise serializers.ValidationError("Price must be positive")
        return value

    # Object-level validation
    def validate(self, data):
        if data.get('name', '').lower() == 'test' and data.get('price', 0) > 1000:
            raise serializers.ValidationError(
                "Test products cannot be priced over $1000"
            )
        return data

    # Custom create
    def create(self, validated_data):
        user = self.context['request'].user
        return Product.objects.create(created_by=user, **validated_data)

Nested Serializers and Relationships

Nest serializers to embed related objects in JSON responses. Use select_related on the queryset to avoid N+1 queries.

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ['id', 'name', 'slug']

class ProductDetailSerializer(serializers.ModelSerializer):
    # Nested read-only (one query per product without select_related)
    category = CategorySerializer(read_only=True)

    # Write using ID, read full object
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(), source='category', write_only=True
    )

    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'category', 'category_id']

# Response JSON:
# {"id": 1, "name": "Widget", "price": "9.99",
#  "category": {"id": 5, "name": "Gadgets", "slug": "gadgets"}}

# Optimize with select_related to avoid N+1
queryset = Product.objects.select_related('category')

Custom JSON Error Responses

Override DRF's default exception handler to return a consistent error envelope across all endpoints, and configure renderer and pagination settings for production.

# Custom exception handler for consistent error format
from rest_framework.views import exception_handler

def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is not None:
        errors = response.data
        response.data = {
            'status': 'error',
            'code': response.status_code,
            'errors': errors,
        }
    return response

# settings.py
REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'myapp.utils.custom_exception_handler',
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
        # Remove BrowsableAPIRenderer in production
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
}

Pagination and Filtering

DRF's built-in pagination wraps list responses in a count / next / previous / results envelope. Add django-filter for field filtering, and DRF's built-in backends for search and ordering.

# Pagination — automatic with DEFAULT_PAGINATION_CLASS setting
# Response includes: count, next, previous, results

# Filter with django-filter
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['category', 'is_active']  # ?category=5&is_active=true
    search_fields = ['name', 'description']        # ?search=widget
    ordering_fields = ['price', 'created_at']     # ?ordering=-price

# Response JSON (paginated):
# {
#   "count": 150,
#   "next": "https://api.example.com/products/?page=2",
#   "previous": null,
#   "results": [...]
# }

Key Definitions

Serializer
DRF class that converts complex Python data (QuerySets, model instances) to native Python types that can be rendered to JSON, and validates and deserializes incoming data back into Python objects.
ModelSerializer
Serializer subclass that auto-generates fields matching a Django model; provides default create() and update() implementations that call the ORM directly.
ViewSet
DRF class combining related view logic (list, create, retrieve, update, destroy) into a single class; routers automatically generate URL patterns from registered ViewSets.
JSONRenderer
DRF component that converts the final Python data structure into a JSON byte string for the HTTP response, setting Content-Type: application/json.
request.data
DRF's equivalent to request.POST but works for any content type; parses JSON, form data, or multipart depending on the Content-Type header of the incoming request.

FAQ

What is Django REST Framework and how does it handle JSON?

Django REST Framework (DRF) is a powerful toolkit for building Web APIs on top of Django. It handles JSON through a layered system: JSONParser automatically parses incoming requests with Content-Type: application/json into Python dicts accessible via request.data. JSONRenderer converts outgoing Python data structures to JSON byte strings for HTTP responses. Content negotiation selects the appropriate parser and renderer based on the request's Content-Type and Accept headers. DRF also handles serialization, deserialization, authentication, permissions, and throttling — all within a consistent framework built on Django's class-based views.

What is the difference between Serializer and ModelSerializer?

Serializer requires you to manually define all fields and implement create() and update() methods — giving full control but requiring significant boilerplate. ModelSerializer auto-generates fields by inspecting the Django model; set fields = '__all__' for everything or provide an explicit list. It also provides default create() and update() that call the ORM directly. Both support validate_fieldname() for field-level validation and validate() for object-level validation. Use plain Serializer when working with non-model data; use ModelSerializer for standard CRUD APIs backed by Django models.

How do I return a 201 Created response in DRF?

Use Response(serializer.data, status=status.HTTP_201_CREATED). Always import status from rest_framework — never use raw integers like 201. The DRF status module contains named constants for all standard HTTP codes, making code self-documenting and preventing typos. To also set a Location header pointing to the new resource, assign it after creating the response: response['Location'] = reverse('product-detail', args=[obj.id]). The Location header is part of the HTTP/1.1 spec for 201 responses and helps clients navigate to the created resource.

How do I handle nested JSON objects in DRF?

Nest another serializer as a field on the parent serializer. For read-only nesting, set read_only=True on the nested serializer — DRF includes the full nested object in GET responses without expecting it in POST/PUT bodies. For writable nested objects, override create() and update() on the parent serializer to handle nested data manually. A common pattern is to use a nested serializer for reads and PrimaryKeyRelatedField for writes. Always use select_related() or prefetch_related() on the queryset to avoid N+1 database queries when serializing nested objects across many records.

How does DRF validate incoming JSON?

Call serializer.is_valid() after constructing the serializer with request.data — returns True on success, False on failure; check serializer.errors for field-level error messages. Use serializer.is_valid(raise_exception=True) to automatically return a 400 Bad Request response with the errors dict as JSON body on failure. Define validate_fieldname(self, value) for field-level validation and validate(self, data) for object-level (cross-field) validation. You can also attach standalone validator functions or Validator classes to fields via the validators= parameter.

How do I configure DRF to return only JSON in production?

Set DEFAULT_RENDERER_CLASSES in the REST_FRAMEWORK dict in settings.py to only include JSONRenderer: REST_FRAMEWORK = {"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"]}. This removes BrowsableAPIRenderer, which renders an interactive HTML interface only useful in development and exposes API structure to anyone opening the URL in a browser. To override per view, use the @renderer_classes([JSONRenderer]) decorator on function-based views or set renderer_classes = [JSONRenderer] on class-based views. JSONRenderer uses UTF-8 encoding and sets Content-Type: application/json.

What is the DRF request.data vs request.POST?

request.data is DRF's unified interface for all incoming request bodies, regardless of Content-Type. It automatically parses application/json bodies via JSONParser, form-encoded bodies, and multipart bodies. Always use request.data in DRF views. Django's native request.POST only parses application/x-www-form-urlencoded — it returns an empty QueryDict for JSON bodies, which is a common bug when transitioning from Django form views to DRF. Similarly, use request.query_params instead of request.GET for query string parameters.

How do I serialize datetime fields in DRF JSON?

DRF's DateTimeField serializes datetime objects to ISO 8601 strings by default (e.g., 2026-01-15T10:30:00Z). Control the format globally with DATETIME_FORMAT in REST_FRAMEWORK settings, or per field: created_at = serializers.DateTimeField(format='%Y-%m-%d'). DRF respects Django's USE_TZ = True setting — when enabled, datetimes are converted to UTC before serialization and the output includes a Z suffix. For consistent behavior across timezones, always enable USE_TZ and set TIME_ZONE in Django settings.

Further reading and primary sources

  • DRF Serializers docsComplete reference for Serializer, ModelSerializer, field types, validation, and nested relationships
  • DRF ViewSets docsViewSet, ModelViewSet, and ReadOnlyModelViewSet with router configuration and custom actions
  • Django docsOfficial Django documentation covering models, ORM queries, settings, and the request/response cycle