Python Django Framework: The Complete Guide for 2026

February 11, 202628 min read

Django is the most popular Python web framework, powering applications from Instagram and Pinterest to Disqus and Mozilla. It follows the "batteries included" philosophy: everything you need to build a production web application — ORM, authentication, admin panel, form handling, template engine, security middleware — ships with the framework. You do not spend weeks choosing and gluing together libraries. You spend that time building your application.

This guide covers Django from initial project setup through production deployment. Every section includes working code examples that you can adapt for your own projects. Whether you are building a content management system, an e-commerce platform, a SaaS application, or a REST API, Django provides the foundation and Django REST Framework extends it for modern frontend architectures.

⚙ Related resources: New to Python? Start with our Python Complete Beginner's Guide. Keep our Python Basics Cheat Sheet open for quick syntax reference, and use the JSON Formatter when debugging API responses.

1. Project Setup and Structure

Every Django project starts with a virtual environment and the django-admin startproject command. Keeping dependencies isolated per project prevents version conflicts and makes deployments reproducible.

Installation and Project Creation

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

# Install Django
pip install django

# Create the project
django-admin startproject myproject .

# Create your first app
python manage.py startapp blog

# Run the development server
python manage.py runserver

The . after startproject places the project files in the current directory instead of creating a nested folder. This produces a cleaner layout for production projects.

Project Structure

myproject/
    manage.py               # CLI entry point (runserver, migrate, etc.)
    myproject/
        __init__.py
        settings.py         # Configuration (database, apps, middleware)
        urls.py             # Root URL configuration
        wsgi.py             # WSGI entry point for production
        asgi.py             # ASGI entry point for async/WebSockets
    blog/
        __init__.py
        admin.py            # Admin panel registration
        apps.py             # App configuration
        models.py           # Database models
        views.py            # Request handlers
        urls.py             # App-level URL routing (create this)
        forms.py            # Form definitions (create this)
        tests.py            # Test cases
        templates/          # HTML templates (create this)
            blog/
                post_list.html
                post_detail.html
        migrations/         # Database migration files
            __init__.py

Essential Settings

# myproject/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Your apps
    'blog.apps.BlogConfig',
]

# Database - use PostgreSQL in production
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',  # dev
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

# For production, use PostgreSQL:
# pip install psycopg2-binary
# DATABASES = {
#     'default': {
#         'ENGINE': 'django.db.backends.postgresql',
#         'NAME': os.environ.get('DB_NAME', 'myproject'),
#         'USER': os.environ.get('DB_USER', 'postgres'),
#         'PASSWORD': os.environ.get('DB_PASSWORD', ''),
#         'HOST': os.environ.get('DB_HOST', 'localhost'),
#         'PORT': os.environ.get('DB_PORT', '5432'),
#     }
# }

# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']

# Media files (user uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

# Security settings for production
# SECRET_KEY = os.environ.get('SECRET_KEY')
# DEBUG = False
# ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

2. Models and the ORM

Django's Object-Relational Mapper (ORM) lets you define database tables as Python classes and query them with Python code instead of raw SQL. Models are the single source of truth for your data structure — Django generates database migrations automatically from model changes.

Defining Models

# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify


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

    class Meta:
        verbose_name_plural = 'categories'
        ordering = ['name']

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


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

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique_for_date='published_at')
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='blog_posts'
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='posts'
    )
    body = models.TextField()
    excerpt = models.TextField(blank=True, max_length=500)
    status = models.CharField(
        max_length=10,
        choices=STATUS_CHOICES,
        default='draft'
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    published_at = models.DateTimeField(default=timezone.now)

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

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('blog:post_detail', kwargs={'slug': self.slug})


class Comment(models.Model):
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE,
        related_name='comments'
    )
    name = models.CharField(max_length=80)
    email = models.EmailField()
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    active = models.BooleanField(default=True)

    class Meta:
        ordering = ['created_at']

    def __str__(self):
        return f'Comment by {self.name} on {self.post}'

Migrations

# Generate migration files from model changes
python manage.py makemigrations

# Apply migrations to the database
python manage.py migrate

# See the SQL a migration would execute
python manage.py sqlmigrate blog 0001

# Show migration status
python manage.py showmigrations

QuerySet API: Querying Data

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

# Retrieve all published posts
published = Post.objects.filter(status='published')

# Chain filters
recent_tech = Post.objects.filter(
    status='published',
    category__slug='technology',
    published_at__gte=timezone.now() - timezone.timedelta(days=30)
)

# Exclude, order, slice
posts = Post.objects.exclude(
    status='draft'
).order_by('-published_at')[:10]

# Get a single object (raises DoesNotExist if not found)
post = Post.objects.get(slug='my-first-post')

# Get or 404 (common in views)
from django.shortcuts import get_object_or_404
post = get_object_or_404(Post, slug='my-first-post', status='published')

# Aggregate and annotate
from django.db.models import Count, Avg, Q

