??????? Django Handbook — From Beginner to Intermediate
V1
Django 5.x Python 3.11+ DRF 3.15+
// Python Web Framework

Django
Handbook

A practical guide covering everything from your first project to building production-ready REST APIs with Django REST Framework — written for beginners and intermediate developers.

Models & ORM Views & Templates Forms & Validation Admin Panel REST APIs (DRF) Auth & Permissions Testing
🌱

Introduction

What Django is, how it works, and the MTV pattern

Django is a high-level Python web framework built on the principle of "batteries included" — it ships with an ORM, admin panel, authentication system, form library, and more, so you can build production-ready apps without assembling a pile of third-party packages.

MTV Architecture

Django uses Model-Template-View (MTV). Models define data, Templates define presentation, and Views contain business logic — similar to MVC.

Batteries Included

ORM, admin panel, auth, sessions, caching, CSRF protection, migrations — all built in and configured to work together safely by default.

DRY Principle

Don't Repeat Yourself. Django's ORM, class-based views, and template inheritance all encourage defining things once and reusing everywhere.

Request / Response Cycle

🌐
client
Browser
🔗
routing
urls.py
🪝
process
Middleware
👁
logic
View
🗄
data
Model/ORM
🎨
render
Template
📤
respond
Response
📦

Installation

Virtual environments, pip, and your first project
bash
# 1. Create and activate a virtual environment python -m venv venv source venv/bin/activate # Linux/Mac venv\Scripts\activate # Windows # 2. Install Django and DRF pip install django djangorestframework # 3. Create a new project django-admin startproject myproject . # 4. Create your first app python manage.py startapp blog # 5. Run the dev server python manage.py runserver # http://127.0.0.1:8000
Always use a virtual environment. It isolates your project's dependencies from other Python projects on your machine. Pin your dependencies with pip freeze > requirements.txt.
🗂

Project Structure

What every file and folder does
project layout
myproject/ ├── manage.py # CLI entry point — run commands here ├── requirements.txt # pip dependencies │ ├── myproject/ # project config package │ ├── __init__.py │ ├── settings.py # all configuration │ ├── urls.py # root URL routing │ ├── asgi.py # ASGI entry point (async) │ └── wsgi.py # WSGI entry point (sync) │ └── blog/ # an app (python manage.py startapp blog) ├── __init__.py ├── admin.py # register models in admin ├── apps.py # app configuration class ├── models.py # database models ├── views.py # request handlers ├── urls.py # app-level URL routing ├── forms.py # Django forms ├── serializers.py # DRF serializers (for APIs) ├── tests.py # unit tests └── templates/ └── blog/ # namespaced templates └── list.html
ℹ️
Apps are self-contained units. Keep each app focused on one domain (blog, users, payments). After creating an app, add it to INSTALLED_APPS in settings.py.
⚙️

Settings

Key configuration options and environment-based settings
settings.py
from pathlib import Path import os BASE_DIR = Path(__file__).resolve().parent.parent # ── Security ── SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-never-use-in-prod') DEBUG = os.environ.get('DEBUG', 'True') == 'True' ALLOWED_HOSTS = ['localhost', '127.0.0.1'] # ── Apps ── INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', # Third-party 'rest_framework', # Local apps 'blog', ] # ── Database (SQLite default) ── DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } } # ── PostgreSQL (production) ── # DATABASES = {'default': dj_database_url.config(conn_max_age=600)} # ── Static & Media ── STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' # ── Default primary key ── DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
🗄

Models & ORM

Defining your database schema in Python

A Model is a Python class that maps to a database table. Django's ORM (Object-Relational Mapper) lets you query and manipulate the database entirely in Python — no raw SQL needed for most tasks.

blog/models.py
from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(max_length=100, unique=True) slug = models.SlugField(unique=True) class Meta: verbose_name_plural = 'categories' ordering = ['name'] def __str__(self): return self.name class Post(models.Model): class Status(models.TextChoices): DRAFT = 'draft', 'Draft' PUBLISHED = 'published', 'Published' title = models.CharField(max_length=200) slug = models.SlugField(unique=True) author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts') category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts') tags = models.ManyToManyField('Tag', blank=True) body = models.TextField() status = models.CharField(max_length=20, choices=Status, default=Status.DRAFT) created_at = models.DateTimeField(auto_now_add=True) # set once on create updated_at = models.DateTimeField(auto_now=True) # updated on every save class Meta: ordering = ['-created_at'] # newest first def __str__(self): return self.title class Tag(models.Model): name = models.CharField(max_length=50, unique=True) def __str__(self): return self.name

