DEV Community

Building Your First API with Django REST Framework (A Tutorial I Wish I Had When I Started)

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
Enter fullscreen mode Exit fullscreen mode

Start the Django project and an app:

django-admin startproject config .
python manage.py startapp tasks
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

Run the initial migration and create a superuser so you can poke around the admin later:

python manage.py migrate
python manage.py createsuperuser
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Nothing fancy — just a task tied to a user, with a status field. Migrate it:

python manage.py makemigrations
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

A couple of things worth calling out:

  • ModelSerializer auto-generates fields from the model, similar to how ModelForm works. 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 plain Serializer at 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

Run the server:

python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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),
]
Enter fullscreen mode Exit fullscreen mode

Now generate a token for your superuser (or any user) via the shell:

python manage.py shell
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

Test it with curl:

curl -H "Authorization: Token YOUR_TOKEN_HERE" http://127.0.0.1:8000/api/tasks/
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add filtering. Install django-filter and let users filter tasks by status via a query param like /api/tasks/?status=done.
  2. Add a "mark complete" action. Use DRF's @action decorator on the ViewSet to add a custom endpoint POST /api/tasks/{id}/complete/ that sets status to "done".
  3. Add nested comments. Create a Comment model related to Task, and expose it as a nested serializer so a task's JSON includes its comments.
  4. Write a test. Use rest_framework.test.APITestCase to verify that a user can't see another user's tasks.
  5. Throttle it. Add DEFAULT_THROTTLE_RATES to settings and confirm that hammering the endpoint eventually returns a 429.

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_related in get_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_page or 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-silk or APM tooling once you're past the toy-project stage — guessing at bottlenecks is a lot slower than measuring them.

Learning Resources

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)