# Count posts per category
categories = Category.objects.annotate(
    post_count=Count('posts')
).filter(post_count__gt=0)

# Complex queries with Q objects (OR conditions)
results = Post.objects.filter(
    Q(title__icontains='django') | Q(body__icontains='django'),
    status='published'
)

# Efficient relationship loading
# select_related: follows ForeignKey (single JOIN)
posts = Post.objects.select_related('author', 'category').all()

# prefetch_related: separate query for ManyToMany / reverse FK
posts = Post.objects.prefetch_related('comments').all()

# Update and delete
Post.objects.filter(status='draft', created_at__lt=cutoff).delete()
Post.objects.filter(author=user).update(status='published')

# Raw SQL when needed
posts = Post.objects.raw(
    'SELECT * FROM blog_post WHERE status = %s ORDER BY published_at DESC',
    ['published']
)

3. Views: Function-Based and Class-Based

Views handle HTTP requests and return HTTP responses. Django supports two styles: function-based views (FBVs) for simplicity and explicitness, and class-based views (CBVs) for reusability and reduced boilerplate. Both are fully supported and widely used.

Function-Based Views

# blog/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponse, JsonResponse
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
from .models import Post, Category
from .forms import CommentForm, PostForm


def post_list(request):
    """List all published posts with pagination."""
    posts = Post.objects.filter(
        status='published'
    ).select_related('author', 'category')

    # Optional category filter
    category_slug = request.GET.get('category')
    if category_slug:
        posts = posts.filter(category__slug=category_slug)

    # Pagination
    paginator = Paginator(posts, 10)  # 10 posts per page
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)

    categories = Category.objects.all()

    return render(request, 'blog/post_list.html', {
        'page_obj': page_obj,
        'categories': categories,
    })


def post_detail(request, slug):
    """Display a single post with comments and comment form."""
    post = get_object_or_404(Post, slug=slug, status='published')
    comments = post.comments.filter(active=True)

    if request.method == 'POST':
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.post = post
            comment.save()
            return redirect(post.get_absolute_url())
    else:
        form = CommentForm()

    return render(request, 'blog/post_detail.html', {
        'post': post,
        'comments': comments,
        'form': form,
    })


@login_required
def post_create(request):
    """Create a new blog post (authenticated users only)."""
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            return redirect(post.get_absolute_url())
    else:
        form = PostForm()

    return render(request, 'blog/post_form.html', {'form': form})

Class-Based Views

# blog/views.py
from django.views.generic import (
    ListView, DetailView, CreateView, UpdateView, DeleteView
)
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .models import Post


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

    def get_queryset(self):
        queryset = Post.objects.filter(
            status='published'
        ).select_related('author', 'category')

        category = self.request.GET.get('category')
        if category:
            queryset = queryset.filter(category__slug=category)
        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['categories'] = Category.objects.all()
        return context


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

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

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['comments'] = self.object.comments.filter(active=True)
        context['form'] = CommentForm()
        return context


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_form.html'

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)


class PostUpdateView(LoginRequiredMixin, UpdateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_form.html'

    def get_queryset(self):
        # Users can only edit their own posts
        return Post.objects.filter(author=self.request.user)


class PostDeleteView(LoginRequiredMixin, DeleteView):
    model = Post
    template_name = 'blog/post_confirm_delete.html'
    success_url = reverse_lazy('blog:post_list')

    def get_queryset(self):
        return Post.objects.filter(author=self.request.user)

4. URL Routing

Django's URL dispatcher maps URL patterns to view functions or classes. The convention is to define app-level URL patterns and include them in the root URL configuration.

App-Level URLs

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

app_name = 'blog'  # namespace for URL reversing

urlpatterns = [
    # Function-based views
    path('', views.post_list, name='post_list'),
    path('<slug:slug>/', views.post_detail, name='post_detail'),
    path('create/', views.post_create, name='post_create'),

    # Or class-based views
    # path('', views.PostListView.as_view(), name='post_list'),
    # path('<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
    # path('create/', views.PostCreateView.as_view(), name='post_create'),
    # path('<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post_update'),
    # path('<slug:slug>/delete/', views.PostDeleteView.as_view(), name='post_delete'),
]

Root URL Configuration

# myproject/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('blog/', include('blog.urls')),
    path('accounts/', include('django.contrib.auth.urls')),
    path('api/', include('blog.api_urls')),  # REST API routes
]

# Serve media files in development
if settings.DEBUG:
    urlpatterns += static(
        settings.MEDIA_URL,
        document_root=settings.MEDIA_ROOT
    )

URL Patterns and Path Converters

# Django's built-in path converters
path('posts/<int:pk>/', views.post_detail)        # Integer
path('posts/<slug:slug>/', views.post_detail)      # Slug (letters, numbers, hyphens, underscores)
path('posts/<str:title>/', views.post_detail)      # String (any non-empty string, excluding /)
path('posts/<uuid:uuid>/', views.post_detail)      # UUID
path('files/<path:file_path>/', views.download)    # Path (string including /)