Common Field Types

FieldPurposeKey Options
CharFieldShort text with a max lengthmax_length (required)
TextFieldLong text, no length limit
IntegerField / BigIntegerFieldWhole numbersvalidators
FloatField / DecimalFieldDecimal numbersmax_digits, decimal_places
BooleanFieldTrue/Falsedefault
DateField / DateTimeFieldDates and datetimesauto_now, auto_now_add
SlugFieldURL-friendly identifiersunique
ImageField / FileFieldFile uploadsupload_to
ForeignKeyMany-to-one relationshipon_delete, related_name
ManyToManyFieldMany-to-many relationshipthrough, blank
OneToOneFieldOne-to-one relationshipon_delete, related_name
JSONFieldJSON data (PostgreSQL native)default, encoder

ORM Queries

python shell / views.py
# ── Create ── post = Post.objects.create(title='Hello', slug='hello', author=user, body='...') # ── Read ── Post.objects.all() # all posts (lazy QuerySet) Post.objects.filter(status='published') # WHERE status = 'published' Post.objects.exclude(status='draft') # WHERE status != 'draft' Post.objects.get(slug='hello') # single obj — raises if missing Post.objects.filter(author__username='alice') # follow FK with __ Post.objects.filter(title__icontains='django') # case-insensitive LIKE Post.objects.filter(created_at__year=2024) # date lookup Post.objects.order_by('-created_at')[:10] # latest 10 # ── Update ── Post.objects.filter(status='draft').update(status='published') post.title = 'New Title'; post.save() # save single instance # ── Delete ── Post.objects.filter(status='draft').delete() # ── Aggregation ── from django.db.models import Count, Avg, Q Post.objects.aggregate(Count('id')) # {'id__count': N} Post.objects.values('category').annotate(n=Count('id')) # ── Complex queries with Q objects ── Post.objects.filter(Q(status='published') | Q(author=request.user)) # ── Optimize: avoid N+1 queries ── Post.objects.select_related('author', 'category') # JOIN for FK/OneToOne Post.objects.prefetch_related('tags') # separate query for M2M
🔄

Migrations

Tracking and applying database schema changes
bash
# After changing models.py: python manage.py makemigrations # generate migration files python manage.py makemigrations blog # for a specific app python manage.py migrate # apply pending migrations to DB # Inspect and debug python manage.py showmigrations # list all migrations and status python manage.py sqlmigrate blog 0001 # preview the SQL python manage.py migrate blog 0002 # roll back to migration 0002
⚠️
Never edit migration files by hand unless you know exactly what you're doing. Always run makemigrations after editing models.py, and commit migration files to version control.
👁

Views

Function-based views (FBV) and class-based views (CBV)

Function-Based Views (FBV)

blog/views.py
from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from .models import Post from .forms import PostForm def post_list(request): posts = Post.objects.filter(status='published').select_related('author') return render(request, 'blog/list.html', {'posts': posts}) def post_detail(request, slug): post = get_object_or_404(Post, slug=slug, status='published') return render(request, 'blog/detail.html', {'post': post}) @login_required @require_http_methods(['GET', 'POST']) def post_create(request): 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('blog:post_detail', slug=post.slug) else: form = PostForm() return render(request, 'blog/create.html', {'form': form})

Class-Based Views (CBV)

blog/views.py — CBV
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy class PostListView(ListView): model = Post template_name = 'blog/list.html' context_object_name = 'posts' # var name in template paginate_by = 10 def get_queryset(self): return Post.objects.filter(status='published').select_related('author') class PostDetailView(DetailView): model = Post template_name = 'blog/detail.html' class PostCreateView(LoginRequiredMixin, CreateView): model = Post fields = ['title', 'slug', 'body', 'category', 'status'] template_name = 'blog/create.html' def form_valid(self, form): form.instance.author = self.request.user return super().form_valid(form) class PostDeleteView(LoginRequiredMixin, DeleteView): model = Post success_url = reverse_lazy('blog:post_list')
When to use FBVs
  • Simple one-off logic that doesn't fit a generic pattern
  • Beginners — easier to read and debug
  • Views with complex custom logic or multiple HTTP methods
