PYTHON · STEP 5 · API

Python · Django: building and deploying an API

Use Django REST framework's serializer, ModelViewSet and router to get a complete /todos CRUD API in a few dozen lines, shape every error as JSON, add CORS, then deploy it to Railway with Postgres.

Step 5 of 5Djangov6.1.3© Arthur Bernier Jrrewritten · verified16 code blocks1426 words

What you will learn#

  • Creating an API
  • Setting CORS headers
  • Testing an API
  • Deploying an API

The Todos contract#

RequestResponse
GET /health200 {"ok":true}
GET /todos200 [{id,subject,details}] ordered by id
GET /todos/:id200 the object, or 404 {"error":"not found"}
POST /todos with {subject,details}201 the new object
PUT /todos/:id with {subject,details}200 the updated object, or 404
DELETE /todos/:id204 with no body, or 404
blank or missing subject or details400 {"error":"…"}

Every backend guide on this site builds this same API, with no trailing slashes, objects that contain exactly id, subject and details, and CORS open to any origin so the four frontend guides can point at any of them. The finished Django example is github.com/arthurbernierjr/dtc-python-api, live at https://api-production-02380.up.railway.app.


Setup#

  • Open a terminal in your django folder

  • Activate your virtual environment: source .venv/bin/activate

  • Generate a new Django project: django-admin startproject todoproject

  • cd todoproject

  • Test your dev server: python manage.py runserver

The example runs Python 3.13 and Django 6.1.3 (python -m django --version).

Creating the API#

  • Create a new app: python manage.py startapp todos

DjangoRestFramework#

Yesterday we built CRUD routes with no help, and it was tedious:

  • every CRUD function written by hand
  • data converted to a JSON string and back to a dictionary just to send it
  • the output shape (model/pk/fields) isn't what a frontend expects
  • CSRF had to be switched off to make it work

Django REST framework (DRF) fixes all of that.

Django REST framework documentation

  • Install it: python -m pip install djangorestframework

  • Install DRF, the todos app and corsheaders (the CORS package from the deploy section, added now so you edit this list once) in settings.py:

PY
INSTALLED_APPS = [    "django.contrib.admin",    "django.contrib.auth",    "django.contrib.contenttypes",    "django.contrib.sessions",    "django.contrib.messages",    "django.contrib.staticfiles",    "todos",    "rest_framework",    "corsheaders",]

Leave the CSRF middleware on this time. DRF only enforces CSRF for requests authenticated by a browser session, so Postman and a separate frontend are unaffected.

Creating our Model#

todos/models.py

PY
from django.db import models  class Todo(models.Model):    subject = models.CharField(max_length=100)    details = models.CharField(max_length=100)     def __str__(self):        return self.subject

Django names the table todos_todo; id is an auto-incrementing integer.

Make and Run Migrations#

SH
python manage.py makemigrationspython manage.py migrate

Making Our Serializer#

A serializer converts model instances to JSON-ready data and validates incoming JSON back into model fields. ModelSerializer reads the model and generates the fields for you.

The contract says a blank subject or details is a 400, so the two fields are declared explicitly with allow_blank=False, plus a validate_<field> method each, which DRF calls during is_valid().

  • Create todos/serializers.py:
PY
from rest_framework import serializers from .models import Todo  class TodoSerializer(serializers.ModelSerializer):    subject = serializers.CharField(max_length=100, allow_blank=False, trim_whitespace=True)    details = serializers.CharField(max_length=100, allow_blank=False, trim_whitespace=True)     class Meta:        model = Todo        fields = ["id", "subject", "details"]     @staticmethod    def _require_text(value, name):        value = (value or "").strip()        if not value:            raise serializers.ValidationError(f"{name} is required")        return value     def validate_subject(self, value):        return self._require_text(value, "subject")     def validate_details(self, value):        return self._require_text(value, "details")

Output is now the flat shape a frontend wants: {"id": 1, "subject": "...", "details": "..."}; fields is also what is accepted as input.

WHY: trim_whitespace=True turns " " into "" before allow_blank is checked, so DRF rejects whitespace-only values with This field may not be blank. and missing ones with This field is required. _require_text is a belt-and-braces guard for the same rule.

Creating Our Viewset#

A ViewSet bundles the actions for a resource — list, create, retrieve, update, partial_update, destroy — into one class. ModelViewSet implements all six from a queryset and a serializer.

The contract also needs GET /health, so it is a plain Django view in the same file. todos/views.py (the top of the file; below this excerpt is a home view that returns a small HTML page pointing at /todos):