# Reversing URLs in templates
# {% url 'blog:post_detail' slug=post.slug %}

# Reversing URLs in Python code
from django.urls import reverse
url = reverse('blog:post_detail', kwargs={'slug': 'my-post'})
# Returns: '/blog/my-post/'

5. Templates

Django's template engine uses HTML files with special template tags and filters. Templates support inheritance, which eliminates repetition by defining a base layout that child templates extend.

Base Template

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}My Site{% endblock %}</title>
    {% load static %}
    <link rel="stylesheet" href="{% static 'css/style.css' %}">
    {% block extra_css %}{% endblock %}
</head>
<body>
    <header>
        <nav>
            <a href="{% url 'blog:post_list' %}">Blog</a>
            {% if user.is_authenticated %}
                <a href="{% url 'blog:post_create' %}">New Post</a>
                <a href="{% url 'logout' %}">Logout</a>
            {% else %}
                <a href="{% url 'login' %}">Login</a>
            {% endif %}
        </nav>
    </header>

    <main>
        {% if messages %}
            {% for message in messages %}
                <div class="alert alert-{{ message.tags }}">
                    {{ message }}
                </div>
            {% endfor %}
        {% endif %}

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

    <footer>
        <p>&copy; 2026 My Site</p>
    </footer>
    {% block extra_js %}{% endblock %}
</body>
</html>

Post List Template

<!-- templates/blog/post_list.html -->
{% extends 'base.html' %}

{% block title %}Blog Posts{% endblock %}

{% block content %}
    <h1>Blog</h1>

    <!-- Category filter -->
    <div class="categories">
        <a href="{% url 'blog:post_list' %}">All</a>
        {% for cat in categories %}
            <a href="?category={{ cat.slug }}">{{ cat.name }}</a>
        {% endfor %}
    </div>

    {% for post in page_obj %}
        <article>
            <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
            <p class="meta">
                By {{ post.author.get_full_name|default:post.author.username }}
                on {{ post.published_at|date:"F j, Y" }}
                {% if post.category %}in {{ post.category.name }}{% endif %}
            </p>
            <p>{{ post.excerpt|default:post.body|truncatewords:50 }}</p>
        </article>
    {% empty %}
        <p>No posts yet.</p>
    {% endfor %}

    <!-- Pagination -->
    {% if page_obj.has_other_pages %}
        <nav class="pagination">
            {% 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 %}

Common Template Tags and Filters

<!-- Variables -->
{{ post.title }}
{{ post.body|linebreaks }}
{{ post.created_at|date:"Y-m-d H:i" }}
{{ post.body|truncatewords:30 }}
{{ post.title|lower }}
{{ comment_count|default:"No comments" }}
{{ price|floatformat:2 }}

<!-- Conditionals -->
{% if post.status == 'published' %}
    <span class="badge">Published</span>
{% elif post.status == 'draft' %}
    <span class="badge">Draft</span>
{% endif %}

<!-- Loops -->
{% for post in posts %}
    {{ forloop.counter }}.  <!-- 1, 2, 3, ... -->
    {{ forloop.first }}     <!-- True for first iteration -->
    {{ forloop.last }}      <!-- True for last iteration -->
{% endfor %}

<!-- Static files -->
{% load static %}
<img src="{% static 'images/logo.png' %}" alt="Logo">

<!-- URL reversing -->
<a href="{% url 'blog:post_detail' slug=post.slug %}">Read more</a>

<!-- CSRF protection (required for all POST forms) -->
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Submit</button>
</form>

<!-- Include another template -->
{% include 'blog/partials/sidebar.html' with categories=categories %}

6. Forms and Validation

Django forms handle rendering HTML form elements, validating submitted data, and converting that data into Python objects. ModelForms generate form fields automatically from your model definitions, eliminating duplication between your models and your forms.

ModelForm

# blog/forms.py
from django import forms
from .models import Post, Comment


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'slug', 'category', 'body', 'excerpt', 'status']
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': 'Enter post title'
            }),
            'body': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 15,
            }),
            'excerpt': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 3,
                'placeholder': 'Brief summary (optional)'
            }),
        }

    def clean_title(self):
        title = self.cleaned_data['title']
        if len(title) < 5:
            raise forms.ValidationError(
                'Title must be at least 5 characters long.'
            )
        return title


class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ['name', 'email', 'body']
        widgets = {
            'name': forms.TextInput(attrs={'class': 'form-control'}),
            'email': forms.EmailInput(attrs={'class': 'form-control'}),
            'body': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 4,
                'placeholder': 'Write your comment...'
            }),
        }

