DJANGO
Backend

Django: Building Python Web Apps

Mayur Dabhi
Mayur Dabhi
June 2, 2026
14 min read

You start a small Flask project, and a month later you've wired up SQLAlchemy, Alembic, Flask-Login, a password reset flow, WTForms, and a half-finished admin page so someone can fix typos in the database. None of that was the actual product. Django exists for exactly this situation: those pieces ship with the framework, they're already wired together, and they've been used in production for close to two decades.

This post walks through a small blog app from scratch: project setup, models and migrations, views and URLs, templates, the admin, auth, and a production setup with Gunicorn. I'll also point out a few things that tend to bite people the first time they deploy.

Why Django Over Flask or FastAPI?

Flask and FastAPI give you full control, but you pick and glue every piece yourself. Django comes with an ORM, admin, auth, forms, migrations, and security middleware that already know about each other. For a CRUD-heavy app with a database at the center, that usually means a lot less setup code before you get to real features.

The MVT Architecture

Django calls its pattern Model-View-Template (MVT). If you know MVC, the mapping is a bit confusing at first: Django's "view" is what most frameworks call a controller, and the framework itself (URL routing plus request handling) plays the controller role. Once that clicks, the rest of the layout makes sense.

Browser HTTP Request URL Router urls.py View views.py Model models.py Template .html files DB Django MVT Request/Response Cycle

Django's MVT architecture: URL Router dispatches requests to Views, which interact with Models and render Templates

Installation and Project Setup

Django requires Python 3.10 or higher. Use a virtual environment per project so one project's Django version doesn't break another's.

Requirement Minimum Recommended
Python 3.10 3.12+
Django 4.2 (LTS) 5.1+
Database SQLite (dev only) PostgreSQL 14+
pip 22.0 Latest
1

Create a Virtual Environment

Isolate your project dependencies using Python's built-in venv module or uv for faster installs.

Terminal
# Create and activate virtual environment
python -m venv venv
source venv/bin/activate        # Linux/macOS
# venv\Scripts\activate         # Windows

# Install Django
pip install django

# Verify installation
python -m django --version      # Should print 5.x.x
2

Create a Django Project

The startproject command scaffolds the top-level configuration files for your Django site.

Terminal
# Create the project (trailing dot avoids a nested folder)
django-admin startproject mysite .

# Run the development server
python manage.py runserver
# → http://127.0.0.1:8000/
3

Create Your First App

Django projects are composed of reusable apps. Each app has its own models, views, and URLs, which keeps the codebase split into manageable pieces.

Terminal
# Create a "blog" app inside your project
python manage.py startapp blog

Project Structure Explained

After running these commands, your project layout looks like this. It's worth knowing what each file does before you start adding code:

Project Layout
mysite/               ← project config package
    __init__.py
    settings.py       ← all project settings (DB, INSTALLED_APPS, etc.)
    urls.py           ← root URL configuration
    wsgi.py           ← WSGI entry point for production servers
    asgi.py           ← ASGI entry point for async servers

blog/                 ← your first app
    migrations/       ← auto-generated database migration files
    __init__.py
    admin.py          ← register models with Django admin
    apps.py           ← app configuration class
    models.py         ← database model definitions
    tests.py          ← unit and integration tests
    views.py          ← request handlers (business logic)
    urls.py           ← (you create this) app-level URL routing

manage.py             ← CLI tool for running dev server, migrations, etc.
4

Register the App in settings.py

Django won't discover your new app until you add it to INSTALLED_APPS in mysite/settings.py.

mysite/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog',          # ← add your app here
]

Models and the ORM

You describe your schema as Python classes and the ORM handles the SQL. Each subclass of models.Model becomes a table, and each field attribute becomes a column. For normal CRUD you won't write SQL by hand.

Defining Models

blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone


class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)

    class Meta:
        verbose_name_plural = "categories"

    def __str__(self):
        return self.name


class Post(models.Model):
    STATUS_CHOICES = [
        ('draft', 'Draft'),
        ('published', 'Published'),
    ]

    title      = models.CharField(max_length=250)
    slug       = models.SlugField(max_length=250, unique_for_date='publish')
    author     = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
    category   = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
    body       = models.TextField()
    publish    = models.DateTimeField(default=timezone.now)
    created    = models.DateTimeField(auto_now_add=True)   # set once on creation
    updated    = models.DateTimeField(auto_now=True)       # updated on every save
    status     = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')

    class Meta:
        ordering = ['-publish']
        indexes  = [models.Index(fields=['-publish'])]

    def __str__(self):
        return self.title

Running Migrations

Think of migrations as version control for your schema. Every time you change models.py, you run two commands. Commit the generated migration files with your code; your teammates and your server need them too.

Terminal
# Generate migration file from model changes
python manage.py makemigrations

# Apply pending migrations to the database
python manage.py migrate

# Inspect the SQL Django will run (without executing it)
python manage.py sqlmigrate blog 0001

Querying with the ORM

ORM Query Examples (Django Shell)
from blog.models import Post
from django.contrib.auth.models import User

# Retrieve all published posts (QuerySet is lazy, so no DB hit yet)
published = Post.objects.filter(status='published')