PY
from django.http import HttpResponse, JsonResponsefrom django.views.decorators.http import require_GETfrom rest_framework import permissions, viewsets from .models import Todofrom .serializers import TodoSerializer  class TodoViewSet(viewsets.ModelViewSet):    queryset = Todo.objects.all().order_by("id")    serializer_class = TodoSerializer    permission_classes = [permissions.AllowAny]  @require_GETdef health(request):    return JsonResponse({"ok": True})

order_by("id") keeps the list order stable. AllowAny is open for now — use IsAuthenticated for a real app.

Shaping errors as JSON#

Out of the box DRF answers a bad POST with {"subject": ["This field may not be blank."]} and a missing row with {"detail": "..."}. The contract wants {"error": "..."} every time, so we swap in our own exception handler.

Create todos/exceptions.py:

PY
from django.http import Http404from rest_framework import statusfrom rest_framework.exceptions import NotFound, ParseError, ValidationErrorfrom rest_framework.response import Responsefrom rest_framework.views import exception_handler as default_exception_handler  def _first_message(detail):    """Flatten a DRF error detail (dict, list or string) into one line."""    if isinstance(detail, dict):        for field, messages in detail.items():            message = _first_message(messages)            if field == "non_field_errors":                return message            return f"{field}: {message}"        return "invalid"    if isinstance(detail, (list, tuple)):        return _first_message(detail[0]) if detail else "invalid"    return str(detail)  def todos_exception_handler(exc, context):    if isinstance(exc, ValidationError):        return Response({"error": _first_message(exc.detail)}, status=status.HTTP_400_BAD_REQUEST)    if isinstance(exc, (NotFound, Http404)):        return Response({"error": "not found"}, status=status.HTTP_404_NOT_FOUND)    if isinstance(exc, ParseError):        return Response({"error": "invalid json"}, status=status.HTTP_400_BAD_REQUEST)     response = default_exception_handler(exc, context)    if response is not None and hasattr(exc, "detail"):        response.data = {"error": _first_message(exc.detail)}    return response

ValidationError (the serializer's 400) becomes the first field's first message, e.g. {"error": "subject: This field may not be blank."}; Http404 (what ModelViewSet raises when get_object() finds nothing) becomes not found; ParseError (a body that is not JSON) becomes invalid json.

Then tell DRF to use it, and lock the API to JSON, at the bottom of settings.py:

PY
REST_FRAMEWORK = {    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],    "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],    "DEFAULT_AUTHENTICATION_CLASSES": [],    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],    "EXCEPTION_HANDLER": "todos.exceptions.todos_exception_handler",}

WHY: JSONRenderer only switches off DRF's browsable API, which would hand a browser tab HTML instead of JSON. No authentication classes means no session lookups, which is also why CSRF never gets in the way.

Setting Up Our Router#

To connect the ViewSet's actions to URLs, DRF provides routers. DefaultRouter normally generates URLs with trailing slashes (/todos/); the contract has none, so pass trailing_slash=False. todoproject/urls.py:

PY
from django.contrib import adminfrom django.urls import include, pathfrom rest_framework import routers from todos.views import TodoViewSet, health, home router = routers.DefaultRouter(trailing_slash=False)router.register(r"todos", TodoViewSet) urlpatterns = [    path("", home),    path("health", health),    path("", include(router.urls)),    path("admin/", admin.site.urls),]

The router generates GET/POST /todos and GET/PUT/PATCH/DELETE /todos/<id>. path("", home) sits above the router so / serves our HTML page instead of DRF's API root. Start the server and run curl http://localhost:8000/todos — you should get [].

Testing the API#

Test with curl (or Postman with a JSON body). Each line shows the status you should see:

SH
curl -i http://localhost:8000/health                                    # 200 {"ok": true}curl -i -X POST http://localhost:8000/todos \     -H "Content-Type: application/json" \     -d '{"subject": "Learn Django", "details": "serializers and viewsets"}'   # 201 {"id": 1, ...}curl -i http://localhost:8000/todos                                     # 200 [{"id": 1, ...}]curl -i http://localhost:8000/todos/1                                   # 200 {"id": 1, ...}curl -i -X POST http://localhost:8000/todos \     -H "Content-Type: application/json" -d '{"subject": "  "}'         # 400 {"error": "..."}curl -i http://localhost:8000/todos/999                                 # 404 {"error": "not found"}curl -i -X DELETE http://localhost:8000/todos/1                         # 204, no body

PUT /todos/<id> with both fields answers 200 with the updated object; a second DELETE of the same id is a 404. No trailing slashes: GET /todos/ is a 404 now.

Run the contract check against your own deploy: node check.mjs https://your-api.up.railway.app (copy check.mjs from the example repo; Node 18 or later, no packages). All 16 lines should say PASS.

Deploying the API#

We deploy to Railway. It builds from your GitHub repo, reads the Procfile for the start command, gives you a Postgres DATABASE_URL, and sets PORT plus the variables you add.

  • Install the production dependencies:
SH
python -m pip install gunicorn django-cors-headers dj-database-url "psycopg[binary]" whitenoise

gunicorn is the production WSGI server; django-cors-headers lets a frontend on another origin call the API; dj-database-url reads DATABASE_URL into DATABASES; psycopg is the Postgres driver ([binary] ships a wheel, so no compiler or libpq-dev); whitenoise serves the admin's static files without a separate web server.

setting up django-cors-headers#

In settings.py:

  • corsheaders is already in INSTALLED_APPS from earlier

  • add the CORS middleware above CommonMiddleware, and WhiteNoise right after SecurityMiddleware

PY
MIDDLEWARE = [    "django.middleware.security.SecurityMiddleware",    "whitenoise.middleware.WhiteNoiseMiddleware",    "django.contrib.sessions.middleware.SessionMiddleware",    "corsheaders.middleware.CorsMiddleware",    "django.middleware.common.CommonMiddleware",    "django.middleware.csrf.CsrfViewMiddleware",    "django.contrib.auth.middleware.AuthenticationMiddleware",    "django.contrib.messages.middleware.MessageMiddleware",    "django.middleware.clickjacking.XFrameOptionsMiddleware",]
  • open CORS to every origin, so any of the four frontend guides can call the API:
PY
CORS_ALLOW_ALL_ORIGINS = True

CORS_ALLOW_ALL_ORIGINS = True is for the lesson. In a real product replace it with your frontend's exact origins, e.g. CORS_ALLOWED_ORIGINS = ["https://my-frontend.example.com", "http://localhost:5173"].

production settings from the environment#

Django's deployment checklist boils down to: no secrets in code, DEBUG off, hosts declared. Replace the generated lines at the top of settings.py:

PY
import osimport dj_database_urlfrom pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-key")DEBUG = os.environ.get("DEBUG", "false").lower() == "true"ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o] DATABASES = {    "default": dj_database_url.config(        default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}",        conn_max_age=600,    )}