Custom Form with Validation

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    subject = forms.CharField(max_length=200)
    message = forms.CharField(widget=forms.Textarea)
    cc_myself = forms.BooleanField(required=False)

    def clean_email(self):
        email = self.cleaned_data['email']
        blocked = ['spam.com', 'throwaway.email']
        domain = email.split('@')[1]
        if domain in blocked:
            raise forms.ValidationError(
                'This email domain is not allowed.'
            )
        return email

    def clean(self):
        """Cross-field validation."""
        cleaned_data = super().clean()
        name = cleaned_data.get('name')
        message = cleaned_data.get('message')

        if name and message and name.lower() in message.lower():
            raise forms.ValidationError(
                'Do not include your name in the message body.'
            )
        return cleaned_data

Handling Forms in Views

from django.contrib import messages

def contact(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            # Access cleaned data
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            subject = form.cleaned_data['subject']
            message = form.cleaned_data['message']

            # Send email, save to database, etc.
            send_mail(subject, message, email, ['admin@example.com'])

            messages.success(request, 'Your message has been sent!')
            return redirect('contact')
    else:
        form = ContactForm()

    return render(request, 'contact.html', {'form': form})

7. The Admin Panel

Django's admin is an automatically generated interface for managing your application data. With a few lines of configuration, you get a full CRUD interface with search, filtering, pagination, and bulk actions. It is production-ready for internal tools and content management.

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


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


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'category', 'status', 'published_at']
    list_filter = ['status', 'category', 'published_at', 'author']
    search_fields = ['title', 'body']
    prepopulated_fields = {'slug': ('title',)}
    date_hierarchy = 'published_at'
    ordering = ['-published_at']
    raw_id_fields = ['author']  # faster than dropdown for many users

    # Custom actions
    actions = ['make_published', 'make_draft']

    @admin.action(description='Mark selected posts as published')
    def make_published(self, request, queryset):
        count = queryset.update(status='published')
        self.message_user(request, f'{count} posts marked as published.')

    @admin.action(description='Mark selected posts as draft')
    def make_draft(self, request, queryset):
        count = queryset.update(status='draft')
        self.message_user(request, f'{count} posts marked as draft.')

    # Show related comments inline
    class CommentInline(admin.TabularInline):
        model = Comment
        extra = 0
        readonly_fields = ['name', 'email', 'created_at']

    inlines = [CommentInline]


@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
    list_display = ['name', 'email', 'post', 'active', 'created_at']
    list_filter = ['active', 'created_at']
    search_fields = ['name', 'email', 'body']
    actions = ['approve_comments', 'reject_comments']

    @admin.action(description='Approve selected comments')
    def approve_comments(self, request, queryset):
        queryset.update(active=True)

    @admin.action(description='Reject selected comments')
    def reject_comments(self, request, queryset):
        queryset.update(active=False)
# Create a superuser to access the admin
python manage.py createsuperuser
# Then visit http://localhost:8000/admin/

8. Authentication and Users

Django's authentication system handles user accounts, passwords, groups, permissions, and sessions. The most important decision you make at the start of a project is defining a custom User model — changing it later after migrations exist is painful.

Custom User Model

# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    """Custom user model. Always create this at the start of a project."""
    bio = models.TextField(blank=True, max_length=500)
    avatar = models.ImageField(upload_to='avatars/', blank=True)
    website = models.URLField(blank=True)

    def __str__(self):
        return self.username
# myproject/settings.py
AUTH_USER_MODEL = 'accounts.User'  # Must be set BEFORE first migration

Authentication Views

# myproject/urls.py
from django.contrib.auth import views as auth_views

urlpatterns = [
    # Django's built-in auth views provide:
    # login, logout, password_change, password_reset
    path('accounts/', include('django.contrib.auth.urls')),
]

# This gives you these URL patterns:
# /accounts/login/                  [name='login']
# /accounts/logout/                 [name='logout']
# /accounts/password_change/        [name='password_change']
# /accounts/password_change/done/   [name='password_change_done']
# /accounts/password_reset/         [name='password_reset']
# /accounts/password_reset/done/    [name='password_reset_done']
# /accounts/reset/<uidb64>/<token>/  [name='password_reset_confirm']
# /accounts/reset/done/             [name='password_reset_complete']
# settings.py
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'

Registration View

# accounts/views.py
from django.contrib.auth import login
from django.shortcuts import render, redirect
from .forms import UserRegistrationForm


def register(request):
    if request.method == 'POST':
        form = UserRegistrationForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)  # Log in immediately after registration
            return redirect('blog:post_list')
    else:
        form = UserRegistrationForm()
    return render(request, 'accounts/register.html', {'form': form})
# accounts/forms.py
from django.contrib.auth.forms import UserCreationForm
from .models import User


class UserRegistrationForm(UserCreationForm):
    class Meta:
        model = User
        fields = ['username', 'email', 'password1', 'password2']

Protecting Views

# Function-based views: use decorators
from django.contrib.auth.decorators import login_required, permission_required

@login_required
def dashboard(request):
    return render(request, 'dashboard.html')

@permission_required('blog.add_post', raise_exception=True)
def create_post(request):
    # Only users with the blog.add_post permission can access this
    pass