# Chaining filters (AND logic)
recent = Post.objects.filter(
    status='published',
    publish__year=2026
).order_by('-publish')

# OR logic using Q objects
from django.db.models import Q
results = Post.objects.filter(
    Q(title__icontains='django') | Q(body__icontains='django')
)

# Select related (uses a JOIN and avoids N+1 queries)
posts = Post.objects.select_related('author', 'category').all()

# Aggregate: count posts per category
from django.db.models import Count
Post.objects.values('category__name').annotate(total=Count('id'))

# Create a new post
user = User.objects.get(username='admin')
post = Post.objects.create(
    title='My First Post',
    slug='my-first-post',
    author=user,
    body='Hello, Django!',
    status='published',
)

# Update
Post.objects.filter(author=user).update(status='published')

# Delete
Post.objects.filter(status='draft', publish__lt=timezone.now() - timedelta(days=30)).delete()
Avoiding N+1 Query Problems

The most common Django performance problem is touching a related object inside a loop, like {{ post.author.username }} in a template, which fires one query per row. Use select_related() for ForeignKey/OneToOne (a SQL JOIN) and prefetch_related() for ManyToMany and reverse FK relations (one extra query for the whole set). Django Debug Toolbar shows the query count per page, so check it before guessing. Also note the snippet above uses timezone and timedelta without importing them; in a real shell you'd need from django.utils import timezone and from datetime import timedelta first.

Views and URL Routing

A view takes an HttpRequest and returns an HttpResponse. It can be a plain function or a class. The patterns in urls.py decide which view handles which path, using path converters like <int:year> (or regex via re_path if you really need it).

Function-Based Views (FBVs)

blog/views.py
from django.shortcuts import render, get_object_or_404
from django.core.paginator import Paginator
from .models import Post


def post_list(request):
    posts = Post.objects.filter(status='published')
    paginator = Paginator(posts, 10)           # 10 posts per page
    page_number = request.GET.get('page', 1)
    page_obj = paginator.get_page(page_number)
    return render(request, 'blog/post_list.html', {'page_obj': page_obj})


def post_detail(request, year, month, day, slug):
    post = get_object_or_404(
        Post,
        status='published',
        slug=slug,
        publish__year=year,
        publish__month=month,
        publish__day=day,
    )
    return render(request, 'blog/post_detail.html', {'post': post})

Class-Based Views (CBVs)

For list pages, detail pages, and forms, the generic class-based views do the pagination, context, and 404 handling for you. The trade-off is that the logic is spread across mixins, so when something behaves oddly you end up reading Django's source (or ccbv.co.uk) to find which method to override. Here's the same pair of views:

blog/views.py: Class-Based Equivalent
from django.views.generic import ListView, DetailView
from .models import Post


class PostListView(ListView):
    model = Post
    queryset = Post.objects.filter(status='published')
    context_object_name = 'posts'
    paginate_by = 10
    template_name = 'blog/post_list.html'


class PostDetailView(DetailView):
    model = Post
    context_object_name = 'post'
    template_name = 'blog/post_detail.html'

    def get_queryset(self):
        return Post.objects.filter(status='published')

URL Configuration

blog/urls.py
from django.urls import path
from . import views

app_name = 'blog'   # namespace for URL reversing

urlpatterns = [
    path('', views.PostListView.as_view(), name='post_list'),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:slug>/',
        views.PostDetailView.as_view(),
        name='post_detail',
    ),
]
mysite/urls.py: Root Router
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('blog/', include('blog.urls', namespace='blog')),
]

Django Templates

Django's template language is deliberately limited. You can't call arbitrary Python in it, which pushes logic back into views where it belongs. The feature you'll use most is template inheritance: write the base layout once, then have each page extend it and fill in blocks.

templates/base.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}My Blog{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'css/styles.css' %}">
{% load static %}
</head>
<body>
    <nav>...</nav>

    <main>
        {% block content %}{% endblock %}
    </main>

    <footer>...</footer>
</body>
</html>
Watch the Order of {% load static %}