When to use CBVs
  • CRUD operations — ListView, DetailView, CreateView, etc.
  • Shared behaviour via Mixins across multiple views
  • When DRY matters most (authentication, permissions)
🔗

URLs & Routing

Mapping URLs to views with include() and namespacing
blog/urls.py
from django.urls import path from . import views app_name = 'blog' # namespace — use blog:post_list in templates urlpatterns = [ path('', views.post_list, name='post_list'), path('<slug:slug>/', views.post_detail, name='post_detail'), path('create/', views.post_create, name='post_create'), path('<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post_edit'), path('<int:pk>/delete/', views.PostDeleteView.as_view(), name='post_delete'), ]
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('api/', include('blog.api_urls')), # DRF URLs ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Path Converters

ConverterMatchesExample
<str:name>Any non-empty string (default)/about/
<int:pk>Positive integer/posts/42/
<slug:slug>ASCII letters, numbers, hyphens, underscores/posts/hello-world/
<uuid:id>UUID string/items/550e8400-…/
<path:file>Any string including slashes/files/img/bg.png
🎨

Templates

Django Template Language (DTL) — variables, tags, filters, inheritance
templates/base.html
<!-- base.html — parent template --> <!DOCTYPE html> <html> <head> <title>{% block title %}My Site{% endblock %}</title> {% load static %} <link rel="stylesheet" href="{% static 'css/main.css' %}"> </head> <body> {% if user.is_authenticated %} <p>Hello, {{ user.username }}</p> <a href="{% url 'logout' %}">Logout</a> {% else %} <a href="{% url 'login' %}">Login</a> {% endif %} {% block content %}{% endblock %} {% block scripts %}{% endblock %} </body> </html> <!-- blog/list.html — child template --> {% extends 'base.html' %} {% block title %}Blog Posts{% endblock %} {% block content %} {% for post in posts %} <article> <h2><a href="{% url 'blog:post_detail' slug=post.slug %}"> {{ post.title }} </a></h2> <p>{{ post.body|truncatewords:30 }}</p> <time>{{ post.created_at|date:"N j, Y" }}</time> </article> {% empty %} <p>No posts yet.</p> {% endfor %} <!-- Pagination --> {% if is_paginated %} {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">Previous</a> {% endif %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} {% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}">Next</a> {% endif %} {% endif %} {% endblock %}

Common Template Filters

FilterExampleResult
truncatewords:N{{ text|truncatewords:20 }}First 20 words + …
date:"FORMAT"{{ dt|date:"Y-m-d" }}2024-06-15
linebreaks{{ text|linebreaks }}Newlines → <p>/<br>
safe{{ html|safe }}Render raw HTML (careful!)
default:"val"{{ name|default:"Anonymous" }}Fallback if empty
length{{ list|length }}Count of items
lower / upper{{ title|upper }}Case conversion
urlencode{{ query|urlencode }}URL-encode the string
📝

Forms

Django forms and ModelForms with built-in validation
blog/forms.py
from django import forms from .models import Post # ── ModelForm — automatic fields from model ── class PostForm(forms.ModelForm): class Meta: model = Post fields = ['title', 'slug', 'body', 'category', 'status'] widgets = { 'body': forms.Textarea(attrs={'rows': 8}), } labels = {'body': 'Content'} # Custom field-level validation def clean_title(self): title = self.cleaned_data['title'] if len(title) < 5: raise forms.ValidationError('Title must be at least 5 characters.') return title # Cross-field validation def clean(self): cleaned = super().clean() if cleaned.get('status') == 'published' and not cleaned.get('body'): raise forms.ValidationError('Cannot publish a post without content.') return cleaned # ── Plain Form — not tied to a model ── class ContactForm(forms.Form): name = forms.CharField(max_length=100) email = forms.EmailField() message = forms.CharField(widget=forms.Textarea)
🛠

Admin Panel

Customizing Django's built-in admin interface
blog/admin.py
from django.contrib import admin from .models import Post, Category, Tag @admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display = ['title', 'author', 'status', 'created_at'] list_filter = ['status', 'category', 'created_at'] search_fields = ['title', 'body', 'author__username'] prepopulated_fields = {'slug': ('title',)} # auto-fill slug from title date_hierarchy = 'created_at' ordering = ['-created_at'] raw_id_fields = ['author'] # use a lookup widget for FKs filter_horizontal = ['tags'] # nicer M2M widget # Inline for related models class PostInline(admin.TabularInline): model = Post extra = 0 # don't show empty extra rows @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): inlines = [PostInline] admin.site.register(Tag)
bash — create superuser
python manage.py createsuperuser # then visit http://127.0.0.1:8000/admin/
🔐

Auth & Permissions

Login, logout, registration, and permission checks
settings.py + urls.py
# settings.py — redirect targets LOGIN_URL = '/accounts/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' # urls.py — include built-in auth views from django.contrib.auth import views as auth_views urlpatterns += [ path('accounts/', include('django.contrib.auth.urls')), # Provides: login, logout, password_change, password_reset, etc. ]
views.py — permission patterns
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import PermissionDenied # ── FBV decorator ── @login_required def dashboard(request): ... @permission_required('blog.add_post', raise_exception=True) def create_post(request): ... # ── Manual check ── def edit_post(request, pk): post = get_object_or_404(Post, pk=pk) if post.author != request.user: raise PermissionDenied ... # ── CBV Mixin ── class PostCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): permission_required = 'blog.add_post' ... # ── Custom user model (do this before any migrations!) ── from django.contrib.auth.models import AbstractUser class CustomUser(AbstractUser): bio = models.TextField(blank=True) avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) # settings.py AUTH_USER_MODEL = 'accounts.CustomUser'
🚨
Set AUTH_USER_MODEL before your first migration. Changing it later in a project with existing data is extremely painful. Always create a custom user model at the start, even if you don't need extra fields yet.
🪝

Middleware

Processing requests and responses globally

Middleware is a layer that wraps every request/response. Django's built-in middleware handles sessions, authentication, CSRF, and security headers. You can write custom middleware for cross-cutting concerns like logging, rate limiting, or adding custom headers.

myapp/middleware.py
import time import logging logger = logging.getLogger(__name__) class RequestTimingMiddleware: """Log the time taken for each request.""" def __init__(self, get_response): self.get_response = get_response # called once on startup def __call__(self, request): start = time.monotonic() response = self.get_response(request) # call the next layer elapsed = time.monotonic() - start logger.info(f"{request.method} {request.path} {response.status_code} {elapsed:.3f}s") return response # Register in settings.py: # MIDDLEWARE = ['myapp.middleware.RequestTimingMiddleware', ...]
📡

Signals

Decoupled event-driven logic with post_save, pre_delete, etc.
blog/signals.py
from django.db.models.signals import post_save from django.dispatch import receiver from django.contrib.auth import get_user_model from .models import Profile User = get_user_model() @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): """Auto-create a Profile whenever a User is created.""" if created: Profile.objects.create(user=instance) # Connect signals in AppConfig.ready() # blog/apps.py: class BlogConfig(AppConfig): name = 'blog' def ready(self): import blog.signals # noqa — triggers registration

