A RESTful web service exposes your application's data as resources (such as countries, users or orders) that clients read and change over HTTP using standard verbs — GET, POST, PUT, PATCH and DELETE — and that respond with meaningful HTTP status codes. Django REST Framework (DRF) is the de-facto toolkit for building these services on top of Django: it handles serialization, request parsing, content negotiation, authentication, permissions and browsable documentation for you.
In this guide you'll build a complete RESTful web service for a Country resource the way it is done in production today — with serializers, a ModelViewSet, a router, authentication and pagination — and you'll learn the REST conventions that make an API predictable: correct verbs, correct status codes, statelessness and clean resource URLs. We'll target Django 5.x, DRF (latest) and Python 3.12+.
If you are brand new to DRF, start with our introduction to API development with Django REST Framework for the first-principles walkthrough, then come back here to assemble the full service.
REST principles you need to get right
REST is an architectural style, not a library. A service is "RESTful" when it follows a handful of conventions consistently:
- Resources, not actions. Model nouns (
/api/countries/), not verbs (/api/getCountry/). The HTTP method expresses the action. - Use the right HTTP verb. Each verb has well-defined semantics (read, create, replace, partial update, delete).
- Return the right status code. Clients and caches rely on the status line — never return
200 OKfor an error. - Stateless requests. Every request carries everything the server needs (typically an auth token); the server keeps no per-client session state, which lets the service scale horizontally.
- Consistent representations. A resource looks the same whether you fetch one or list many, and errors share a predictable shape.
Get these right and your API becomes self-explanatory — the foundation for the rest of this tutorial.
HTTP verbs and status codes at a glance
The table below maps the CRUD operations you'll implement to their HTTP verb and the status code a well-behaved service returns. Keep it handy — most API review comments come down to a wrong verb or a wrong code.
| Operation | HTTP verb | Endpoint | Success status | Notes |
|---|---|---|---|---|
| List resources | GET |
/api/countries/ |
200 OK |
Safe, idempotent; supports pagination/filtering |
| Retrieve one | GET |
/api/countries/{id}/ |
200 OK |
404 Not Found if missing |
| Create | POST |
/api/countries/ |
201 Created |
Return the created resource + Location |
| Replace | PUT |
/api/countries/{id}/ |
200 OK |
Full update; all fields required |
| Partial update | PATCH |
/api/countries/{id}/ |
200 OK |
Only the supplied fields change |
| Delete | DELETE |
/api/countries/{id}/ |
204 No Content |
Empty body on success |
Other codes you'll meet often: 400 Bad Request (validation failed), 401 Unauthorized (no/invalid credentials), 403 Forbidden (authenticated but not allowed), 405 Method Not Allowed (verb not supported on this endpoint) and 429 Too Many Requests (throttled).
Project setup
Install DRF into your Django project with pip:
pip install djangorestframeworkRegister rest_framework (and the app you're about to create) in INSTALLED_APPS, and add a small DRF config block. Setting DEFAULT_AUTHENTICATION_CLASSES, DEFAULT_PERMISSION_CLASSES and pagination once, project-wide, keeps every endpoint consistent.
# settings.py
INSTALLED_APPS = [
# ...
"rest_framework",
"rest_framework.authtoken", # token auth (added later)
"api", # our app
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}Model the resource
A RESTful web service exposes data, so it starts with a model. Create the api app (python manage.py startapp api) and define the Country resource in api/models.py.
# api/models.py
from django.db import models
class Country(models.Model):
name = models.CharField(max_length=100, unique=True)
iso_code = models.CharField(max_length=2, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.nameGenerate and apply the migrations to create the table:
python manage.py makemigrations
python manage.py migrateSerializers: the translation layer
Serializers convert model instances and querysets into native Python types that render to JSON, and they validate and deserialize incoming JSON back into model instances. They are the boundary between your database and the wire.
For a model-backed resource, use ModelSerializer — it builds the fields and a working create()/update() from your model, so you don't hand-write them. Create api/serializers.py next to your model.
# api/serializers.py
from rest_framework import serializers
from .models import Country
class CountrySerializer(serializers.ModelSerializer):
class Meta:
model = Country
fields = ["id", "name", "iso_code", "created_at"]
read_only_fields = ["id", "created_at"]If you outgrow the defaults — computed fields, nested objects, cross-field rules — see customizing Django REST API serializers. For now, ModelSerializer gives us validation, JSON output and write handling for free.
Build the service with a ViewSet and router
You can write a function for every verb, but for a standard CRUD resource that's repetitive and error-prone (it's also where the verb/status-code mistakes creep in). The production-standard approach is a ModelViewSet: a single class that implements list, retrieve, create, update, partial-update and destroy, each returning the correct status code automatically.
In api/views.py:
# api/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Country
from .serializers import CountrySerializer
class CountryViewSet(viewsets.ModelViewSet):
"""Full CRUD web service for the Country resource."""
queryset = Country.objects.all()
serializer_class = CountrySerializer
permission_classes = [IsAuthenticatedOrReadOnly]
filterset_fields = ["iso_code"]
search_fields = ["name"]A router turns that ViewSet into a full set of RESTful URLs, wiring each HTTP verb to the right action and giving you clean, conventional endpoints. In urls.py, note that we use path() and include() — django.conf.urls.url was removed in Django 4.0 and must not be used in modern projects.
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from api.views import CountryViewSet
router = DefaultRouter()
router.register(r"countries", CountryViewSet, basename="country")
urlpatterns = [
path("api/", include(router.urls)),
]Those two registrations give you the entire RESTful surface:
GET /api/countries/— list (paginated)POST /api/countries/— createGET /api/countries/{id}/— retrievePUT /api/countries/{id}/— replacePATCH /api/countries/{id}/— partial updateDELETE /api/countries/{id}/— delete
Each action already returns the correct status code from the table above — 201 on create, 204 on delete, 404 for a missing id, 405 for an unsupported verb. To understand exactly how the router maps verbs to actions, read understanding routers in Django REST Framework.
When you need a custom function-based endpoint
ViewSets cover standard CRUD, but real services occasionally need a bespoke endpoint. DRF's @api_view decorator turns a plain function into an API view. The example below shows a clean, modern create handler — note data=request.data is passed explicitly, and raise_exception=True lets DRF return a structured 400 automatically instead of hand-building the error payload.
# api/views.py (custom function-based endpoint)
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 .serializers import CountrySerializer
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def add_country(request):
serializer = CountrySerializer(data=request.data)
serializer.is_valid(raise_exception=True) # -> 400 with field errors
serializer.save() # calls serializer.create()
return Response(serializer.data, status=status.HTTP_201_CREATED)@api_view(["POST"]) whitelists the allowed verbs — requesting this endpoint with GET or DELETE yields 405 Method Not Allowed for free. For deeper coverage of the function-based vs class-based vs generic options, see generic, functional and class-based views in DRF.
Authentication and permissions
A RESTful service is stateless, so clients authenticate on every request — typically by sending a token in the Authorization header rather than relying on a server-side session. We enabled TokenAuthentication in settings; generate a token per user and clients send it like this:
# Request a token (DRF's obtain_auth_token view), then call the API:
curl -X POST http://localhost:8000/api/auth/token/ \
-d "username=alice&password=s3cret"
# -> {"token": "9c1e..."}
curl http://localhost:8000/api/countries/ \
-H "Authorization: Token 9c1e..."Authentication answers who are you?; permissions answer what may you do?. Our IsAuthenticatedOrReadOnly default lets anyone read but requires a valid token to create, update or delete. Apply finer rules per endpoint with permission_classes. For token setup and JWT alternatives see token-based authentication in DRF, and for row-level rules see user-level and object-level permissions.
Pagination, filtering and content negotiation
Never return an unbounded list. Because we set DEFAULT_PAGINATION_CLASS and PAGE_SIZE in settings, GET /api/countries/ is paginated automatically — the response wraps results with count, next and previous:
GET /api/countries/?page=2
{
"count": 195,
"next": "http://localhost:8000/api/countries/?page=3",
"previous": "http://localhost:8000/api/countries/?page=1",
"results": [
{"id": 21, "name": "India", "iso_code": "IN", "created_at": "2026-01-04T10:11:00Z"},
{"id": 22, "name": "Japan", "iso_code": "JP", "created_at": "2026-01-04T10:12:00Z"}
]
}For filtering and search, install django-filter (pip install django-filter) and add it to INSTALLED_APPS plus the DRF backends; the filterset_fields and search_fields we declared on the ViewSet then enable GET /api/countries/?iso_code=IN and ?search=india.
DRF also handles content negotiation out of the box: it inspects the Accept header and renders JSON for API clients or the browsable HTML UI for a browser — the same endpoint, two representations, no extra code.
Consistent error responses
A predictable error shape is part of a good contract. With raise_exception=True (or ViewSet defaults), DRF returns 400 with a field-keyed body, and 401/403/404 with a detail message — so clients can parse failures uniformly:
# 400 Bad Request - validation error
{
"iso_code": ["This field is required."],
"name": ["country with this name already exists."]
}
# 404 Not Found
{"detail": "No Country matches the given query."}Consume and test the service
With the dev server running (python manage.py runserver), exercise every verb with curl to confirm the status codes match the contract:
AUTH="-H 'Authorization: Token 9c1e...'"
# Create -> 201 Created
curl -X POST http://localhost:8000/api/countries/ $AUTH \
-H "Content-Type: application/json" \
-d '{"name": "India", "iso_code": "IN"}'
# List -> 200 OK (paginated)
curl http://localhost:8000/api/countries/
# Partial update -> 200 OK
curl -X PATCH http://localhost:8000/api/countries/1/ $AUTH \
-H "Content-Type: application/json" -d '{"name": "Republic of India"}'
# Delete -> 204 No Content
curl -X DELETE http://localhost:8000/api/countries/1/ $AUTHManual checks are fine while building, but a web service needs an automated test suite. DRF ships APITestCase and APIClient, which speak the same verbs and assert on status codes and response bodies:
# api/tests.py
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Country
class CountryAPITests(APITestCase):
def test_create_returns_201(self):
response = self.client.post(
"/api/countries/",
{"name": "India", "iso_code": "IN"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Country.objects.count(), 1)
def test_list_returns_200(self):
Country.objects.create(name="Japan", iso_code="JP")
response = self.client.get("/api/countries/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)Run the suite with python manage.py test api. Add tests for unauthorized writes (401/403), missing resources (404) and validation errors (400) so your contract stays honest as the service grows.
Conclusion
You've built a complete RESTful web service in Django REST Framework: a Country resource modelled cleanly, serialized with ModelSerializer, exposed through a ModelViewSet and router with conventional URLs, the correct HTTP verb and status code on every action, stateless token authentication, permissions, pagination, filtering, predictable errors and an automated test suite. From here, layer on throttling, versioning and OpenAPI schema generation as your API matures.
If you're choosing between Django REST Framework and an async-first stack, our FastAPI development services page compares the trade-offs. And when you need a production-grade API designed, built and maintained, our team offers dedicated Django development services — we've shipped REST and real-time services for startups and enterprises for over a decade.
Frequently Asked Questions
What is a RESTful web service in Django?
A RESTful web service exposes your Django application's data as resources accessed over HTTP using standard verbs (GET, POST, PUT, PATCH, DELETE) and meaningful status codes. Django REST Framework provides the serializers, views, routers, authentication and permissions needed to build one quickly on top of Django models.
Should I use function-based views or a ViewSet for a REST API?
Use a ModelViewSet with a router for standard CRUD resources — it implements list, retrieve, create, update and delete with the correct status codes in a single class and the least code. Reach for function-based views with @api_view only for bespoke, non-CRUD endpoints that don't fit the resource pattern.
Which HTTP status codes should a Django REST API return?
Return 200 OK for successful reads and updates, 201 Created after a POST, 204 No Content after a DELETE, 400 Bad Request for validation errors, 401/403 for authentication and permission failures, 404 Not Found for missing resources, and 405 Method Not Allowed for unsupported verbs. DRF's generic views and ViewSets set most of these for you.
How do I secure a Django REST Framework web service?
Authenticate every request with a stateless mechanism such as TokenAuthentication or JWT (clients send the credential in the Authorization header) and authorise actions with permission classes like IsAuthenticatedOrReadOnly or object-level permissions. Configure these once via DEFAULT_AUTHENTICATION_CLASSES and DEFAULT_PERMISSION_CLASSES so they apply consistently across endpoints.
How is this different from a basic DRF tutorial?
This guide focuses on building a complete web service end to end — REST principles, resource modelling, the request/response lifecycle, ViewSets and routers, authentication, pagination, content negotiation, error contracts and testing. For the first-principles introduction to DRF concepts, read our introduction to API development with Django REST Framework.
Is Django REST Framework still a good choice in 2026?
Yes. DRF remains the most widely used API toolkit for Django, is actively maintained, and supports Django 5.x and Python 3.12+. For synchronous, model-backed REST services with rich auth, permissions and serialization, it is the pragmatic default; consider FastAPI when you need an async-first, high-throughput service.