PythonMastery
intermediate 24 min read · lesson 11 of 12 in Web Frameworks

Django REST Framework

1 · The lesson

read

Django on its own is very good at server-rendered pages and the admin. For JSON APIs (the kind your React SPA, your mobile app or your other services use), the standard choice is Django REST Framework (DRF). It is a third-party package that turns Django models into a REST API. It gives you serializers, viewsets, routers, permissions, pagination, throttling and OpenAPI schema generation, all added to the framework you already use.

This lesson covers the four pieces that make up almost every DRF API: serializers, views/viewsets, permissions and pagination. It also answers the question "when should I pick Django+DRF over FastAPI?".

Run locally with pip install django and python manage.py runserver after pip install djangorestframework. Browse the live API at /api/. DRF imports fine in the browser but has no port to listen on, so the API itself has to run on your machine.


1. Why DRF (And When Not To)

DRF is the usual way to add a REST API to a Django project. It expects that you already have models, sessions, auth and an ORM set up, and it builds the API layer on top with sensible defaults.

bash
pip install djangorestframework
python
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "blog",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 25,
}

Pick DRF when:

  • You already have a Django app and want to expose its models as an API without changing to a different framework.
  • You want one codebase serving both server-rendered pages (admin, internal dashboards) and JSON endpoints.
  • You need DRF's permissions, browsable API, and ecosystem (django-filter, drf-spectacular, dj-rest-auth), which are all mature.

Pick FastAPI instead when:

  • The project is new, API-only, and doesn't need Django's admin or ORM.
  • Async I/O is central to the app: DRF is mainly synchronous, while FastAPI is async-native.
  • You want Pydantic everywhere and the minimum framework footprint.

The full decision tree is in web-which-framework.


2. Serializers: Like Pydantic, but for Django Models

Serializers convert data in DRF: Django model instance ↔ JSON (and JSON ↔ validated Python dict for incoming requests). The closest thing in FastAPI is a Pydantic model. Both validate data, but DRF's serializers can also create and update model instances.

In most cases, you use ModelSerializer:

python
# blog/serializers.py
from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.username", read_only=True)

    class Meta:
        model = Article
        fields = ["id", "title", "slug", "body", "status", "author", "author_name", "created_at"]
        read_only_fields = ["id", "created_at"]

What's happening:

  • ModelSerializer inspects the model and generates the right field types automatically.
  • fields is the explicit allow-list of what's exposed. Use this. Never use fields = "__all__" on a user-facing model: if someone adds a sensitive field to the model later, the API exposes it without any warning.
  • read_only_fields blocks writes: clients can read these fields but can't change them.
  • source="author.username" is the dotted path for derived/related values, and the field shows up as author_name in JSON.

For data that doesn't map to a model (a search request, a report response), use the plain Serializer:

python
class TaskSearchSerializer(serializers.Serializer):
    query = serializers.CharField(max_length=200)
    status = serializers.ChoiceField(choices=["all", "done", "open"], default="all")
    limit = serializers.IntegerField(min_value=1, max_value=100, default=20)
+ setup added so this can run · defines serializers
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

serializers = _AutoMock('serializers')

Validate any payload with the same two steps:

python
serializer = ArticleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)        # 400 with field errors if invalid
article = serializer.save()                       # creates the model instance
+ setup added so this can run · defines ArticleSerializer, request
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def ArticleSerializer(*_a, **_kw):
    print('-> ArticleSerializer() called')
    return _AutoMock('ArticleSerializer()')
request = _AutoMock('request')

raise_exception=True is the simple and correct option. DRF turns validation errors into a 400 response with structured {"field": ["message"]} JSON.


3. Function-Based API Views: @api_view

The simplest DRF view: a function with the @api_view decorator and DRF's Response (which handles content negotiation: JSON, browsable HTML, etc.).

python
# blog/views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from .models import Article
from .serializers import ArticleSerializer

@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def article_list(request):
    if request.method == "GET":
        qs = Article.objects.select_related("author").order_by("-created_at")
        return Response(ArticleSerializer(qs, many=True).data)

    serializer = ArticleSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    serializer.save(author=request.user)
    return Response(serializer.data, status=status.HTTP_201_CREATED)

@api_view is fine for one-off endpoints. For full CRUD on a resource, ViewSets need much less code.


4. ViewSets and Routers: The DRY Approach

A ModelViewSet puts the whole list/retrieve/create/update/partial_update/destroy CRUD set into one class. A Router connects it to URLs automatically.

python
# blog/views.py
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.select_related("author").order_by("-created_at")
    serializer_class = ArticleSerializer

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
python
# blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import ArticleViewSet

router = DefaultRouter()
router.register("articles", ArticleViewSet, basename="article")

