Building Scalable Python APIs with Django REST Framework

A practical guide to structuring Django REST APIs for production — authentication, pagination, caching, and deployment.

By

Software Development Experts

UpNext Software is a full-cycle software development company specialising in AI/ML, Python, Flutter, and SaaS development.

Building Scalable Python APIs with Django REST Framework
Article Contents

Most Django REST Framework projects don't fall over because of traffic. They fall over because someone wrote a serializer that hits the database once per row, and six months later that endpoint is returning 4,000 rows to a mobile app. The database is fine. The server is fine. The code is doing exactly what it was told to do, 4,000 times.

We've built and inherited a lot of DRF codebases. The ones that scale well are rarely the clever ones — they're the boring, predictable ones where you can guess where a file lives and where every endpoint has the same shape. This guide covers the decisions that actually matter for production: project structure, serializer discipline, authentication, pagination, caching, and what to check before you deploy.

Structure the project around domains, not layers

The default Django tutorial teaches you to group by type: all models in one place, all views in another. That works until you have thirty models. Then every change touches five files in five directories and nobody can tell which parts of the system are actually related.

We prefer grouping by domain. One Django app per bounded area of the business — billing, accounts, catalogue, notifications — and inside each app, the files it needs:

  • models.py for the ORM layer, kept thin
  • serializers.py split into read and write serializers when they diverge
  • views.py or a views/ package for viewsets
  • services.py for business logic that doesn't belong to a single model
  • selectors.py for read queries that are more complex than a single manager method
  • urls.py registering that app's router

The important rule: views should be short. A view's job is to parse a request, call something, and return a response. When a view starts making decisions about pricing rules or state transitions, that logic belongs in a service function you can call from a management command, a Celery task, or a test — without faking an HTTP request.

Settings that don't fight you later

Split settings into base, local, and production modules, and read every secret and connection string from the environment. Not because it's fashionable, but because the moment you need a staging environment or a second worker container, hardcoded values become a manual migration. Use django-environ or a similar helper, and make the app fail loudly at startup if a required variable is missing. A crash on boot is much cheaper than a silent fallback to a development database.

Serializers are where performance is won or lost

DRF makes nested data trivially easy to expose, which is exactly the problem. A nested serializer that reads a related object triggers a query per parent row unless you've told the queryset to fetch it up front.

A few habits that prevent most of this:

  • Use select_related for forward foreign keys and OneToOne fields, prefetch_related for reverse relations and many-to-many. Set them on the viewset's get_queryset, not deep in the serializer.
  • Avoid SerializerMethodField when it queries. It's the most common source of hidden N+1 because it looks like a plain field.
  • Never rely on __all__ in production serializers. It leaks columns you add later — including ones you didn't intend to expose.
  • Separate the list serializer from the detail serializer. Lists should be lean; detail responses can afford nesting.
  • Use ModelSerializer for CRUD, plain Serializer for anything that isn't a direct mapping to a table.

Install django-debug-toolbar locally and watch the query count on your heaviest list endpoint. If pagination is set to 50 and you're seeing 60+ queries, you have an N+1. It's worth adding an assertNumQueries test around your two or three busiest endpoints — that's the only way we've found to stop regressions from creeping back in during a busy sprint.

Authentication: pick the simplest thing that fits the client

There is no single right answer here, and vendors will happily sell you complexity you don't need. What matters is who's calling your API.

  • Server-rendered Django templates plus a few AJAX calls: session authentication is fine. It's built in, it's secure, and you get CSRF protection for free.
  • A single mobile app or SPA you also own: DRF's built-in TokenAuthentication is often enough. One token per user, stored server-side, revocable instantly. Simple to reason about.
  • Short-lived access needed across multiple services: JWT via djangorestframework-simplejwt. Keep access tokens short (5-15 minutes is typical), use refresh tokens with rotation, and maintain a blacklist so logout actually means something.
  • Third-party developers building on your platform: OAuth2 with django-oauth-toolkit, plus scopes.

A caution on JWT: people reach for it because it's stateless, then add a database-backed blacklist to handle revocation, at which point it isn't stateless anymore. If you only have one backend and one frontend, plain tokens usually cost less to operate.

For permissions, set DEFAULT_PERMISSION_CLASSES to IsAuthenticated globally and open up specific endpoints deliberately. Defaulting to closed means a forgotten decorator produces a 403, not a data leak. Write permission logic in permission classes and object-level checks, not scattered if statements inside views.