# Class-based views: use mixins
from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    PermissionRequiredMixin,
    UserPassesTestMixin
)

class DashboardView(LoginRequiredMixin, TemplateView):
    template_name = 'dashboard.html'

class EditorOnlyView(UserPassesTestMixin, ListView):
    def test_func(self):
        return self.request.user.groups.filter(name='editors').exists()

9. REST APIs with Django REST Framework

Django REST Framework (DRF) is the standard toolkit for building Web APIs in Django. It provides serializers, viewsets, authentication, permissions, pagination, and a browsable API that makes development and debugging significantly faster.

# Install DRF
pip install djangorestframework

# settings.py
INSTALLED_APPS = [
    ...
    'rest_framework',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.TokenAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'anon': '100/hour',
        'user': '1000/hour',
    },
}

Serializers

# blog/serializers.py
from rest_framework import serializers
from .models import Post, Category, Comment


class CategorySerializer(serializers.ModelSerializer):
    post_count = serializers.IntegerField(read_only=True)

    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'description', 'post_count']


class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ['id', 'name', 'email', 'body', 'created_at', 'active']
        read_only_fields = ['created_at', 'active']


class PostSerializer(serializers.ModelSerializer):
    author = serializers.StringRelatedField(read_only=True)
    category = CategorySerializer(read_only=True)
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        source='category',
        write_only=True,
        required=False
    )
    comments = CommentSerializer(many=True, read_only=True)
    comment_count = serializers.SerializerMethodField()
    url = serializers.HyperlinkedIdentityField(
        view_name='api:post-detail',
        lookup_field='slug'
    )

    class Meta:
        model = Post
        fields = [
            'id', 'url', 'title', 'slug', 'author',
            'category', 'category_id', 'body', 'excerpt',
            'status', 'published_at', 'created_at', 'updated_at',
            'comments', 'comment_count'
        ]
        read_only_fields = ['created_at', 'updated_at']

    def get_comment_count(self, obj):
        return obj.comments.filter(active=True).count()

    def validate_title(self, value):
        if len(value) < 5:
            raise serializers.ValidationError(
                'Title must be at least 5 characters.'
            )
        return value

ViewSets and Routers

# blog/api_views.py
from rest_framework import viewsets, permissions, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Count
from .models import Post, Category, Comment
from .serializers import PostSerializer, CategorySerializer, CommentSerializer


class PostViewSet(viewsets.ModelViewSet):
    """
    API endpoint for blog posts.
    list: GET /api/posts/
    create: POST /api/posts/
    retrieve: GET /api/posts/{slug}/
    update: PUT /api/posts/{slug}/
    partial_update: PATCH /api/posts/{slug}/
    delete: DELETE /api/posts/{slug}/
    """
    serializer_class = PostSerializer
    lookup_field = 'slug'
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['title', 'body']
    ordering_fields = ['published_at', 'created_at', 'title']
    ordering = ['-published_at']

    def get_queryset(self):
        queryset = Post.objects.select_related(
            'author', 'category'
        ).prefetch_related('comments')

        # Non-staff users only see published posts
        if not self.request.user.is_staff:
            queryset = queryset.filter(status='published')

        # Optional category filter
        category = self.request.query_params.get('category')
        if category:
            queryset = queryset.filter(category__slug=category)

        return queryset

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

    @action(detail=True, methods=['post'])
    def publish(self, request, slug=None):
        """POST /api/posts/{slug}/publish/"""
        post = self.get_object()
        post.status = 'published'
        post.save()
        return Response(PostSerializer(post, context={'request': request}).data)

    @action(detail=True, methods=['get', 'post'])
    def comments(self, request, slug=None):
        """GET/POST /api/posts/{slug}/comments/"""
        post = self.get_object()

        if request.method == 'POST':
            serializer = CommentSerializer(data=request.data)
            serializer.is_valid(raise_exception=True)
            serializer.save(post=post)
            return Response(serializer.data, status=status.HTTP_201_CREATED)

        comments = post.comments.filter(active=True)
        serializer = CommentSerializer(comments, many=True)
        return Response(serializer.data)


class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = CategorySerializer
    lookup_field = 'slug'

    def get_queryset(self):
        return Category.objects.annotate(
            post_count=Count('posts', filter=models.Q(posts__status='published'))
        )
# blog/api_urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import api_views

app_name = 'api'

router = DefaultRouter()
router.register(r'posts', api_views.PostViewSet, basename='post')
router.register(r'categories', api_views.CategoryViewSet, basename='category')

urlpatterns = [
    path('', include(router.urls)),
]

# This generates:
# GET    /api/posts/                  List posts
# POST   /api/posts/                  Create post
# GET    /api/posts/{slug}/           Retrieve post
# PUT    /api/posts/{slug}/           Update post
# PATCH  /api/posts/{slug}/           Partial update
# DELETE /api/posts/{slug}/           Delete post
# POST   /api/posts/{slug}/publish/   Custom action
# GET    /api/posts/{slug}/comments/  List comments
# POST   /api/posts/{slug}/comments/  Add comment
# GET    /api/categories/             List categories
# GET    /api/categories/{slug}/      Retrieve category
⚙ Debug APIs: Use our JSON Formatter to inspect DRF responses, the JSON to Python Converter to generate model stubs, and check out our REST API Design Guide for endpoint naming conventions.