In the base template above, {% load static %} sits after the first {% static %} tag. If you copy it as is, Django raises TemplateSyntaxError: Invalid block tag on line 5: 'static'. Did you forget to register or load this tag? Put {% load static %} at the very top of the file (and in every template that uses {% static %}, since loads aren't inherited through {% extends %}).

templates/blog/post_list.html
{% extends 'base.html' %}

{% block title %}Latest Posts | My Blog{% endblock %}

{% block content %}
<h1>Latest Posts</h1>

{% for post in page_obj %}
  <article>
    <h2>
      <a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
    </h2>
    <p>By {{ post.author.get_full_name }} on {{ post.publish|date:"F j, Y" }}</p>
    <p>{{ post.body|truncatewords:30 }}</p>
  </article>
{% empty %}
  <p>No posts published yet.</p>
{% endfor %}

{# Pagination #}
{% if page_obj.has_other_pages %}
  <nav>
    {% if page_obj.has_previous %}
      <a href="?page={{ page_obj.previous_page_number }}">Previous</a>
    {% endif %}
    <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
    {% if page_obj.has_next %}
      <a href="?page={{ page_obj.next_page_number }}">Next</a>
    {% endif %}
  </nav>
{% endif %}
{% endblock %}

Django Admin Interface

Register a model and you get a working CRUD screen for it, with search, filters, and pagination, without writing any frontend code. It's fine for internal staff and for poking at data in production. I wouldn't hand it to customers, though: it's built around your database tables, not around your users' workflows.

blog/admin.py
from django.contrib import admin
from .models import Category, Post


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug']
    prepopulated_fields = {'slug': ('name',)}


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display  = ['title', 'author', 'status', 'publish']
    list_filter   = ['status', 'created', 'publish', 'author']
    search_fields = ['title', 'body']
    prepopulated_fields = {'slug': ('title',)}
    raw_id_fields = ['author']       # search widget instead of dropdown
    date_hierarchy = 'publish'
    ordering      = ['status', '-publish']
    list_editable = ['status']       # edit status inline in the list view
Terminal: Create Admin User
python manage.py createsuperuser
# → Visit http://127.0.0.1:8000/admin/ to log in

Authentication and Security

You get user accounts, password hashing (PBKDF2 by default), login/logout views, email password reset, and permissions out of the box. For plain username/password auth you usually don't need anything else. One thing to decide on day one: if there's any chance you'll want email login or extra user fields, set up a custom user model (AUTH_USER_MODEL) before your first migration. Switching later, once tables reference auth.User, is painful.

mysite/urls.py: Include Auth URLs
from django.contrib.auth import views as auth_views

urlpatterns += [
    # Built-in login/logout/password reset views
    path('accounts/', include('django.contrib.auth.urls')),
]
# Provides: /accounts/login/  /accounts/logout/
#           /accounts/password_change/  /accounts/password_reset/
blog/views.py: Protecting Views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin


# Function-based view protection
@login_required
def create_post(request):
    ...


# Class-based view protection
class CreatePostView(LoginRequiredMixin, CreateView):
    model = Post
    fields = ['title', 'body', 'status']
    # LoginRequiredMixin redirects unauthenticated users to settings.LOGIN_URL
Security Checklist for Production

Before deploying, set DEBUG = False in settings, configure ALLOWED_HOSTS, use environment variables for SECRET_KEY and database credentials (never commit them to git), serve static files via a CDN or Nginx, and enable HTTPS. Run python manage.py check --deploy to get Django's built-in security audit.

Django's Built-in Security Features

Deploying a Django Application

Django ships with a development server (runserver) that is not suitable for production. The standard production stack uses Gunicorn as the WSGI application server behind Nginx as a reverse proxy.

Component Role Alternative
Gunicorn WSGI/ASGI server that runs your Python code uWSGI, Daphne (async)
Nginx Reverse proxy, static files, SSL termination Caddy, Apache
PostgreSQL Production database MySQL, MariaDB
Redis Cache backend, session storage, Celery broker Memcached
Celery Asynchronous task queue Django Q, Dramatiq
Terminal: Install and Run Gunicorn
pip install gunicorn psycopg2-binary whitenoise

# Collect static files into STATIC_ROOT
python manage.py collectstatic

# Run with 4 worker processes
gunicorn mysite.wsgi:application --workers 4 --bind 0.0.0.0:8000
mysite/settings.py: Production Settings
import os

SECRET_KEY = os.environ['DJANGO_SECRET_KEY']   # never hardcode!
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

# PostgreSQL
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ['DB_NAME'],
        'USER': os.environ['DB_USER'],
        'PASSWORD': os.environ['DB_PASSWORD'],
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': '5432',
    }
}

# WhiteNoise serves static files efficiently without Nginx
MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware', ...rest...]
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'

# Redis cache
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379/1'),
    }
}

# Security headers
SECURE_SSL_REDIRECT         = True
SECURE_HSTS_SECONDS         = 31536000
SESSION_COOKIE_SECURE       = True
CSRF_COOKIE_SECURE          = True
Two Errors You'll Probably Hit on First Deploy

If the domain you're serving from isn't in ALLOWED_HOSTS, every request returns a 400 and the log shows DisallowedHost: Invalid HTTP_HOST header. That includes the IP address if you test with it directly. Second, STATICFILES_STORAGE was deprecated in Django 4.2 and removed in 5.1. On 5.1+ the setting above is ignored, so configure WhiteNoise through the STORAGES dict instead ("staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}).

When I'd Use Django (and When I Wouldn't)

I'd reach for Django when the app is mostly a database with screens and business rules around it: a CMS, an internal tool, a marketplace back office, a SaaS dashboard. The admin alone saves a lot of work when non-developers need to edit data, and the auth and migrations are things you don't want to rebuild yourself.

I'd think twice in a few cases:

If you do go with Django, the things I'd check before production are the ones from this post that are easy to skip: run manage.py check --deploy, look at query counts on your list pages, pick a custom user model early, and keep secrets out of settings.py. For next steps, DRF (APIs for a React or mobile frontend), Celery (background jobs), and django-allauth (social login) are the add-ons most projects end up needing.

Python Django Web Development MVT ORM Backend Tutorial
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.