All lessons
BackendIntermediate15 min readWorth knowing

Python, Django and FastAPI for web developers

The Python you need for the web, Django's batteries, and when FastAPI fits better.

  • Python
  • Django
  • FastAPI
  • ORM
  • Async
Step 1/5
A request arrives: POST /orders. The router reads the URL and method and picks the controller action that will handle it.

In 30 seconds

Python is a readable, general-purpose language that powers a large share of web backends, data and AI work. Django is its 'batteries included' web framework: ORM, migrations, admin panel, auth, forms and security defaults in one package. FastAPI is a lighter, async-first framework for typed JSON APIs. If you know PHP, Node.js or another backend stack, this lesson maps what you already know onto the Python world.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • Python is the default language of data science and AI, so many teams want their web backend in the same language as their models and scripts.
  • Django ships a production-grade admin, auth and ORM out of the box, which lets a small team build a data-heavy product or back office very quickly.
  • FastAPI has become a common choice for typed, async JSON APIs and for serving machine-learning models behind HTTP.
  • For a developer coming from Laravel or NestJS, Django and FastAPI map closely to familiar ideas, so a project that needs Python is a short learning curve, not a restart.

How it works

01Python in one page for web developers

Python uses indentation instead of braces, has no semicolons, and reads close to English. Variables are dynamically typed, but modern code adds type hints (name: str, -> list[int]) that tools like mypy or Pyright check before runtime. The core types you will use daily are str, int, float, bool, None, list, dict, tuple and set. Functions are defined with def, classes with class, and modules are just .py files you import. Python 3.14 is the current stable line (3.15 is due in October 2026's cycle), and you should always work inside a virtual environment so each project has its own dependencies. The fast, modern way to manage Python versions, environments and packages is uv; pip with venv still works everywhere.

  • Truthy and falsy: empty list, empty string, 0 and None are all falsy.
  • List and dict comprehensions replace most map and filter calls.
  • async def and await exist, but much Python code is still synchronous.
  • PEP 8 is the style guide; Ruff formats and lints it in milliseconds.
python
from dataclasses import dataclass
 
@dataclass
class Product:
    name: str
    price: float
    tags: list[str]
 
def cheap(items: list[Product], limit: float = 10.0) -> list[str]:
    return [p.name for p in items if p.price < limit]
 
items = [Product("Mug", 8.5, ["home"]), Product("Lamp", 40.0, [])]
print(cheap(items))  # ['Mug']

02Setting up a Django project

Django is organised into a project (settings, root URLs, WSGI/ASGI entry points) and apps (self-contained features such as orders or blog). Each app has models, views, URLs, admin registration and migrations. Django 6.1 is the current feature release and 5.2 is the long-term support line; the next LTS, 6.2, is planned for April 2027. Django 6.0 added a built-in background tasks API, template partials and Content Security Policy support. The request flow is simple: the URL resolver matches a path, calls a view, the view uses models to read or write data and returns a response, often rendered with a template or serialised to JSON.

bash
uv init shop && cd shop
uv add django
uv run django-admin startproject config .
uv run python manage.py startapp catalog
# add "catalog" to INSTALLED_APPS in config/settings.py
uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py runserver

03Models and the ORM

A model is a Python class that maps to a table. Each attribute is a field with a type and options, and Django generates migrations from changes to these classes. The ORM gives you QuerySets: lazy, chainable query builders that only hit the database when you iterate, slice or evaluate them. Relationships use ForeignKey, OneToOneField and ManyToManyField. Two methods matter most for performance: select_related joins single-valued relations in one query, and prefetch_related loads many-valued relations in a second query. Without them, a loop that touches product.category runs one query per row, the classic N+1 problem.

python
from django.db import models
 
class Category(models.Model):
    name = models.CharField(max_length=100, unique=True)
 
class Product(models.Model):
    name = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products")
    created_at = models.DateTimeField(auto_now_add=True)
 
    class Meta:
        indexes = [models.Index(fields=["category", "-created_at"])]
 
# One query with a JOIN instead of N+1:
recent = Product.objects.select_related("category").filter(price__lt=50)[:20]

04The admin: a back office in ten lines

Django's admin reads your models and builds a full CRUD interface with search, filters, pagination and permission checks. Register a model, describe which columns to show and which fields to filter on, and staff users can manage data immediately. This is one of the main reasons teams choose Django for internal tools and content-heavy products. Treat the admin as a tool for trusted staff, not a customer-facing UI: put it behind a non-obvious path, require strong passwords or SSO, and use list_select_related so big tables do not trigger N+1 queries.

python
from django.contrib import admin
from .models import Product
 
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ("name", "category", "price", "created_at")
    list_filter = ("category",)
    search_fields = ("name",)
    list_select_related = ("category",)
    ordering = ("-created_at",)

05JSON APIs with Django REST Framework

Django renders HTML by default. For JSON APIs, most teams add Django REST Framework (DRF). Its building blocks are serializers (convert models to JSON and validate input, like Laravel Form Requests plus API Resources), views or viewsets (handle HTTP methods) and routers (generate URLs). DRF also gives you authentication classes, permissions, throttling, pagination and a browsable API for development. Django Ninja is a newer alternative that uses type hints and Pydantic in a FastAPI-like style while keeping the Django ORM and admin.

python
from rest_framework import serializers, viewsets, permissions
from .models import Product
 
class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ["id", "name", "price", "category"]
 
class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.select_related("category").order_by("id")
    serializer_class = ProductSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
 
# urls.py
# router = routers.DefaultRouter(); router.register("products", ProductViewSet)

06FastAPI: the async, typed alternative

FastAPI is a micro-framework built on Starlette (the ASGI toolkit) and Pydantic (validation). You declare request and response shapes as Pydantic models with type hints, and FastAPI validates input, serialises output and generates OpenAPI docs at /docs automatically. It is async-first, so it handles many concurrent I/O-bound requests well, which is why it is popular for ML model serving and API gateways. The trade-off: it has no ORM, admin, migrations or auth system built in. You pick SQLAlchemy or SQLModel, Alembic for migrations, and your own auth. Choose Django when you want a complete, opinionated product framework; choose FastAPI when you want a small, fast, typed API service.

python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
 
app = FastAPI()
 
class ProductIn(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    price: float = Field(gt=0)
 
@app.post("/products", status_code=201)
async def create_product(body: ProductIn) -> dict:
    if body.name.lower() == "test":
        raise HTTPException(status_code=422, detail="Reserved name")
    return {"id": 1, **body.model_dump()}
 
# run: uv add "fastapi[standard]" && uv run fastapi dev main.py

07Running Python web apps in production

In development you use runserver or fastapi dev; never in production. For a synchronous Django app the classic setup is Gunicorn with several worker processes behind nginx. For async code (FastAPI, Django async views, WebSockets via Channels) you run an ASGI server such as Uvicorn, often managed by Gunicorn or as multiple Uvicorn workers. Static files are collected with collectstatic and served by nginx, a CDN or WhiteNoise. Settings come from environment variables, DEBUG must be False, ALLOWED_HOSTS must be set, and manage.py check --deploy lists missing security settings. Background work goes to Celery, RQ, Dramatiq or Django's new tasks API with a worker backend.

bash
# Django (WSGI) with 4 workers
gunicorn config.wsgi:application --workers 4 --bind 127.0.0.1:8000
 
# FastAPI or Django ASGI
uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4
 
# Before each deploy
python manage.py check --deploy
python manage.py migrate --noinput
python manage.py collectstatic --noinput

Use it when

  • Data-heavy products, CMS-like sites and internal tools where Django's admin saves weeks of work.
  • Teams that already use Python for data science, ML or automation and want one language across the stack.
  • Typed JSON APIs or ML model endpoints that need high concurrency and automatic OpenAPI docs (FastAPI).
  • Projects that value stable, well-documented, conservative frameworks with long-term support releases.

Skip it when

  • Real-time, highly concurrent systems where the whole team is already strong in Node.js or Go and has the tooling in place.
  • When the team has deep Laravel or NestJS experience and no Python requirement; switching stacks has a real cost.
  • CPU-heavy request handling in pure Python without offloading work to queues or native libraries.

Alternatives

  • Laravel (PHP)

    Very similar 'batteries included' philosophy: Eloquent, migrations, queues, auth, with a large ecosystem.

  • NestJS (Node.js)

    Structured TypeScript backend with modules and DI; one language with a React or Next.js frontend.

  • Flask

    Minimal Python micro-framework; flexible but you assemble everything yourself.

  • Django Ninja / Litestar

    Typed, Pydantic-style APIs; Ninja keeps Django's ORM and admin, Litestar is a FastAPI-like alternative.

Best practices

  1. Pin Python and dependencies per project with uv (pyproject.toml plus uv.lock) or pip-tools; never install into the system Python.
  2. Use select_related and prefetch_related for every list view, and check query counts with Django Debug Toolbar in development.
  3. Keep settings in environment variables, run manage.py check --deploy in CI, and keep DEBUG=False in every non-local environment.
  4. Add type hints and run Ruff plus mypy or Pyright in CI; they catch many bugs that dynamic typing hides.
  5. Use a custom user model from day one (AUTH_USER_MODEL); changing it later is painful.
  6. In FastAPI, never call blocking libraries inside async def handlers; use def handlers or async drivers instead.
  7. Write tests with pytest and pytest-django; use factories rather than large fixtures.

Common mistakes

  • N+1 queries from touching related objects in loops or templates without select_related or prefetch_related.
  • Running runserver or fastapi dev in production, or leaving DEBUG=True, which leaks settings and stack traces.
  • Calling a synchronous database driver or requests.get inside an async FastAPI handler, freezing the event loop for every user.
  • Editing old migrations that are already applied in production instead of adding new ones.
  • Doing slow work (emails, PDFs, external APIs) inside the request instead of a background worker.

Deep dive

08How QuerySets really execute

A QuerySet is lazy: filter, exclude and order_by just build an internal query tree. SQL runs when you iterate, call list(), len(), bool(), slice with a step, or access an index. After the first evaluation, results are cached on that QuerySet object, so iterating it twice hits the database once, but calling .all() again creates a new QuerySet and a new query. exists() and count() run cheap targeted queries, so use them instead of len() when you only need a number. For huge tables, .iterator(chunk_size=2000) streams rows without caching them all in memory. F() expressions push arithmetic into SQL and avoid race conditions, and annotate with Subquery or aggregates keeps reporting logic in the database.

python
from django.db.models import F, Count, Q
 
# Atomic increment in SQL, no read-modify-write race
Product.objects.filter(pk=42).update(stock=F("stock") - 1)
 
# Aggregate in the database, not in Python
Category.objects.annotate(
    active=Count("products", filter=Q(products__price__gt=0))
).order_by("-active")
 
# Stream a large export
for p in Product.objects.only("id", "name").iterator(chunk_size=2000):
    ...

09Transactions, locking and consistency

By default Django runs in autocommit mode: each query commits on its own. Wrap multi-step changes in transaction.atomic() so they succeed or fail together. For row-level locks, select_for_update() inside atomic issues SELECT ... FOR UPDATE, which is how you safely decrement stock or move money between accounts. Side effects such as sending an email or enqueuing a task should run only after the commit; transaction.on_commit() does exactly that, so a worker never processes a row that was rolled back. ATOMIC_REQUESTS wraps every request in a transaction, which is simple but holds transactions open during slow views, so many teams prefer explicit atomic blocks.

python
from django.db import transaction
 
def reserve(product_id: int, qty: int) -> None:
    with transaction.atomic():
        p = Product.objects.select_for_update().get(pk=product_id)
        if p.stock < qty:
            raise ValueError("Out of stock")
        p.stock -= qty
        p.save(update_fields=["stock"])
        transaction.on_commit(lambda: notify_warehouse.enqueue(product_id))

10Async in Python: what actually runs concurrently

Python's asyncio runs coroutines on a single-threaded event loop. Concurrency comes from awaiting I/O; while one request waits on the network, another runs. A blocking call (time.sleep, a synchronous DB driver, requests) stops the whole loop. FastAPI handles this nicely: plain def endpoints run in a thread pool, async def endpoints run on the loop. Django's async views work, and the ORM exposes async methods (aget, acreate, async for), but the database layer still largely runs in a thread via sync_to_async, so async Django mostly benefits I/O fan-out and long-lived connections, not raw query throughput. CPU-bound work needs processes or native code; Python 3.13 and 3.14 offer an optional free-threaded build that removes the GIL, but library support is still maturing.

11Scaling and hardening a Django deployment

Django scales horizontally: the app is stateless if sessions and cache live in Redis or the database, so you add Gunicorn processes and servers behind a load balancer. Connection count becomes the bottleneck first; set CONN_MAX_AGE for persistent connections, or Django 5.1+ native PostgreSQL connection pooling, or PgBouncer in front of the database. Cache expensive fragments and views with the cache framework backed by Redis. Read replicas are supported through database routers. On security, enable SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE and HSTS, configure SECURE_PROXY_SSL_HEADER only when your proxy sets that header, and keep the admin behind an unusual URL with MFA.

python
# settings/production.py (excerpt)
DEBUG = False
ALLOWED_HOSTS = ["example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
DATABASES["default"]["CONN_MAX_AGE"] = 60
CACHES = {"default": {"BACKEND": "django.core.cache.backends.redis.RedisCache",
                      "LOCATION": "redis://127.0.0.1:6379/1"}}

Real-world scenarios

01

The product list page got slow after a release

Problem

A Django list page that loaded in 200 ms now takes three seconds. Nothing changed in infrastructure, but a template now shows each product's category and supplier.

How to solve it

Install Django Debug Toolbar locally and open the page: it will show hundreds of near-identical queries, the N+1 pattern. Add select_related('category', 'supplier') to the view's QuerySet, and prefetch_related for any many-to-many tags. Use only() or defer() if the table has large text columns the list does not need. Add a test with assertNumQueries so the count cannot regress. Finally check that the filter and ordering columns have a composite index, verified with EXPLAIN ANALYZE in PostgreSQL. The page should return to a handful of queries.

02

Serving an ML model next to an existing Django app

Problem

The data team has a Python model that must answer predictions over HTTP with low latency. The main Django app is synchronous and already busy.

How to solve it

Put the model in a small FastAPI service instead of inside Django. Load the model once at startup with a lifespan handler, define Pydantic input and output schemas, and run it under Uvicorn with workers sized to CPU and memory. If inference is CPU-heavy, use def endpoints or a process pool so the event loop stays free. Django calls the service over HTTP with a short timeout and a fallback, or enqueues batch predictions through Celery. This keeps scaling, deploys and failures of the model independent from the main product.

03

Staff need a back office by next week

Problem

A client has a database of orders, customers and products and needs staff to search, edit and export data. There is no budget for a custom admin UI.

How to solve it

Model the tables in Django (or use inspectdb on an existing schema), then register each model in the admin with list_display, list_filter, search_fields and list_select_related. Add custom admin actions for exports and status changes, readonly_fields for audit columns, and groups with fine-grained permissions so support staff cannot delete orders. Protect the admin with HTTPS, a non-default path and MFA through a package such as django-otp. This delivers a usable, permission-aware back office in days, and a custom UI can come later if needed.

Cheat sheet

New project with uv
uv init app && uv add django && uv run django-admin startproject config .
Create and apply migrations
python manage.py makemigrations && python manage.py migrate
Show SQL for a migration
python manage.py sqlmigrate catalog 0002
Interactive ORM shell
python manage.py shell
Avoid N+1 (FK / M2M)
Model.objects.select_related("fk").prefetch_related("m2m")
Production readiness check
python manage.py check --deploy
Run Django under Gunicorn
gunicorn config.wsgi:application --workers 4 --bind 127.0.0.1:8000
Run FastAPI in dev
uv add "fastapi[standard]" && uv run fastapi dev main.py
Lint and format Python
ruff check . --fix && ruff format .

Questions & answers

Q1Django or FastAPI: how do you choose?

I start from what the product needs. If it is a full application with users, permissions, a back office and lots of relational data, Django wins because the ORM, migrations, admin and auth are all there and well integrated. If it is a focused JSON service, such as an ML inference endpoint or an internal API with high concurrency, FastAPI is a better fit: async by default, Pydantic validation and generated OpenAPI docs. The cost of FastAPI is that you assemble the rest yourself. Django Ninja is a good middle ground when you want FastAPI-style typing on top of Django.

Q2What is the N+1 problem in Django and how do you fix it?

It happens when you load a list of objects with one query and then touch a relation on each one, which triggers one extra query per row. Fifty products with product.category in a template becomes fifty-one queries. The fix is select_related for ForeignKey and OneToOne relations, which adds a SQL JOIN, and prefetch_related for many-to-many and reverse relations, which runs one extra query and joins in Python. I check with Django Debug Toolbar or assertNumQueries in tests so regressions are caught before production.

Q3What are migrations and how do you deploy them safely?

Migrations are versioned Python files that describe schema changes; makemigrations generates them from model changes and migrate applies them. They live in Git and run in order. To deploy safely I keep changes backward compatible: add nullable columns first, backfill data, then make them required in a later release, and never rename or drop a column the running code still reads. Large tables need care because some operations lock the table; on PostgreSQL I add indexes concurrently. Migrations run as a separate deploy step before the new code takes traffic.

Q4What is WSGI vs ASGI?

Both are Python standards for how a web server talks to an application. WSGI is the older synchronous one: each request occupies a worker for its full duration, which is fine for classic request-response apps served by Gunicorn. ASGI is the async successor: one process can handle many concurrent connections while waiting on I/O, and it supports WebSockets and long-lived connections. FastAPI is ASGI only; Django supports both and has async views and an async ORM interface, though much of the ORM still runs synchronously under the hood in a thread.

Q5How does Django protect against common web attacks?

Several protections are on by default. The template engine escapes output, which blocks most XSS. CSRF middleware requires a token on unsafe form requests. The ORM parameterises queries, so SQL injection only happens if you build raw SQL with string formatting. Clickjacking protection sets X-Frame-Options, password hashing uses strong algorithms, and Django 6.0 added built-in Content Security Policy support. Your job is to keep DEBUG off, set ALLOWED_HOSTS, enable secure cookies and HSTS behind HTTPS, and run manage.py check --deploy to see what is missing.

Q6How do you run background jobs in Python web apps?

Anything slow or unreliable, such as emails, image processing or third-party calls, should leave the request. The long-standing choice is Celery with Redis or RabbitMQ as a broker, which supports retries, scheduling and routing to different queues. RQ and Dramatiq are simpler alternatives. Django 6.0 added a built-in tasks framework that defines a standard API for enqueuing work, with backends providing the actual worker. Whatever the tool, tasks should be idempotent, receive IDs instead of full objects, and have timeouts and retry limits.

Senior-level questions

S1When would you split a Django monolith, and how?

Only when there is a concrete pressure: a component with very different scaling needs, a separate team that is blocked by shared releases, or a hard isolation requirement. Before splitting, I enforce boundaries inside the monolith: each Django app owns its models, other apps call a small service module instead of importing models directly, and cross-app foreign keys are minimised. That makes extraction cheap later. When a part is extracted, it gets its own database and talks to the monolith through an API or events, not shared tables. Often the right first step is just extracting background work to Celery workers and the hot read path to a cache, which removes most of the pressure without distributed-system costs.

S2How do you handle zero-downtime schema changes on a large PostgreSQL table in Django?

I follow expand and contract. First release: add the new column as nullable with no default that rewrites the table, and deploy code that writes to both old and new columns. Then backfill in batches with a management command or task, throttled to avoid replication lag. Next release: switch reads to the new column, then add NOT NULL using a validated check constraint approach. Final release: drop the old column once no running code reads it. Indexes use AddIndexConcurrently from django.contrib.postgres, inside a migration with atomic = False. I review sqlmigrate output for every risky migration, and set lock_timeout so a blocked ALTER fails fast instead of queueing all traffic behind it.

S3Is async Django worth it, or should high-concurrency work go to FastAPI?

It depends on where the time goes. Async Django is useful for fan-out to several external APIs, streaming responses and WebSockets through Channels, while keeping the admin, ORM and auth. But the ORM's async API mostly wraps synchronous drivers in threads, and mixing sync middleware forces context switches, so a mostly-database workload gains little over well-tuned Gunicorn workers. FastAPI with an async driver such as asyncpg gives truly non-blocking database I/O and lower per-request overhead, at the cost of building auth, admin and migrations yourself. My rule: keep the product in Django, and extract specific high-concurrency endpoints into a FastAPI service only when measurements show the sync workers are the bottleneck.

S4How would you secure a DRF API used by a mobile app and a SPA?

For the first-party SPA on the same site, session authentication with CSRF protection and secure, HttpOnly, SameSite cookies is simplest and avoids storing tokens in JavaScript. For the mobile app, short-lived access tokens with refresh tokens (for example via djangorestframework-simplejwt or an OAuth2 provider) work better, with rotation and revocation on logout. Every viewset gets explicit permission classes and object-level checks, ideally scoped querysets like Order.objects.filter(owner=request.user) so other users' data is unreachable by design. Add throttling per user and per IP, strict serializer validation, pagination limits, CORS restricted to known origins and logging of auth failures. Finally, test the authorisation paths, not just the happy path.

Glossary

Virtual environment
An isolated folder with its own Python interpreter and packages, so projects do not share or break each other's dependencies.
ORM
Object-relational mapper: lets you query and save database rows as Python objects instead of writing SQL by hand.
QuerySet
Django's lazy, chainable representation of a database query; it runs only when its results are needed.
Migration
A versioned file describing a schema change, applied in order to keep every database in sync with the models.
Serializer (DRF)
Converts model instances to JSON and validates incoming data before it reaches the database.
Pydantic
A library that validates and parses data using Python type hints; the core of FastAPI's request handling.
WSGI / ASGI
Standard interfaces between Python apps and web servers; WSGI is synchronous, ASGI supports async and WebSockets.
GIL
Global Interpreter Lock: lets only one thread run Python bytecode at a time in standard CPython builds.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.