Caching

Per-view, per-template, and low-level cache API
settings.py + views.py
# settings.py — use Redis in production CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', } } # ── Per-view caching (60 seconds) ── from django.views.decorators.cache import cache_page @cache_page(60 * 15) # cache for 15 minutes def post_list(request): ... # ── Low-level cache API ── from django.core.cache import cache posts = cache.get('published_posts') if posts is None: posts = Post.objects.filter(status='published').select_related('author') cache.set('published_posts', posts, timeout=300) # 5 min cache.delete('published_posts') # invalidate after save/update
📁

Static & Media Files

Serving CSS/JS/images in development and production
Static Files

CSS, JavaScript, images bundled with your app. Collected with collectstatic for production. Referenced with {% static 'path' %} in templates.

Media Files

User-uploaded content (avatars, attachments). Stored in MEDIA_ROOT, served from MEDIA_URL. Use a CDN or object storage (S3) in production.

bash + model + template
# Collect all static files into STATIC_ROOT python manage.py collectstatic # Model with file upload class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) avatar = models.ImageField(upload_to='avatars/%Y/%m/', blank=True) # Template {% load static %} <img src="{% static 'images/logo.png' %}"> <!-- static --> <img src="{{ profile.avatar.url }}"> <!-- media -->
🌐

DRF Introduction