urlpatterns = router.urls
python
# mysite/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("blog.urls")),
]
+ setup added so this can run · defines path, include, admin
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')
def include(*_a, **_kw):
    print('-> include() called')
    return _AutoMock('include()')
admin = _AutoMock('admin')

What you get from those ~10 lines:

MethodURLAction
GET/api/articles/list
POST/api/articles/create
GET/api/articles/{id}/retrieve
PUT/api/articles/{id}/full update
PATCH/api/articles/{id}/partial update
DELETE/api/articles/{id}/destroy

For custom actions (anything outside the standard CRUD set), decorate a method with @action:

python
from rest_framework.decorators import action

class ArticleViewSet(viewsets.ModelViewSet):
    # ...

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.status = "published"
        article.save(update_fields=["status"])
        return Response({"status": article.status})
+ setup added so this can run · defines viewsets, Response
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
def Response(*_a, **_kw):
    print('-> Response() called')
    return _AutoMock('Response()')

Now POST /api/articles/42/publish/ changes the status. With custom actions, you can add endpoints that don't fit strict REST rules, and still keep them in the viewset.

For finer control: ModelViewSet → all actions. ReadOnlyModelViewSet → list + retrieve only. Or combine mixins (ListModelMixin, CreateModelMixin, GenericViewSet) to get exactly the actions you want.


5. Permissions: Who Can Do What

DRF separates authentication (who are you?) from permissions (are you allowed to do this?). Authentication runs first, and permissions check on every request.

Built-in permission classes:

  • AllowAny: anyone can access. Use for public read-only endpoints.
  • IsAuthenticated: logged-in users only.
  • IsAdminUser: only users with user.is_staff.
  • IsAuthenticatedOrReadOnly: anyone can GET, but only logged-in users can write.

Set a global default in settings.py (recommended, because then every view is secure by default) and override it per view:

python
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
+ setup added so this can run · defines viewsets, ArticleSerializer, permissions, Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
ArticleSerializer = _AutoMock('ArticleSerializer')
permissions = _AutoMock('permissions')
Article = _AutoMock('Article')

For "author can edit their own article, others can read", write a BasePermission subclass:

python
from rest_framework import permissions

class IsAuthorOrReadOnly(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:    # GET, HEAD, OPTIONS
            return True
        return obj.author == request.user

has_permission is the view-level check (runs before the object is fetched), and has_object_permission is the per-object check (runs after get_object()). A custom permission class is better than repeating if request.user != article.author: raise PermissionDenied in every view.

For JWT-based auth (token in the Authorization: Bearer ... header), see auth-jwt. The DRF integration is djangorestframework-simplejwt.


6. Pagination

If your list endpoint returns 10,000 rows, your mobile client becomes slow or crashes. Configure pagination globally:

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 25,
}

The three built-in styles:

ClassURL patternBest for
PageNumberPagination?page=2Most cases, familiar to clients
LimitOffsetPagination?limit=10&offset=20When clients want fine-grained windows
CursorPagination?cursor=cD0yMDI2L...Large datasets with stable ordering, no COUNT(*)

CursorPagination is what you want for a large "infinite scroll" feed. It doesn't have to count all rows, and items don't move between pages when new rows arrive.

Response shape (PageNumberPagination):

json
{
  "count": 142,
  "next": "/api/articles/?page=3",
  "previous": "/api/articles/?page=1",
  "results": [ /* 25 articles */ ]
}

Forgetting to paginate is a very common DRF performance bug. Set the default once in settings.py, and you won't ship a list endpoint without pagination by accident.


7. Filtering, Searching, Ordering

Combine DRF with django-filter and SearchFilter for declarative query-param filtering:

bash
pip install django-filter
python
# settings.py
INSTALLED_APPS = [..., "django_filters"]

REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}
python
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    filterset_fields = ["status", "author"]
    search_fields = ["title", "body"]
    ordering_fields = ["created_at", "views"]
    ordering = ["-created_at"]
+ setup added so this can run · defines viewsets, ArticleSerializer, Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
ArticleSerializer = _AutoMock('ArticleSerializer')
Article = _AutoMock('Article')

A client can now request /api/articles/?status=published&author=4&search=django&ordering=-views, and you didn't write the code for any of those filters. For complex filtering rules, define a FilterSet class.


8. The Browsable API

This is DRF's most useful feature during development: every API endpoint also renders an HTML page when you open it in a browser. You see the JSON response, a form to POST new data, and links to related resources. All of it is generated from your serializer.

python
http://localhost:8000/api/articles/

In a real browser you get a styled page with a GET tab, a POST form (validated against the serializer), and authentication. It's the fastest way to show an API to a colleague or a tester, and you don't need to set up Postman. Disable it in production by removing BrowsableAPIRenderer from DEFAULT_RENDERER_CLASSES.


9. Throttling

