Building Your First API with Django REST Framework (A Tutorial I Wish I Had When I Started)
I still remember the first time I tried to turn a Django app into an API. I'd built a perfectly nice Django project with models, views, templates — the works. Then someone asked, "can the mobile team hit this as JSON?" and I panicked a little.
Abstract illustration of interconnected API nodes and endpoints, shown as glowing dots linked by curved lines on a dark navy background with teal and orange accents, representing data flow in a REST API.
Django REST Framework (DRF) is the tool that saved me, and it's still what I reach for whenever a Django project needs an API layer. This tutorial walks through building a small but real API — a task manager — from a blank virtualenv to a working, authenticated, paginated endpoint. We'll write code, break things on purpose, and fix them, because that's how this stuff actually sticks.
By the end you'll have:
- A working Django + DRF project
- Models, serializers, and viewsets
- Token-based authentication and permissions
- A handful of exercises to do on your own
- A troubleshooting section for the errors you will hit
Let's get into it.
Prerequisites
You should be comfortable with:
- Basic Python
- Basic Django (models, migrations, the admin panel)
- Making HTTP requests (curl, Postman, or Insomnia — whatever you like)
You don't need to know DRF at all. That's the point.
Step 1: Project Setup
Create a virtual environment and install what we need:
mkdir drf-tasks && cd drf-tasks
python3 -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install django djangorestframework
Start the Django project and an app:
django-admin startproject config .
python manage.py startapp tasks
Add both rest_framework and your new tasks app to INSTALLED_APPS in config/settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken", # we'll use this later for auth
"tasks",
]
Run the initial migration and create a superuser so you can poke around the admin later:
python manage.py migrate
python manage.py createsuperuser
Step 2: The Model
In tasks/models.py:
from django.db import models
from django.contrib.auth.models import User
class Task(models.Model):
STATUS_CHOICES = [
("todo", "To Do"),
("doing", "In Progress"),
("done", "Done"),
]
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tasks")
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="todo")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
Nothing fancy — just a task tied to a user, with a status field. Migrate it:
python manage.py makemigrations
python manage.py migrate
Step 3: Your First Serializer
This is the part that trips people up coming from plain Django, so let's slow down here. A serializer's job is to convert model instances into something JSON can represent (and back again on the way in, with validation).
In tasks/serializers.py:
from rest_framework import serializers
from .models import Task
class TaskSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source="owner.username")
class Meta:
model = Task
fields = ["id", "title", "description", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["created_at", "updated_at"]
A couple of things worth calling out:
-
ModelSerializerauto-generates fields from the model, similar to howModelFormworks. It's a huge time saver, but it can also hide what's actually happening — I'd recommend writing at least one serializer by hand with plainSerializerat some point, just so you understand the machinery underneath. -
owner = serializers.ReadOnlyField(source="owner.username")is a small trick: instead of showing the owner's numeric ID, we show their username, and it can't be set through the API (it gets assigned in the view instead).
Step 4: Views — Function-Based First, Then ViewSets
DRF gives you a few ways to write views. I think it's worth seeing the "manual" version before jumping to the shortcuts, so you understand what they're shortcuts for.
The manual way
# tasks/views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import Task
from .serializers import TaskSerializer
@api_view(["GET", "POST"])
def task_list(request):
if request.method == "GET":
tasks = Task.objects.filter(owner=request.user)
serializer = TaskSerializer(tasks, many=True)
return Response(serializer.data)
if request.method == "POST":
serializer = TaskSerializer(data=request.data)
if serializer.is_valid():
serializer.save(owner=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
This works fine. But once you add detail views, update, delete, filtering, pagination... you end up rewriting the same boilerplate over and over. That's exactly what ViewSets exist to remove.
The ViewSet way (what you'll actually use day to day)
Replace tasks/views.py with:
from rest_framework import viewsets, permissions
from .models import Task
from .serializers import TaskSerializer
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# only show the logged-in user's own tasks
return Task.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
One class, and you get list, retrieve, create, update, partial_update, and destroy — all wired up. get_queryset scopes it to the current user, and perform_create attaches the owner automatically.
Step 5: URLs and Routers
DRF's DefaultRouter generates all the standard URL patterns for a ViewSet automatically. In tasks/urls.py:
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet
router = DefaultRouter()
router.register("tasks", TaskViewSet, basename="task")
urlpatterns = router.urls
And hook it into config/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("tasks.urls")),
path("api-auth/", include("rest_framework.urls")), # browsable API login
]
Run the server:
python manage.py runserver
Visit http://127.0.0.1:8000/api/tasks/ in your browser. If you're logged in via the admin, DRF's browsable API will show you a working form right there in the page — this is one of my favorite things about the framework. It's genuinely useful for manual testing, not just a demo gimmick.
Step 6: Authentication with Tokens
Session auth (cookies) is fine for browser testing, but for a real API consumed by mobile apps or other services, token auth is more common. We already added rest_framework.authtoken to INSTALLED_APPS, so migrate it:
python manage.py migrate
Add token settings to config/settings.py:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
}
Add a login endpoint that returns a token in config/urls.py:
from rest_framework.authtoken.views import obtain_auth_token
urlpatterns += [
path("api-token-auth/", obtain_auth_token),
]
Now generate a token for your superuser (or any user) via the shell:
python manage.py shell
from django.contrib.auth.models import User
from rest_framework.authtoken.models import Token
user = User.objects.get(username="your_username")
token, _ = Token.objects.get_or_create(user=user)
print(token.key)
Test it with curl:
curl -H "Authorization: Token YOUR_TOKEN_HERE" http://127.0.0.1:8000/api/tasks/
Create a task:
curl -X POST http://127.0.0.1:8000/api/tasks/ \
-H "Authorization: Token YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"title": "Write DRF tutorial", "description": "First draft", "status": "doing"}'
If that returns your new task as JSON with a 201 status, everything's wired up correctly.
A Working Example on GitHub
If you'd rather read code than type it out, the DRF maintainers keep a well-documented example project in the official repo, and it's worth cloning locally to poke around:
git clone https://github.com/encode/django-rest-framework.git
Inside docs/tutorial/ you'll find the exact "quickstart"-style project the official docs walk through, which pairs well with everything above — seeing two slightly different takes on the same problem (theirs vs. this one) tends to make the concepts click faster than reading either alone.
Practical Exercises
Don't just read this — go do these. Seriously, close the tab-switching and actually type these out:
-
Add filtering. Install
django-filterand let users filter tasks bystatusvia a query param like/api/tasks/?status=done. -
Add a "mark complete" action. Use DRF's
@actiondecorator on the ViewSet to add a custom endpointPOST /api/tasks/{id}/complete/that sets status to"done". -
Add nested comments. Create a
Commentmodel related toTask, and expose it as a nested serializer so a task's JSON includes its comments. -
Write a test. Use
rest_framework.test.APITestCaseto verify that a user can't see another user's tasks. -
Throttle it. Add
DEFAULT_THROTTLE_RATESto settings and confirm that hammering the endpoint eventually returns a429.
If you get stuck on any of these, the DRF docs (linked below) cover every one of them directly.
Troubleshooting: Errors You'll Definitely Hit
AssertionError: 'TaskSerializer' should either include a 'fields' attribute...
You forgot the fields (or exclude) attribute in your serializer's Meta class. DRF refuses to guess.
Got AttributeError when attempting to get a value for field 'owner'
Usually means your queryset or the object you're serializing doesn't actually have an owner attribute — check for typos in related field names, or that you're serializing the right model.
401 Unauthorized on every request, even with a valid token
Double check the header format. It's Authorization: Token <key>, not Bearer <key> — that's a JWT convention, not DRF's default token auth. Mixing them up is probably the single most common auth bug I see.
CSRF errors when POSTing from the browsable API
This only happens with SessionAuthentication. Either log in through /api-auth/login/ first so the CSRF cookie is set, or just use token auth for programmatic requests instead.
TypeError: Object of type Decimal is not JSON serializable
This shows up when you bypass the serializer somewhere and try to return a raw queryset or model field with Response(). Always pass data through a serializer before returning it.
Pagination broke your frontend's expected array response
Once DEFAULT_PAGINATION_CLASS is set, list endpoints return an object like {"count": ..., "next": ..., "previous": ..., "results": [...]} instead of a bare array. Update your frontend to read .results, or override pagination per-view if you need the old shape.
Best Practices
-
Keep serializers thin. Business logic belongs in model methods or a service layer, not sprinkled across
validate_*methods. A little validation logic is fine; a 200-line serializer is a sign something needs to move. -
Use
select_related/prefetch_relatedinget_queryset. Every foreign key or reverse relation you access in a serializer is a potential N+1 query. Check your SQL logs. -
Version your API early. Even
api/v1/in the URL costs you nothing now and saves real pain later when you need to change a response shape. -
Don't expose your models 1:1 by default. Just because a field exists on the model doesn't mean it belongs in the API response. Be deliberate about
fields. - Write permission classes, don't hardcode checks in views. It keeps authorization logic reusable and testable in isolation.
Performance Tips
- Watch your query count. Django Debug Toolbar (works fine alongside DRF) will show you exactly how many queries a single API call triggers. If a list endpoint is running one query per row, that's your N+1 problem.
- Paginate everything that can grow. An unpaginated list endpoint is a slow-motion outage waiting to happen once your table has real data in it.
-
Cache serializer-heavy responses when the data doesn't change often.
cache_pageor a manual cache key based on the queryset's last-updated timestamp both work well. -
Use
.only()or.defer()on querysets when a serializer only needs a handful of fields from a wide table. -
Consider
django-silkor APM tooling once you're past the toy-project stage — guessing at bottlenecks is a lot slower than measuring them.
Learning Resources
- Official DRF documentation — genuinely one of the better-written docs sets in the Python ecosystem, worth reading start to finish once.
-
Django REST Framework GitHub repo — read the source for
ModelSerializerat some point; it demystifies a lot. - Two Scoops of Django — not DRF-specific, but the Django patterns advice generalizes well to API projects.
- Django Filter docs — for the filtering exercise above.
- Real Python's DRF articles — good for reinforcing concepts from a different angle.
Wrapping Up
That's a full loop: model → serializer → viewset → router → auth → a handful of real bugs fixed along the way. The core mental model to hold onto is that a serializer is just a two-way translator between Python objects and JSON, and a ViewSet is just a bundle of the CRUD views you'd otherwise write by hand.
If you build the exercises above, you'll have touched filtering, custom actions, nested serialization, testing, and throttling — which honestly covers most of what shows up in real-world DRF projects.
Drop questions in the comments if you get stuck anywhere — I'll try to answer what I can.
I teach Python full stack courses in BTM Layout, Bangalore, and this kind of hands-on, build-it-yourself approach is exactly how I run my classes. If you're looking for the best Python full stack course in Bangalore and want to actually build projects like this one rather than just watch slides, feel free to reach out.
Top comments (0)