In the first piece of this series, we learned what serializers are, why we need them, and what types of serializers you could use.

To create a clear idea of both terms, think about that: if the serializer’s whole job is translation, the view’s job is deciding what to translate, when, and what to do with the result.

This article walks through that decision-making the same way we did with serializers: starting from the bare, manual version - APIView, and watching what actually piles up once you need it for every action, before looking at what generic views and ViewsSets do to shrink that back down. Let’s get started!

Our setup

We’ll expand our database by adding two more tables to models.py: Country and PurchasedItem. PurchasedItem is nested under Purchase — that’s what related_name=“items” is for, and it’s what will let us build a route like /purchases/{id}/items/ later. Country works differently: it’s a standalone reference resource that Purchase simply points to, not something nested under it.

Additionally, we’ll query a fourth resource, ShippingStatus, from an external API - it deliberately has no local table at all. In total, that’s 3 real tables in models.py, plus one external resource with nothing behind it in our own database.

#models.py

from django.db import models

class Country(models.Model):
      name = models.TextField(blank=True, null=True)
      local_address = models.CharField(max_length=255, blank=True, null=True)
      local_code = models.CharField(max_length=255, blank=True, null=True)
      country_code = models.CharField(max_length=100, blank=True, null=True)
      local_vat=models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True )
      
class Purchase(models.Model):
      title = models.TextField(blank=True, null=True)
      city = models.CharField(max_length=255, blank=True, null=True)
      country = models.ForeignKey(Country, on_delete=models.CASCADE)
      continent = models.CharField(max_length=255, blank=True, null=True)
      purchase_date = models.DateField()
      price = models.DecimalField(max_digits=10, decimal_places=2)

class PurchasedItem(models.Model):
    purchase=models.ForeignKey(Purchase, on_delete=models.CASCADE, related_name="items")
    product_name=models.CharField(max_length=255, blank=True, null=True)
    quantity=models.PositiveIntegerField(default=1)
    unit_price=models.DecimalField(max_digits=10, decimal_places=2)

How Django ORM handles our database

As explained in our serializers post, we will need to manually write our serializers, so that the tables are in the appropriate json format:

#views.py

def serialize_purchase_by_hand(purchase):
    return {
        "id": purchase.id,
        "title": purchase.title,
        "city": purchase.city,
        "country": purchase.country_id,
        "continent": purchase.continent,
        "purchase_date": purchase.purchase_date.isoformat(),
        "price": str(purchase.price)
    }

def serialize_country_by_hand(country):
    return {
        "id": country.id,
        "name": country.name,
        "local_address": country.local_address,
        "local_code": country.local_code, 
        "country_code": country.country_code, 
        "local_vat": str(country.local_vat)
    }
    

def serialize_purchased_item_by_hand(purchased_item):
    return {
        "id": purchased_item.id, 
        "purchase": purchased_item.purchase_id, 
        "product_name": purchased_item.product_name, 
        "quantity": purchased_item.quantity, 
        "unit_price": str(purchased_item.unit_price)
    }

Two things repeat across all three functions, and both come straight from what we already know about the ORM: a foreign key isn’t a JSON-safe value any more than a datetime.date is, and a DecimalField needs the same careful treatment price did in the last article.

purchase.country and purchased_item.purchase both return real model instances - a Country object, a Purchase object - not something json.dumps() can touch. The fix is the same for both: use country_id/purchase_id instead of country/purchase. That’s not a workaround - Django always creates that raw foreign-key column alongside the relationship itself, whether you use it or not, so reaching for it directly is the normal way to get a JSON-safe value out of a relationship.

local_vat and unit_price get str(), for the exact reason price did before: both are DecimalFields, and str() is the only one of the three obvious conversions (str, float, int) that doesn’t quietly throw away information. float() rounds; int() - the mistake in an earlier draft of this - drops the decimal places entirely. A VAT rate serialized as 19 instead of 19.99 isn’t a formatting quirk, it’s wrong data.