10. Middleware

Middleware is a framework of hooks that processes requests and responses globally. Every request passes through every middleware class before reaching your view, and every response passes back through them in reverse order. Django uses middleware for sessions, authentication, CSRF protection, and security headers.

Writing Custom Middleware

# blog/middleware.py
import time
import logging

logger = logging.getLogger(__name__)


class RequestTimingMiddleware:
    """Log the time taken to process each request."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start_time = time.monotonic()

        response = self.get_response(request)

        duration = time.monotonic() - start_time
        logger.info(
            '%s %s completed in %.3fs (status %d)',
            request.method,
            request.path,
            duration,
            response.status_code
        )

        # Add timing header for debugging
        response['X-Request-Duration'] = f'{duration:.3f}s'
        return response


class MaintenanceModeMiddleware:
    """Return a 503 response when maintenance mode is enabled."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        from django.conf import settings
        if getattr(settings, 'MAINTENANCE_MODE', False):
            if not request.user.is_staff:
                from django.http import HttpResponse
                return HttpResponse(
                    '<h1>Under Maintenance</h1><p>We will be back shortly.</p>',
                    status=503
                )
        return self.get_response(request)
# settings.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    # Custom middleware
    'blog.middleware.RequestTimingMiddleware',
]

11. Testing

Django includes a test framework built on Python's unittest module, with additional features like a test client for simulating HTTP requests, test database creation, and transactional isolation between tests. Write tests from the beginning — they catch regressions, document behavior, and give you confidence to refactor.

Model Tests

# blog/tests/test_models.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from blog.models import Post, Category

User = get_user_model()


class PostModelTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        """Set up data shared by all test methods."""
        cls.user = User.objects.create_user(
            username='testuser',
            password='testpass123'
        )
        cls.category = Category.objects.create(
            name='Technology',
            slug='technology'
        )
        cls.post = Post.objects.create(
            title='Test Post Title',
            slug='test-post-title',
            author=cls.user,
            category=cls.category,
            body='This is the post body content.',
            status='published'
        )

    def test_post_creation(self):
        self.assertEqual(self.post.title, 'Test Post Title')
        self.assertEqual(self.post.author.username, 'testuser')
        self.assertEqual(self.post.status, 'published')

    def test_post_str_method(self):
        self.assertEqual(str(self.post), 'Test Post Title')

    def test_post_get_absolute_url(self):
        url = self.post.get_absolute_url()
        self.assertEqual(url, '/blog/test-post-title/')

    def test_post_ordering(self):
        """Posts should be ordered by published_at descending."""
        older_post = Post.objects.create(
            title='Older Post',
            slug='older-post',
            author=self.user,
            body='Older content',
            published_at=timezone.now() - timezone.timedelta(days=7)
        )
        posts = list(Post.objects.all())
        self.assertEqual(posts[0], self.post)  # Newest first

    def test_category_auto_slug(self):
        category = Category(name='Web Development')
        category.save()
        self.assertEqual(category.slug, 'web-development')

View Tests

# blog/tests/test_views.py
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from blog.models import Post

User = get_user_model()


class PostViewTest(TestCase):
    def setUp(self):
        self.client = Client()
        self.user = User.objects.create_user(
            username='testuser',
            password='testpass123'
        )
        self.post = Post.objects.create(
            title='Published Post',
            slug='published-post',
            author=self.user,
            body='Published content',
            status='published'
        )
        self.draft_post = Post.objects.create(
            title='Draft Post',
            slug='draft-post',
            author=self.user,
            body='Draft content',
            status='draft'
        )

    def test_post_list_view(self):
        url = reverse('blog:post_list')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Published Post')
        self.assertNotContains(response, 'Draft Post')

    def test_post_detail_view(self):
        url = reverse('blog:post_detail', kwargs={'slug': 'published-post'})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Published Post')

    def test_draft_post_returns_404(self):
        url = reverse('blog:post_detail', kwargs={'slug': 'draft-post'})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_post_create_requires_login(self):
        url = reverse('blog:post_create')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 302)  # Redirect to login

    def test_post_create_authenticated(self):
        self.client.login(username='testuser', password='testpass123')
        url = reverse('blog:post_create')
        response = self.client.post(url, {
            'title': 'New Post',
            'slug': 'new-post',
            'body': 'New post content here.',
            'status': 'published',
        })
        self.assertEqual(response.status_code, 302)  # Redirect on success
        self.assertTrue(Post.objects.filter(slug='new-post').exists())

API Tests

# blog/tests/test_api.py
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from blog.models import Post, Category

User = get_user_model()