Django REST Framework — building production REST APIs

Django REST Framework (DRF) is the standard way to build REST APIs in Django. It adds serializers (like forms for JSON), API views, viewsets, routers, authentication, and permissions on top of Django's solid foundation.

Serializers

Convert complex types (model instances, querysets) to Python dicts that can be rendered to JSON — and validate incoming JSON back into model instances.

ViewSets + Routers

Combine related views (list, detail, create, update, delete) into a single class. Routers auto-generate URLs following REST conventions.

REST API Request Cycle

📱
client
Fetch/Axios
🔗
routing
Router/URLs
🔑
auth
Authenticator
🛡
access
Permission
👁
logic
ViewSet
🔀
convert
Serializer
📤
json
Response
🔀

Serializers

ModelSerializer, custom fields, validation, nested relationships
blog/serializers.py
from rest_framework import serializers from .models import Post, Category, Tag class TagSerializer(serializers.ModelSerializer): class Meta: model = Tag fields = ['id', 'name'] class CategorySerializer(serializers.ModelSerializer): post_count = serializers.SerializerMethodField() class Meta: model = Category fields = ['id', 'name', 'slug', 'post_count'] def get_post_count(self, obj): return obj.posts.filter(status='published').count() class PostListSerializer(serializers.ModelSerializer): """Lightweight — used for list endpoints.""" author_name = serializers.CharField(source='author.get_full_name', read_only=True) category = CategorySerializer(read_only=True) class Meta: model = Post fields = ['id', 'title', 'slug', 'author_name', 'category', 'status', 'created_at'] class PostDetailSerializer(serializers.ModelSerializer): """Full — used for retrieve/create/update.""" author_name = serializers.CharField(source='author.get_full_name', read_only=True) category_id = serializers.PrimaryKeyRelatedField( queryset=Category.objects.all(), source='category', write_only=True) category = CategorySerializer(read_only=True) tags = TagSerializer(many=True, read_only=True) tag_ids = serializers.PrimaryKeyRelatedField( queryset=Tag.objects.all(), many=True, source='tags', write_only=True, required=False) class Meta: model = Post fields = ['id', 'title', 'slug', 'body', 'author_name', 'category', 'category_id', 'tags', 'tag_ids', 'status', 'created_at', 'updated_at'] read_only_fields = ['created_at', 'updated_at'] # Custom validation def validate_title(self, value): if Post.objects.filter(title=value).exists(): raise serializers.ValidationError('A post with this title already exists.') return value def validate(self, attrs): if attrs.get('status') == 'published' and not attrs.get('body'): raise serializers.ValidationError('Published posts must have a body.') return attrs
👁

API Views

APIView, generic views, and function-based api_view
blog/api_views.py
from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework import status, generics from rest_framework.views import APIView from .models import Post from .serializers import PostListSerializer, PostDetailSerializer # ── 1. Function-based API view ── @api_view(['GET']) def health_check(request): return Response({'status': 'ok'}) # ── 2. APIView (full manual control) ── class PostListAPIView(APIView): def get(self, request): posts = Post.objects.filter(status='published').select_related('author') serializer = PostListSerializer(posts, many=True) return Response(serializer.data) def post(self, request): serializer = PostDetailSerializer(data=request.data) if serializer.is_valid(): serializer.save(author=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # ── 3. Generic views — least boilerplate ── class PostList(generics.ListCreateAPIView): serializer_class = PostDetailSerializer def get_queryset(self): return Post.objects.filter(status='published') def perform_create(self, serializer): serializer.save(author=self.request.user) class PostDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Post.objects.all() serializer_class = PostDetailSerializer lookup_field = 'slug' # use slug instead of pk in URL
📦

ViewSets & Routers