Three functions in, and the pattern from the last article is already repeating: every field named by hand, every conversion field-type-specific, and this is only three models. That repetition is exactly what motivated ModelSerializer last time - worth keeping in mind as we move from “how do we shape the data” to “how do we serve it”.

Serving the data to the frontend

A dict is JSON-safe now, but isn’t an HTTP response yet - that’s the last step, and it’s plain Django, no DRF involved at all:

#views.py

from django.http import JsonResponse


def country_list(request):
    countries=Country.objects.all()
    data= [serialize_country_by_hand(c) for c in countries] 
    return JsonResponse({"results": data})
    
def purchaseditem_detail(request, pk):
    item= PurchasedItem.objects.get(pk=pk)
    data=serialize_purchased_item_by_hand(item)
    return JsonResponse(data)
    
    
def purchase_list(request):
    purchases = Purchase.objects.all()
    data = [serialize_purchase_by_hand(p) for p in purchases]  
    return JsonResponse({"results": data})

country_list and purchase_list follow the exact shape - both are standalone resources, so both make sense as a full list. purchaseditem_detail breaks that pattern on purpose: PurchasedItem is nested under Purchase, so a bare list of every item across every purchase isn’t a very useful thing to expose on its own - looking one up by its pk, the same way you would look up a single Purchase, is the shape that actually matches how the data is used.

Three tables, three views, but not three identical views - the shape of each one follows from what the table actually represents.

What each table actually needs

Serializers shape the data; views decide what happens with it - and just like Country, Purchase, and PurchasedItem needed different amounts of hand-written serialization earlier, they need different amounts of view logic too. A read-only reference table isn’t going to need the same code as a table with full CRUD.

The next two sections make that concrete: first the honest cost of writing every method by hand for all three tables, then what changes once each one gets matched to the DRF tool actually built for its shape.

Writing it by hand - bare APIView

Same move as the hand-written serializer functions from before: before reaching for anything that automates part of this, it’s worth seeing what each table actually costs when every method is written by hand, with nothing but pure APIView:

#views.py

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status


# country table; read-only, list only
class CountryListView(APIView):
    def get(self, request):
        countries=Country.objects.all()
        serializer=CountrySerializer(countries, many=True)
        return Response(serializer.data)
  
# purchase table - full CRUD 


class PurchaseListView(APIView):
    def get(self, request):
        purchases = Purchase.objects.all()
        serializer = PurchaseSerializer(purchases, many=True)
        return Response(serializer.data)
    
    def post(self, request): 
        serializer=PurchaseSerializer(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)
        
