To build an API with Django REST Framework (DRF), install Django and DRF, model a resource, create a serializer, expose a ModelViewSet, and register it with a router. That path provides CRUD operations and a browsable interface with little code. A production-ready API, however, also needs validation, authentication, permissions, pagination, tests, and predictable errors.

This guide builds an executable task API. It uses SQLite locally and token authentication, while keeping boundaries clear enough to replace either later. If Django itself is unfamiliar, start with the complete Django guide.

Set up the project

Create an isolated environment, install dependencies, and initialize the project:

python -m venv .venv
## Linux/macOS: source .venv/bin/activate
## Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install Django djangorestframework
django-admin startproject config .
python manage.py startapp tasks

Virtual environments prevent dependency collisions; see Python venv for the underlying workflow. Add the applications and defaults to config/settings.py:

INSTALLED_APPS += [  # Preserve the applications generated by Django.
    "rest_framework",
    "rest_framework.authtoken",
    "tasks",
]

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

Secure global defaults prevent a new view from becoming public by accident. Authentication establishes identity; permission determines whether that identity may perform an action. They solve different problems.

Model and migrations

Represent ownership explicitly in tasks/models.py:

from django.conf import settings
from django.db import models


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

    class Meta:
        ordering = ["-created_at"]

    def __str__(self) -> str:
        return self.title

Run python manage.py makemigrations and python manage.py migrate. The owner relationship forms a tenant boundary when every query applies it. For deployment, evaluate Python and PostgreSQL and add indexes based on measured queries rather than assumptions.

Serializer and validation

A serializer converts model instances to renderable primitives, validates input, and controls writable fields. Create tasks/serializers.py:

from rest_framework import serializers

from .models import Task


class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ["id", "title", "completed", "created_at"]
        read_only_fields = ["id", "created_at"]

    def validate_title(self, value: str) -> str:
        title = value.strip()
        if len(title) < 3:
            raise serializers.ValidationError(
                "The title must contain at least 3 characters."
            )
        return title

Do not expose owner as writable input: the server derives it from request.user. Avoid fields = "__all__" because a sensitive model field added later might enter the public contract without review. Explicit fields are both documentation and an allowlist.

ViewSet, isolation, and routes

Create tasks/views.py:

from rest_framework import viewsets

from .models import Task
from .serializers import TaskSerializer


class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer

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

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

get_queryset() prevents both listing and retrieving another user's records by ID. Filtering only the list endpoint would leave a broken object-level authorization path. Register routes in config/urls.py:

from django.contrib import admin
from django.urls import include, path
from rest_framework.authtoken.views import obtain_auth_token
from rest_framework.routers import DefaultRouter

from tasks.views import TaskViewSet

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
    path("api/token/", obtain_auth_token, name="api-token"),
]

The router generates GET/POST /api/tasks/ and GET/PUT/PATCH/DELETE /api/tasks/{id}/. It also keeps URL naming consistent, which matters when tests and clients reverse routes.

Authenticate and exercise the endpoint

Create a user with python manage.py createsuperuser, run python manage.py runserver, and request a token:

curl -X POST http://127.0.0.1:8000/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"local-password"}'

curl -X POST http://127.0.0.1:8000/api/tasks/ \
  -H "Authorization: Token YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Review the API"}'

In PowerShell, call curl.exe explicitly and use the backtick for continuation:

curl.exe -X POST http://127.0.0.1:8000/api/token/ `
  -H "Content-Type: application/json" `
  -d '{"username":"admin","password":"local-password"}'

curl.exe -X POST http://127.0.0.1:8000/api/tasks/ `
  -H "Authorization: Token YOUR_TOKEN" `
  -H "Content-Type: application/json" `
  -d '{"title":"Review the API"}'

Use example credentials only in a local environment. Tokens must travel over HTTPS, stay out of source control and logs, and support revocation. Built-in token authentication is intentionally simple. Expiration, rotation, delegated clients, or scopes may require OAuth2 or another design. The official DRF authentication guide explains the available mechanisms.

Automated API tests

Tests should prove authentication, validation, and isolation. Add tasks/tests.py:

from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APITestCase

from .models import Task


class TaskApiTests(APITestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(
            username="ana", password="strong-local-password"
        )
        self.client.force_authenticate(self.user)

    def test_create_task_assigns_owner(self):
        response = self.client.post("/api/tasks/", {"title": "Test endpoint"})
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertTrue(Task.objects.filter(owner=self.user).exists())

    def test_rejects_short_title(self):
        response = self.client.post("/api/tasks/", {"title": "x"})
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

Run python manage.py test. Add cases for missing credentials, another user's object ID, pagination, and forbidden methods. The pytest testing guide is useful if the project adopts pytest.

Errors, performance, and production practices

DRF renders ValidationError as a structured 400 response. Do not catch every Exception and return 200, and never return tracebacks to clients. Use 401 for missing or invalid authentication, 403 for an authenticated identity lacking permission, 404 for an unavailable resource, and 400 for invalid input. Log a correlation identifier and internal context without passwords, tokens, or full sensitive payloads. The Python logging guide covers structured diagnostic context.

Prevent N+1 queries with select_related() and prefetch_related() when serializers traverse relationships. Pagination bounds each response, but production also needs throttling, reverse-proxy body limits, and timeouts. Version a contract before making incompatible changes for external consumers. Restrict CORS to required origins; CORS is a browser rule, not authentication.

Deploy with DEBUG=False, a secret supplied through the environment, narrow ALLOWED_HOSTS, HTTPS, secure cookies where applicable, and a production application server. Run Django's deployment checks in CI. The Django deployment checklist documents the relevant settings.

Official sources

Frequently asked questions

Is a serializer the same as a Django form?

No. Both validate values, but serializers represent API resources, negotiate renderable formats, and create or update objects from request payloads.

Should I choose ViewSet or APIView?

Use ModelViewSet for conventional resource CRUD. Choose APIView when an endpoint models an operation that does not naturally fit CRUD semantics.

Is TokenAuthentication enough for production?

It can be for a simple system with HTTPS, secure storage, and revocation. Expiration, token rotation, delegated authorization, and scopes call for a more complete mechanism.

How do I stop users from seeing each other's data?

Filter every queryset by the authenticated identity and test list and detail endpoints. Add object permissions for richer policy, but do not omit the base query isolation.

Conclusion

A reliable DRF API uses explicit boundaries: serializers control input and output, querysets enforce ownership, authentication identifies callers, and permissions authorize behavior. Compact CRUD code is only the starting point. Closed defaults, validation, pagination, isolation tests, coherent errors, and production configuration let the project grow without turning convenience into data exposure.