Auto-generated CRUD endpoints from a single class
blog/viewsets.py
from rest_framework import viewsets, permissions from rest_framework.decorators import action from rest_framework.response import Response from .models import Post from .serializers import PostListSerializer, PostDetailSerializer class PostViewSet(viewsets.ModelViewSet): """ Provides: list, retrieve, create, update, partial_update, destroy URLs auto-generated by router. """ queryset = Post.objects.all().select_related('author', 'category') permission_classes = [permissions.IsAuthenticatedOrReadOnly] lookup_field = 'slug' def get_serializer_class(self): # Use lighter serializer for list, full one for detail if self.action == 'list': return PostListSerializer return PostDetailSerializer def get_queryset(self): qs = super().get_queryset() # Unauthenticated users only see published posts if not self.request.user.is_authenticated: return qs.filter(status='published') return qs def perform_create(self, serializer): serializer.save(author=self.request.user) # ── Custom action: GET /api/posts/{slug}/publish/ ── @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def publish(self, request, slug=None): post = self.get_object() post.status = 'published' post.save() return Response({'status': 'published'})
blog/api_urls.py — router
from rest_framework.routers import DefaultRouter from .viewsets import PostViewSet router = DefaultRouter() router.register(r'posts', PostViewSet, basename='post') # Auto-generated URLs: # GET /api/posts/ → list # POST /api/posts/ → create # GET /api/posts/{slug}/ → retrieve # PUT /api/posts/{slug}/ → update # PATCH /api/posts/{slug}/ → partial_update # DELETE /api/posts/{slug}/ → destroy # POST /api/posts/{slug}/publish/ → custom action urlpatterns = router.urls
🔑

API Auth & Permissions

Token auth, JWT, and custom permission classes
settings.py — DRF global defaults
REST_FRAMEWORK = { # Authentication: who is making the request? 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', # browser 'rest_framework_simplejwt.authentication.JWTAuthentication', # mobile/SPA ], # Permissions: what are they allowed to do? 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticatedOrReadOnly', ], 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', ], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20, }

JWT Authentication (recommended for SPAs / mobile)

bash + urls.py
pip install djangorestframework-simplejwt # urls.py from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns += [ path('api/token/', TokenObtainPairView.as_view()), # POST: get tokens path('api/token/refresh/', TokenRefreshView.as_view()), # POST: refresh ] # Client usage: # POST /api/token/ {username, password} → {access, refresh} # Authorization: Bearer <access_token>

Custom Permission Classes

blog/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS class IsAuthorOrReadOnly(BasePermission): """Allow anyone to read; only the author can write.""" def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True return obj.author == request.user # Use on a ViewSet: class PostViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
🔍

Filtering & Pagination

django-filter, search, ordering, and pagination classes
bash + viewsets.py
pip install django-filter # settings.py INSTALLED_APPS += ['django_filters'] REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS'] = [ 'django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', 'rest_framework.filters.OrderingFilter', ] # viewsets.py class PostViewSet(viewsets.ModelViewSet): filter_fields = ['status', 'category'] # ?status=published search_fields = ['title', 'body'] # ?search=django ordering_fields = ['created_at', 'title'] # ?ordering=-created_at ordering = ['-created_at'] # default order

Custom Pagination

blog/pagination.py
from rest_framework.pagination import PageNumberPagination, CursorPagination class StandardResultsPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' # ?page_size=50 max_page_size = 100 # Response shape: {count, next, previous, results: [...]} class CursorPaginationByDate(CursorPagination): """Better for real-time feeds — stable under inserts.""" page_size = 20 ordering = '-created_at' # Apply to a specific viewset: class PostViewSet(viewsets.ModelViewSet): pagination_class = StandardResultsPagination
🧪

Testing APIs

APIClient, fixtures, and test patterns for REST endpoints
blog/tests/test_api.py
from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase from .models import Post User = get_user_model() class PostAPITests(APITestCase): def setUp(self): self.user = User.objects.create_user( username='alice', password='secret') self.post = Post.objects.create( title='Test Post', slug='test-post', author=self.user, body='Content', status='published') self.list_url = reverse('post-list') self.detail_url = reverse('post-detail', kwargs={'slug': 'test-post'}) def test_list_posts_unauthenticated(self): response = self.client.get(self.list_url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), 1) def test_create_post_requires_auth(self): data = {'title': 'New', 'slug': 'new', 'body': 'Body'} response = self.client.post(self.list_url, data) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_create_post_authenticated(self): self.client.force_authenticate(user=self.user) data = {'title': 'New Post', 'slug': 'new-post', 'body': 'Body text'} response = self.client.post(self.list_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Post.objects.count(), 2) def test_non_author_cannot_delete(self): other = User.objects.create_user(username='bob', password='pw') self.client.force_authenticate(user=other) response = self.client.delete(self.detail_url) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # Run tests: # python manage.py test blog.tests # python manage.py test --verbosity=2
📚