WHY: DEBUG defaults to false. Set DEBUG=true in your shell while developing; forgetting it on the host then fails safe instead of leaking tracebacks.

Further down, keep STATIC_URL, add STATIC_ROOT = BASE_DIR / "staticfiles" and set STORAGES["staticfiles"] to whitenoise.storage.CompressedManifestStaticFilesStorage so WhiteNoise has somewhere to serve from (the repo's settings.py has the exact block).

Additional Setup#

  • Create a Procfile in the project root (next to manage.py). Railway reads it for the start command:
TEXT
web: python manage.py collectstatic --noinput && python manage.py migrate && gunicorn todoproject.wsgi --bind 0.0.0.0:${PORT:-8080}

Railway sets PORT and its proxy connects to that port, so gunicorn must bind to it (its default 8000 never answers); ${PORT:-8080} falls back to 8080 locally. collectstatic fills STATIC_ROOT before WhiteNoise looks for it. migrate creates the todos_todo table on a fresh Postgres and is a no-op on later boots, so there is no separate migrate step to remember.

  • Pin the Python version in .python-version in the project root (Railway's builder reads it):
TEXT
3.13
  • Pin the dependencies in requirements.txt. These are the exact versions the live example was built with:
TEXT
django==6.1.3djangorestframework==3.18.3django-cors-headers==4.9.0dj-database-url==3.1.2psycopg[binary]==3.3.8gunicorn==26.2.0whitenoise==6.12.0
  • Add a .gitignore with __pycache__/, db.sqlite3, staticfiles/, .env and your .venv/.

  • Run DEBUG=false SECRET_KEY=x ALLOWED_HOSTS=example.com python manage.py check --deploy: no errors (the HTTPS warnings are handled by Railway's TLS termination).

Getting it live#

Railway's Django guide has the screenshots; the steps:

  • git init, commit, push to a GitHub repo
  • In Railway, create a new project from that repo, then add a PostgreSQL database. Reference it from the web service as DATABASE_URL=${{Postgres.DATABASE_URL}}.
  • Under the web service's Variables, set SECRET_KEY (generate one with python -c "from django.core.management.utils import get_random_secret_key as k; print(k())"), DEBUG=false, ALLOWED_HOSTS=your-app.up.railway.app and CSRF_TRUSTED_ORIGINS=https://your-app.up.railway.app. Railway sets PORT itself.
  • Under Settings → Networking, generate a public domain. The deploy runs the Procfile (collect static, migrate, gunicorn); then curl https://your-app.up.railway.app/health prints {"ok": true} and node check.mjs https://your-app.up.railway.app prints 16 lines of PASS.

Resources to Learn More#


See also