class PurchaseDetailView(APIView):
    def get(self, request, pk):
        purchase=Purchase.objects.get(pk=pk)
        serializer = PurchaseSerializer(purchase)
        return Response(serializer.data)
    def put(self, request, pk):
        purchase=Purchase.objects.get(pk=pk)
        serializer=PurchaseSerializer(purchase, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
    
    def delete(self, request, pk):
        purchase= Purchase.objects.get(pk=pk)
        purchase.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

# PurchasedItem table - get and update only

class PurchasedItemDetailView(APIView):
    def get(self, request, pk):
        item=PurchasedItem.objects.get(pk=pk)
        serializer=PurchasedItemSerializer(item)
        return Response(serializer.data)
    def put(self, request, pk):
        item=PurchasedItem.objects.get(pk=pk)
        serializer=PurchasedItemSerializer(item, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Three tables, four classes, eight methods total - and every single one follows the same shape: fetch the object or queryset, hand it to a serializer, check is_valid() if it’s a write, return a Response with the right status code. Country needed one of these. Purchase needed five. Nothing about the difference is visible in bare APIView - you write exactly as much code as the table needs, by hand, every time.

Matching each table to the right tool

Bare APIView treats every table the same way - you write every method by hand, whether the table needs one of them or five. But Country, Purchase, and PurchasedItem don’t actually need the same thing, and DRF has a tool shaped for each case.

#views.py

from rest_framework import generics
from rest_framework import viewsets
# Country table 

class CountryListView(generics.ListAPIView):
    queryset = Country.objects.all()
    serializer_class = CountrySerializer
    
    
# Purchase table - full CRUD 

class PurchaseViewSet(viewsets.ModelViewSet):
    queryset = Purchase.objects.all()
    serializer_class = PurchaseSerializer



# PurchasedItem - get and update only

class PurchasedItemDetailView(generics.RetrieveUpdateAPIView):
    queryset = PurchasedItem.objects.all()
    serializer_class = PurchasedItemSerializer

Country table only ever needed get(). ListAPIView already implements that for you - no method body at all, just telling it which queryset and which serializer to use.

For Purchase table, we use class PurchaseViewSet(viewsets.ModelViewSet) which replaces PurchaseListView and PurchaseDetailView from the bare-APIView version - list, create, retrieve, update, and delete, all five from two lines of code.

PurchasedItem table gets no create, and no delete. generics.RetrieveUpdateAPIView gives exactly the two operations this particular table needed, nothing extra.

A quick aside: function-based views

Everything so far has been class-based, but DRF also supports plain functions, decorated instead of subclassed:

#views.py

from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(['GET'])
def country_list(request):
    countries= Country.objects.all()
    serializer=CountrySerializer(countries, many=True)
    return Response(serializer.data)

This does exactly the same work as the bare-APIView version of CountryListView from earlier - not the ListAPIView version. That’s the actual point worth noticing: @api_view only wraps a function so DRF’s request parsing, content negotiation, and Response handling apply, and it restricts which HTTP methods are allowed via the list you pass in ([‘GET’] here). It doesn’t give you anything like ListAPIView’s free get() - there’s no equivalent shortcut for function-based views, because generics and ModelViewSet work by inheritance, and a decorated function has nothing to inherit from.

That’s the real reason this series has stuck with classes, not just a style preference: every reduction in boilerplate we have seen so far - one line instead of a method body, one class instead of two - only exists because APIView, generics, and ViewSet are all classes built to be extended. Function-based views stay exactly as verbose as bare APIView, forever, no matter how many models you have.

One thing ViewSets can do that generics can’t

Every reduction so far has just been “less code for the same operations”. @action is different - it’s a capability generics don’t have at all:

#views.py

from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response


class PurchaseViewSet(viewsets.ModelViewSet):
    queryset=Purchase.objects.all()
    serializer_class = PurchaseSerializer
    
    @action(detail=True, methods=['post'])
    def refund(self, request, pk=None):
        purchase= self.get_object()
        return Response({"status": "refunded", "purchase": purchase.id})

detail=True means this action operates on one Purchase, not the whole collection - same idea as retrieve/update/delete, and self.get_object()(inherited from the same generic machinery ModelViewSet is built on) fetches it using the pk from the URL, exactly like the bare-APIView version did by hand earlier. The part that actually matters: a router(which we will cover in the next article) sees @action and generates a new URL for it automatically - something like /purchases/{id}/refund - without you writing a single line of URL configuration.

Try to do this with RetrieveUpdateAPIView or any generic, and there’s no equivalent. Generics only give you the fixed set of operations they were built for; adding “refund” means writing a whole separate view and writing its URL by hand - which puts you right back where bare APIView started. This is the actual argument for ViewSet, not just “fewer lines for the same five methods.”

Where this leaves us

Every view here still needs one thing we haven’t touched: a URL pointing to it. path() works fine with CountryListView, PurchaseListView, and PurchaseDetailView - one class, one URL, same as it’s always been in Django. But PurchaseViewSet doesn’t map to a single URL the way those do - list, create, retrieve, update, delete, and refund all need their own routes, generated from one class. That’s what a router does, and it’s the next article.

One thing we deliberately left out: ShippingStatus, the one table with no local model at all. We touched why bare APIView is its permanent home, not a temporary one, but the real version - actually calling an external service - deserves its own space. That’s coming when we build the full project these two articles have been leading to.