class PostAPITest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser', password='testpass123'
        )
        self.post = Post.objects.create(
            title='API Test Post',
            slug='api-test-post',
            author=self.user,
            body='Content for API testing.',
            status='published'
        )

    def test_list_posts(self):
        url = reverse('api:post-list')
        response = self.client.get(url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_create_post_unauthenticated(self):
        url = reverse('api:post-list')
        data = {'title': 'Unauthorized', 'body': 'Content', 'slug': 'unauth'}
        response = self.client.post(url, data)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_create_post_authenticated(self):
        self.client.force_authenticate(user=self.user)
        url = reverse('api:post-list')
        data = {
            'title': 'New API Post',
            'slug': 'new-api-post',
            'body': 'Created via API.',
            'status': 'draft'
        }
        response = self.client.post(url, data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Post.objects.count(), 2)

    def test_search_posts(self):
        url = reverse('api:post-list')
        response = self.client.get(url, {'search': 'API Test'})
        self.assertEqual(len(response.data['results']), 1)
# Run tests
python manage.py test

# Run tests for a specific app
python manage.py test blog

# Run a specific test class or method
python manage.py test blog.tests.test_views.PostViewTest.test_post_list_view

# Run with verbosity
python manage.py test --verbosity=2

# Use pytest (popular alternative)
# pip install pytest pytest-django
# pytest --reuse-db -v

12. Deployment

A production Django deployment replaces the development server with a WSGI/ASGI server, serves static files through a reverse proxy, uses a production database, and applies security settings. Here is the standard production stack.

Production Settings

# myproject/settings/production.py
import os
from .base import *

DEBUG = False
SECRET_KEY = os.environ['SECRET_KEY']
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')

# Security
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
X_FRAME_OPTIONS = 'DENY'

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

# Static files
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'

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

# Logging
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {process:d} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'WARNING',
            'class': 'logging.FileHandler',
            'filename': '/var/log/django/app.log',
            'formatter': 'verbose',
        },
    },
    'root': {
        'handlers': ['file'],
        'level': 'WARNING',
    },
}

Gunicorn (WSGI Server)

# Install
pip install gunicorn

# Run with Gunicorn
gunicorn myproject.wsgi:application \
    --bind 0.0.0.0:8000 \
    --workers 4 \
    --timeout 120 \
    --access-logfile /var/log/gunicorn/access.log \
    --error-logfile /var/log/gunicorn/error.log

# Workers formula: (2 * CPU_CORES) + 1
# For a 2-core server: 5 workers

Nginx Reverse Proxy