Throttling stops one client from sending too many requests to your API. Throttle classes count requests per time window per identity (anon by IP, auth by user).

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "20/min",
        "user": "1000/hour",
    },
}

For per-view throttles, set throttle_classes on the view. For per-endpoint limits (100/hour on the expensive report endpoint), subclass UserRateThrottle with a custom scope.

Production note: the default throttle backend uses Django's cache framework. With the in-memory cache, each process counts separately. So with 4 gunicorn workers, a user can get up to 4× their quota. Use Redis or memcached in production.


10. Versioning

Once an API is public, if you break it without changing the version number, your users stop trusting it. DRF supports URL versioning (/api/v1/articles/), accept-header versioning, and namespace versioning. URL versioning is the easiest to understand:

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}

# urls.py
urlpatterns = [
    path("api/<version>/", include("blog.urls")),
]
+ setup added so this can run · defines path, include
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')
def include(*_a, **_kw):
    print('-> include() called')
    return _AutoMock('include()')

request.version is then "v1" or "v2" inside the view. Choose the behaviour based on it, or use completely different serializers/views for v2.


11. Schema and Docs: drf-spectacular

FastAPI's automatic OpenAPI docs are its main feature. DRF's equivalent is drf-spectacular:

bash
pip install drf-spectacular
python
# settings.py
INSTALLED_APPS = [..., "drf_spectacular"]
REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {"TITLE": "Blog API", "VERSION": "1.0.0"}
python
# urls.py
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

urlpatterns += [
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
]
+ setup added so this can run · defines urlpatterns, path
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

urlpatterns = 1
def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')

Visit /api/docs/ and you get a Swagger UI generated from your viewsets and serializers. It needs more setup than FastAPI's, but with annotations (@extend_schema(...)) you can get docs of the same quality.


12. Testing

DRF includes APIClient, a thin wrapper around Django's test client that handles JSON, auth and content types properly.

python
from rest_framework.test import APITestCase
from django.contrib.auth.models import User

class ArticleAPITests(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="surya", password="x")
        self.client.force_authenticate(user=self.user)

    def test_create_article(self):
        response = self.client.post("/api/articles/", {
            "title": "Hello",
            "slug": "hello",
            "body": "Body text",
            "status": "draft",
            "author": self.user.id,
        }, format="json")
        self.assertEqual(response.status_code, 201)
        self.assertEqual(response.data["title"], "Hello")

force_authenticate skips the login step, because here you're testing the view, not the login. For end-to-end tests, call the real /login/ endpoint and use the session or token it returns.


13. When to Use DRF vs FastAPI

A practical comparison for an API-only project:

ConcernDjango + DRFFastAPI
Setup time on greenfieldLonger (Django project setup)Shorter (one file)
Async supportPartial (DRF is mainly synchronous)Native
OpenAPI docsdrf-spectacular (good)Built-in (excellent)
ValidationSerializersPydantic
ORMDjango ORM (great)Bring your own (SQLAlchemy)
Admin UIBuilt inNone
AuthBuilt-in user model + DRF auth classesBring your own
Ecosystem ageOlder (since 2011), very matureNewer (since 2018), growing fast
Best fitDjango monolith with API endpoints, internal tools, content sitesAPI-only services, async-heavy, microservices

Both are very good. The choice usually depends on what else you need. If you need an admin UI, server-rendered pages or an existing Django codebase, pick DRF. If you need none of these, pick FastAPI.


14. Common Mistakes

1. Returning model instances directly without serializing. return Response(article) will fail or expose attributes you didn't mean to send. Always pass through a serializer: Response(ArticleSerializer(article).data).

2. fields = "__all__". It is convenient, until you add a secret_token field to the model and publish it by accident. Use explicit allow-lists.

3. No pagination on list endpoints. It works fine in dev with 10 rows, but fails in production with 100,000. Set the default in settings.py so that every list endpoint is paginated.

4. N+1 in viewset listings. This is the same problem as in the previous lesson. Apply select_related / prefetch_related to the queryset on the viewset.

5. Writing permission checks inline in every view. if request.user != article.author: raise PermissionDenied is repeated everywhere. Put the check in one BasePermission subclass and assign it once.

6. Mixing session-auth and token-auth without thinking about CSRF. SessionAuthentication enforces CSRF, but TokenAuthentication doesn't. If you allow both, browser clients still need to send CSRF tokens for session-auth POSTs.

7. Trusting the client on author. If the client can set author=42 in the request body, they can post as someone else. Always set the owner server-side in perform_create: serializer.save(author=self.request.user).

8. Disabling the browsable API in dev. It saves a lot of time. Leave it on locally, and disable it in production.


🎯 Your Turn: A Tasks REST API

Build a JSON API on top of the tasks app from the previous lessons. Requirements:

1. Install DRF, add "rest_framework" to INSTALLED_APPS.
2. Set DRF defaults: IsAuthenticated permission, PageNumberPagination, PAGE_SIZE=10.
3. Write a TaskSerializer (ModelSerializer) exposing id, title, done, created_at, tags. Make id and created_at read-only.
4. Build a TaskViewSet(viewsets.ModelViewSet) with full CRUD. The queryset should eager-load tags.
5. Register the viewset at /api/tasks/ via a DefaultRouter.
6. Add a custom @action for POST /api/tasks/{id}/complete/ that flips done=True.
7. Each task should belong to the user who creates it. Set this on the server in perform_create.

(For step 7 you'll need to add an owner = ForeignKey(User, on_delete=CASCADE) field to Task and migrate. Filter the viewset's queryset to Task.objects.filter(owner=self.request.user) so users only see their own tasks.)

Skeleton:

python
# tasks/serializers.py
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = [...]            # TODO 1
        read_only_fields = [...]   # TODO 2
python
# tasks/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Task
from .serializers import TaskSerializer

class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        # TODO 3: filter to self.request.user's tasks, prefetch tags
        ...

    def perform_create(self, serializer):
        # TODO 4: save with owner=self.request.user
        ...

    @action(detail=True, methods=["post"])
    def complete(self, request, pk=None):
        # TODO 5: fetch the task via self.get_object(), set done=True, save, return serialized
        ...
python
# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet

router = DefaultRouter()
# TODO 6: register

urlpatterns = router.urls
Hint 1: get_queryset vs queryset When the queryset depends on the request (e.g. filtering by self.request.user), override get_queryset(self) instead of setting the class-level queryset attribute. The class attribute is created once, at import time, so it has no access to the request.
Hint 2: perform_create perform_create receives the already-validated serializer. Call serializer.save(owner=self.request.user). Any kwargs you pass to save() override values in the validated data, and this is how you stop a client from setting owner themselves.
Show full solution
python
# tasks/models.py (add an owner field)
from django.conf import settings
from django.db import models

class Task(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    tags = models.ManyToManyField("Tag", blank=True, related_name="tasks")
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="tasks",
    )

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title
python
# tasks/serializers.py
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ["id", "title", "done", "tags", "created_at"]
        read_only_fields = ["id", "created_at"]
python
# tasks/views.py
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Task
from .serializers import TaskSerializer

class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return (
            Task.objects
            .filter(owner=self.request.user)
            .prefetch_related("tags")
        )

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

    @action(detail=True, methods=["post"])
    def complete(self, request, pk=None):
        task = self.get_object()                # 404 if not owned (queryset is filtered)
        task.done = True
        task.save(update_fields=["done"])
        return Response(self.get_serializer(task).data)
python
# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet

router = DefaultRouter()
router.register("tasks", TaskViewSet, basename="task")

urlpatterns = router.urls
python
# todo/settings.py (the new lines)
INSTALLED_APPS = [..., "rest_framework", "tasks"]

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 10,
}
python
# todo/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("tasks.urls")),
]

Run the migration and try it out:

bash
python manage.py makemigrations tasks
python manage.py migrate
python manage.py createsuperuser            # for auth in the browsable API
python manage.py runserver

Visit /api/tasks/ in a browser and you get the browsable API. Log in via the admin first to get a session cookie. POST a task. Send a POST to /api/tasks/1/complete/ to set its done flag. The list endpoint paginates after 10 entries (?page=2), and users only see their own tasks. A client can never set owner, because owner isn't in the serializer fields, and perform_create sets it anyway.

That's a real API with sensible security defaults. The serializer, the viewset and the router together are about 40 lines of code.


What You Learned

  • DRF is the standard way to expose Django models as JSON APIs. Add "rest_framework" to INSTALLED_APPS and configure defaults in REST_FRAMEWORK.
  • Serializers translate between models and JSON, plus validate incoming data. Use explicit fields allow-lists, never "__all__".
  • ViewSets + Routers give you full CRUD URLs from one class. @action decorates custom endpoints.
  • Permissions separate "are you logged in?" from "are you allowed to do this?". Centralise non-trivial rules in BasePermission subclasses.
  • Paginate every list endpoint. Set the default in settings.py so you never forget.
  • select_related / prefetch_related on the viewset's queryset: the same N+1 fix as in plain Django.
  • The browsable API is DRF's most useful feature during development. Leave it on in dev, off in prod.
  • Versioning via URL is the simplest pattern, and drf-spectacular produces OpenAPI schema and Swagger UI.
  • DRF vs FastAPI: pick Django+DRF if you have other reasons to use Django or need the admin. Pick FastAPI for new, API-only services with a lot of async work.

Next: Which Framework, When: a fair comparison of the frameworks, and a practical decision tree.

Found this useful? Share it with a friend who’s learning Python.