ORM Cheat Sheet

Field lookups and queryset methods at a glance
Lookup / MethodSQL equivalent / Description
filter(field=val)WHERE field = val
filter(field__gt=val)WHERE field > val (also: lt, gte, lte)
filter(field__in=[a,b])WHERE field IN (a, b)
filter(field__isnull=True)WHERE field IS NULL
filter(field__contains='x')WHERE field LIKE '%x%' (icontains: case-insensitive)
filter(field__startswith='x')WHERE field LIKE 'x%'
filter(field__range=(a, b))WHERE field BETWEEN a AND b
filter(fk__field=val)JOIN and filter on related model field
exclude(field=val)WHERE NOT field = val
order_by('-created_at')ORDER BY created_at DESC
values('field1', 'field2')SELECT specific columns — returns dicts
values_list('field', flat=True)Returns flat list of values
distinct()SELECT DISTINCT
count()SELECT COUNT(*)
exists()Fast boolean check — uses LIMIT 1
first() / last()First/last item of QuerySet
get_or_create(field=val)SELECT, INSERT if missing — returns (obj, created)
update_or_create(...)SELECT, UPDATE or INSERT
bulk_create([obj1, obj2])INSERT multiple rows in one query
select_related('fk')JOIN — avoid N+1 for FK/OneToOne
prefetch_related('m2m')Extra query — avoid N+1 for M2M/reverse FK
only('f1','f2') / defer('f3')Load subset of columns
annotate(n=Count('related'))Add computed column per row
aggregate(total=Sum('amount'))Compute across all rows — returns dict
⌨️

Management Commands

Essential manage.py commands
CommandDescription
runserver [port]Development server (default port 8000)
startproject name .Create a new Django project
startapp nameCreate a new Django app
makemigrations [app]Generate migration files from model changes
migrate [app] [name]Apply / roll back migrations
showmigrationsList all migrations and their applied status
sqlmigrate app 0001Print the SQL a migration would run
createsuperuserCreate an admin user interactively
shellInteractive Python shell with Django loaded
shell_plusEnhanced shell (django-extensions) — auto-imports all models
dbshellOpens the database CLI (psql, sqlite3, etc.)
collectstaticGather static files into STATIC_ROOT for deployment
test [app.tests]Run the test suite
check --deploySecurity and configuration audit for production
dumpdata app.ModelSerialize data to JSON fixture
loaddata fixture.jsonLoad fixture data into the database
flushDelete all data in the database (keep tables)
🚀

Deployment Checklist

Going from development to production safely
bash — check for issues
python manage.py check --deploy # Checks: DEBUG, SECRET_KEY, ALLOWED_HOSTS, HTTPS, HSTS, cookies, etc.
Security
  • Set DEBUG = False in production
  • Use a long random SECRET_KEY from env
  • Set ALLOWED_HOSTS to your domain
  • Enable SECURE_SSL_REDIRECT = True
  • Set CSRF_COOKIE_SECURE = True
  • Set SESSION_COOKIE_SECURE = True
Database & Files
  • Switch to PostgreSQL — not SQLite
  • Use connection pooling (PgBouncer)
  • Store media on S3/GCS, not the server
  • Run collectstatic in CI/CD
  • Use WhiteNoise for static files behind gunicorn
Performance
  • Use gunicorn (sync) or uvicorn (async)
  • Enable caching (Redis) for expensive views
  • Use select_related / prefetch_related
  • Add database indexes on filtered fields
  • Use django-silk or toolbar to profile queries
Operations
  • Use environment variables for all secrets
  • Set up structured logging (JSON to stdout)
  • Configure error tracking (Sentry)
  • Run migrations in CI before deploy
  • Set up health check endpoint
gunicorn + uvicorn
pip install gunicorn # Sync (WSGI) — simplest, most battle-tested gunicorn myproject.wsgi:application --workers 4 --bind 0.0.0.0:8000 # Async (ASGI) — needed for websockets, async views pip install uvicorn[standard] uvicorn myproject.asgi:application --workers 4 --host 0.0.0.0 --port 8000
Recommended stack: Django + gunicorn behind Nginx (or Caddy), PostgreSQL, Redis for caching and sessions, Celery for background tasks, S3 for media, Sentry for errors. Deploy to Railway, Render, Fly.io, or a VPS.