# /etc/nginx/sites-available/myproject
upstream django {
    server 127.0.0.1:8000;
}

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Static files served directly by Nginx
    location /static/ {
        alias /var/www/myproject/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Media files
    location /media/ {
        alias /var/www/myproject/media/;
        expires 7d;
    }

    # Proxy everything else to Gunicorn
    location / {
        proxy_pass http://django;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }

    client_max_body_size 10M;
}

Docker Deployment

# Dockerfile
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Install system dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq-dev && \
    rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application
COPY . .

# Collect static files
RUN python manage.py collectstatic --noinput

# Create non-root user
RUN addgroup --system app && adduser --system --group app
USER app

EXPOSE 8000

CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--timeout", "120"]
# docker-compose.yml
services:
  web:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    ports:
      - "8000:8000"
    restart: always

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: always

  redis:
    image: redis:7-alpine
    restart: always

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./staticfiles:/var/www/static:ro
      - ./media:/var/www/media:ro
    depends_on:
      - web
    restart: always

volumes:
  pgdata:

Deployment Checklist

# Django's built-in deployment check
python manage.py check --deploy

# This checks for common security issues:
# - DEBUG = True
# - Missing SECRET_KEY
# - Insecure ALLOWED_HOSTS
# - Missing security middleware
# - Missing HTTPS settings
# - Missing HSTS settings
⚙ Deployment tools: Validate your Docker Compose YAML with the YAML Validator, convert JSON configs with JSON to YAML, and read our Docker Complete Guide for containerization best practices.

13. Best Practices

Project Structure

Database and Models

Security

Performance

# Install django-debug-toolbar for development
pip install django-debug-toolbar

# settings/development.py
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']

# Example: caching a view
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # Cache for 15 minutes
def post_list(request):
    posts = Post.objects.filter(status='published')
    return render(request, 'blog/post_list.html', {'posts': posts})

# Example: manual caching
from django.core.cache import cache

def get_popular_posts():
    key = 'popular_posts'
    posts = cache.get(key)
    if posts is None:
        posts = list(
            Post.objects.filter(status='published')
            .annotate(comment_count=Count('comments'))
            .order_by('-comment_count')[:10]
        )
        cache.set(key, posts, 60 * 30)  # Cache for 30 minutes
    return posts

Frequently Asked Questions

What is the difference between Django and Flask?

Django is a full-featured, batteries-included web framework that provides an ORM, admin panel, authentication system, form handling, and template engine out of the box. Flask is a micro-framework that gives you a routing system and leaves everything else as a choice you make yourself. Use Django when you are building a data-driven application with user accounts, an admin interface, and complex database models — Django's conventions and built-in components save significant development time. Use Flask when you need a lightweight API, a small microservice, or when you want full control over every component in the stack. For most web applications that involve a database and user authentication, Django is the faster path to production because you spend time building features instead of assembling infrastructure from separate libraries.

How do I deploy a Django application to production?

A production Django deployment requires several components working together: a WSGI server (Gunicorn is the standard) or ASGI server (Uvicorn for async) to run the Django application, a reverse proxy (Nginx or Caddy) to handle static files, SSL termination, and load balancing, and a production database (PostgreSQL is the standard choice for Django). Key steps include collecting static files with python manage.py collectstatic, running python manage.py migrate, setting DEBUG=False, configuring ALLOWED_HOSTS, using a strong SECRET_KEY from environment variables, and enabling HTTPS with all associated security headers. Run python manage.py check --deploy to catch common security misconfigurations. For containerized deployments, use Docker with a multi-stage build and Docker Compose to orchestrate the web server, database, and cache services.

What is Django REST Framework and when should I use it?

Django REST Framework (DRF) is a powerful, flexible toolkit for building Web APIs on top of Django. It provides serializers for converting complex data types (like Django model instances) to and from JSON, viewsets that dramatically reduce boilerplate for standard CRUD operations, a browsable API interface for development and debugging, authentication classes (token, session, and JWT via third-party packages), permission classes for fine-grained access control, plus pagination, filtering, throttling, and content negotiation. Use DRF whenever your Django application needs to serve data to a frontend framework like React, Vue, or Angular, or to mobile applications. DRF integrates seamlessly with Django's ORM and authentication system, so you get the full power of Django's model layer and security while exposing clean, well-documented REST endpoints.

How does Django's ORM compare to writing raw SQL?

Django's ORM translates Python code into optimized SQL queries, providing database abstraction, automatic SQL injection prevention, and database portability (switch from SQLite to PostgreSQL without changing code). For most CRUD operations, the ORM is both safer and more productive than raw SQL: a query like Article.objects.filter(status='published', created__gte=last_week).order_by('-created') is cleaner and more maintainable than the equivalent SQL string. The ORM also handles schema migrations, foreign key relationships, and complex joins through select_related() and prefetch_related(). However, for complex reporting queries, multi-table aggregations, or performance-critical bulk operations, raw SQL may be more efficient and readable. Django supports raw queries via Model.objects.raw() and direct database cursors via django.db.connection for cases where the ORM generates suboptimal SQL.

How do I handle authentication and user management in Django?

Django includes a complete authentication system out of the box: a User model with password hashing (PBKDF2 by default, upgradable to Argon2), login and logout views, password reset with email verification, session management, and a permission and group system for authorization. For most projects, start by creating a custom User model that inherits from AbstractUser (do this before your first migration), then use Django's built-in auth views (LoginView, LogoutView, PasswordResetView) which handle the complex logic of secure authentication. Protect views with the @login_required decorator or LoginRequiredMixin for class-based views. For API authentication with Django REST Framework, use TokenAuthentication or integrate JWT via djangorestframework-simplejwt. For social login (Google, GitHub, etc.), the django-allauth package is the standard solution.

Conclusion

Django is the framework you reach for when you want to build something real and ship it to production. Its batteries-included philosophy means you start with a working authentication system, a database ORM that handles migrations, an admin panel for content management, and security middleware that protects against the most common web vulnerabilities — all from day one.

If you are starting a new project, follow this path: create a virtual environment, install Django, create a custom User model before your first migration, define your models, register them in the admin, and build your views and templates. Use Django REST Framework when you need an API for a frontend framework or mobile application. Write tests from the beginning. Deploy with Gunicorn behind Nginx, or use Docker for containerized deployments.

If you are already working with Django, invest in performance optimization: use select_related() and prefetch_related() to eliminate N+1 queries, add database indexes on fields you filter frequently, implement caching for expensive operations, and install django-debug-toolbar to see exactly what your application is doing on every request.

Django's ecosystem is mature, its documentation is excellent, and its community is one of the most welcoming in open source. The skills you build here transfer directly to production applications used by millions of people.

⚙ Essential tools: Debug API responses with the JSON Formatter, convert data with JSON to Python, validate YAML configs with the YAML Validator, and keep our Python Cheat Sheet bookmarked.

Related Resources

Related Resources

Python Beginner's Guide
Learn Python fundamentals from variables to OOP
REST API Design Guide
Best practices for designing API endpoints with DRF
Docker Complete Guide
Containerize Django apps for consistent deployments
JSON Formatter
Format and validate API responses from DRF endpoints
JSON to Python Converter
Convert JSON data to Python dictionaries and classes
Python Basics Cheat Sheet
Quick reference for Python syntax and built-ins