Pagination, filtering, and throttling

Set a default pagination class in settings on day one, before any client has learned to expect unbounded lists. Retrofitting pagination onto a live API is a breaking change and an awkward conversation.

On which class to use: PageNumberPagination is the friendliest for UIs that need page numbers, but the database has to count and offset, which gets slow on large tables with deep pages. CursorPagination is the right choice for feeds, activity logs, and anything append-heavy — it's ordering-based, so performance stays flat no matter how deep the client scrolls. LimitOffsetPagination sits in between and shares the deep-page problem.

For filtering, django-filter handles almost everything cleanly and keeps filter definitions declarative. Whatever fields you allow filtering and ordering on need database indexes — an exposed filter on an unindexed column is a full table scan waiting for someone to discover it.

Throttling deserves more attention than it usually gets. Set a modest anonymous rate, a higher authenticated rate, and a strict one on expensive endpoints: login, password reset, search, report generation, anything that fans out to a third-party API. Throttling won't stop a determined attacker, but it does stop accidental abuse from a misconfigured client retry loop, which is the far more common outage.

Caching, and the layers worth having

Cache from the outside in. The cheapest win is the request you never handle.

  1. HTTP caching. Send ETag and Last-Modified headers on read endpoints so clients and CDNs can revalidate with a 304 instead of a full payload. Cheap, standards-based, and helps mobile clients on poor connections most of all.
  2. Fragment or view caching. For endpoints that are identical for everyone — public catalogues, config payloads, reference data — cache the rendered response in Redis with a short TTL. Be very careful applying this to per-user responses; a leaked cache key across users is a serious bug.
  3. Queryset and computed-value caching. Cache the expensive aggregate, not the whole response. Invalidate on write using model signals or, better, explicit calls from your service functions so the invalidation is visible in the code that caused it.
  4. Database-level work. Materialised views or denormalised counter columns for the aggregates that get read constantly and written rarely.

Use Redis as the cache backend and keep it separate from your Celery broker if you can. And move anything slow out of the request cycle entirely — emails, PDF generation, third-party syncs, webhook delivery — into Celery tasks with retries and a dead-letter path. An API that returns in 80ms and does the work asynchronously will always feel better than one that blocks for two seconds while it talks to a payment gateway.

Deployment and what to watch after launch

A conventional production setup is Gunicorn or Uvicorn behind Nginx, static files served by the web server or a CDN via WhiteNoise or S3, PostgreSQL with connection pooling, Redis for cache and broker, and Celery workers as separate processes. Containerise it so local, staging, and production are the same shape.

Before you go live, work through this:

  • DEBUG off, ALLOWED_HOSTS set explicitly, SECRET_KEY from the environment
  • HTTPS enforced, secure cookie flags on, HSTS configured
  • Migrations run as a separate deploy step, not on container start where two replicas can race
  • Structured JSON logging with a request ID that follows the request through to Celery tasks
  • Error tracking (Sentry or similar) wired up before the first real user, not after the first incident
  • A health endpoint that checks the database and cache, not one that returns 200 unconditionally
  • Database backups tested by actually restoring one

Once it's running, watch p95 latency per endpoint rather than averages, query counts on your top ten endpoints, Celery queue depth, and cache hit rate. Averages hide the slow tail that users actually complain about. Set up alerting on the p95 rather than on CPU — CPU tells you the machine is busy, latency tells you the product is broken.

One last thing worth saying plainly: most APIs never need horizontal scaling, sharding, or a rewrite in something faster. A well-indexed PostgreSQL database, disciplined serializers, and a Redis cache will comfortably carry a serious business application. Reach for architectural complexity only when you have measurements telling you to.

Where we usually come in

We build Python and Django backends for products that need to hold up under real use — sometimes from scratch, sometimes as an audit and cleanup of an API that grew faster than its structure. That work often sits alongside AI and ML features where DRF becomes the serving layer for a model, and it's the same backend discipline behind Orbis Lead CRM, our own product. If you'd rather extend your team than hand over a project, we also work as an embedded dedicated team inside an existing engineering group.

If you've got a Django API that's getting slower as it grows, or you're planning one and want to get the foundations right before the code multiplies, get in touch. Send us the endpoint that worries you most and we'll tell you honestly what we'd change.

Continue Reading
Related Articles