Python Django: Full-Stack Web Framework Guide

Django is a high-level Python web framework that prioritizes rapid development and pragmatic design. Unlike minimalist frameworks like Flask or performance-focused options like FastAPI, Django ships...

Key Insights

  • Django’s “batteries-included” philosophy means you get authentication, admin panels, ORM, and form handling out of the box—making it ideal for rapid development of data-driven applications where you need production-ready features immediately.
  • The Django ORM abstracts database operations into Python code, but understanding its query generation is critical—lazy evaluation and select_related/prefetch_related can mean the difference between 1 query and 1000 queries hitting your database.
  • Django REST Framework transforms Django into a powerful API backend with minimal configuration, offering serialization, authentication, and browsable APIs that make it competitive with modern JavaScript frameworks for building decoupled architectures.

Introduction to Django & MVC Architecture

Django is a high-level Python web framework that prioritizes rapid development and pragmatic design. Unlike minimalist frameworks like Flask or performance-focused options like FastAPI, Django ships with everything you need to build production applications: an ORM, authentication system, admin interface, form handling, and security features baked in.

Django follows the MTV (Model-Template-View) pattern, which is functionally equivalent to MVC but with different naming conventions. Models define your data structure, Templates handle presentation, and Views contain business logic. The framework’s URL dispatcher acts as the controller, routing requests to appropriate views.

Choose Django when you’re building content management systems, e-commerce platforms, social networks, or any application where you need a robust admin interface and complex database relationships. Skip it for microservices where FastAPI’s performance matters more, or simple APIs where Flask’s minimalism is sufficient.

Setting Up Your First Django Project

Start with a clean virtual environment to isolate dependencies:

python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install django

Django distinguishes between projects and apps. A project is your entire web application; apps are modular components within it. Create your project:

django-admin startproject myproject
cd myproject
python manage.py startapp blog

Your directory structure looks like this:

myproject/
├── manage.py
├── myproject/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── blog/
    ├── migrations/
    ├── __init__.py
    ├── models.py
    ├── views.py
    └── urls.py

Configure settings.py immediately. Add your app to INSTALLED_APPS:

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

# Database configuration (SQLite for development)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

Run initial migrations to set up Django’s built-in tables:

python manage.py migrate

Models & Database Layer

Django’s ORM is its killer feature. Define your data models as Python classes, and Django handles SQL generation, migrations, and query optimization.

# 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)
    slug = models.SlugField(unique=True)
    
    class Meta:
        verbose_name_plural = "categories"
    
    def __str__(self):
        return self.name

class Post(models.Model):
    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)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    published = models.BooleanField(default=False)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return self.title

Create and apply migrations:

python manage.py makemigrations
python manage.py migrate

The ORM provides an intuitive query API. Here are common operations:

# Create
post = Post.objects.create(
    title="Django Guide",
    slug="django-guide",
    author=user,
    content="Content here"
)

# Read
all_posts = Post.objects.all()
published = Post.objects.filter(published=True)
post = Post.objects.get(slug="django-guide")

# Update
post.title = "Updated Title"
post.save()

# Delete
post.delete()

# Relationships - avoid N+1 queries
posts_with_authors = Post.objects.select_related('author', 'category')
authors_with_posts = User.objects.prefetch_related('posts')

The difference between select_related and prefetch_related is critical. Use select_related for foreign keys (SQL JOIN), and prefetch_related for reverse relationships and many-to-many (separate queries).

Views, URLs & Templates

Views process requests and return responses. Function-based views are straightforward; class-based views reduce boilerplate for common patterns.

# blog/views.py
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, DetailView
from .models import Post

# Function-based view
def post_list(request):
    posts = Post.objects.filter(published=True).select_related('author', 'category')
    return render(request, 'blog/post_list.html', {'posts': posts})

# Class-based view (preferred for standard operations)
class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts'
    paginate_by = 10
    
    def get_queryset(self):
        return Post.objects.filter(published=True).select_related('author', 'category')

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

Wire up URLs:

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

app_name = 'blog'

urlpatterns = [
    path('', views.PostListView.as_view(), name='post_list'),
    path('<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
]

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

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

Templates use Django’s template language for rendering:

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

{% block content %}
<h1>Blog Posts</h1>
{% for post in posts %}
    <article>
        <h2><a href="{% url 'blog:post_detail' post.slug %}">{{ post.title }}</a></h2>
        <p>By {{ post.author.username }} in {{ post.category.name }}</p>
        <p>{{ post.content|truncatewords:30 }}</p>
    </article>
{% empty %}
    <p>No posts available.</p>
{% endfor %}
{% endblock %}

Django REST Framework for APIs

Django REST Framework (DRF) transforms Django into an API powerhouse. Install it:

pip install djangorestframework

Add to INSTALLED_APPS:

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

Create serializers to convert models to JSON:

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

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ['id', 'name', 'slug']

class PostSerializer(serializers.ModelSerializer):
    author = serializers.StringRelatedField()
    category = CategorySerializer()
    
    class Meta:
        model = Post
        fields = ['id', 'title', 'slug', 'author', 'category', 'content', 'created_at', 'published']
        read_only_fields = ['author', 'created_at']

Build API views with viewsets:

# blog/api_views.py
from rest_framework import viewsets, permissions
from .models import Post
from .serializers import PostSerializer

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.filter(published=True).select_related('author', 'category')
    serializer_class = PostSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
    lookup_field = 'slug'
    
    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

# blog/urls.py (add API routes)
from rest_framework.routers import DefaultRouter
from .api_views import PostViewSet

router = DefaultRouter()
router.register(r'posts', PostViewSet)

# Include router.urls in your urlpatterns

Forms, Authentication & Admin Panel

Django’s form system handles validation and rendering:

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

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'slug', 'category', 'content', 'published']
    
    def clean_slug(self):
        slug = self.cleaned_data['slug']
        if Post.objects.filter(slug=slug).exists():
            raise forms.ValidationError("This slug already exists.")
        return slug

The admin interface requires minimal setup:

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

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

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'category', 'published', 'created_at']
    list_filter = ['published', 'category', 'created_at']
    search_fields = ['title', 'content']
    prepopulated_fields = {'slug': ('title',)}
    date_hierarchy = 'created_at'

Create a superuser:

python manage.py createsuperuser

Deployment & Production Best Practices

Never use DEBUG = True in production. Use environment variables:

# settings.py
import os
from pathlib import Path

SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost').split(',')

# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'

# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

Basic Dockerfile for deployment:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN python manage.py collectstatic --noinput

CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]

Use gunicorn or uwsgi for production serving, never Django’s development server. Configure a reverse proxy like Nginx for static files. Store secrets in environment variables, use PostgreSQL instead of SQLite, and implement caching with Redis for high-traffic applications.

Django’s opinionated approach means less decision fatigue and more time building features. The ecosystem is mature, documentation is excellent, and the admin panel alone saves weeks of development time.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.