← Back to DevBytes

Django from Scratch: Step-by-Step Guide to

What is Django?

Django is a high-level Python web framework that encourages rapid development and clean, pragmatic design. Built by experienced developers, it takes care of much of the hassle of web development so you can focus on writing your application without reinventing the wheel. Django follows the "batteries-included" philosophy — it ships with an ORM, authentication system, admin interface, form handling, template engine, and many other components right out of the box.

At its core, Django is built around the MVT (Model-View-Template) architectural pattern:

Why Django Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Django powers some of the busiest sites on the internet — Instagram, Pinterest, Mozilla, and The Washington Post all use Django in their stacks. Here's why it deserves your attention:

Prerequisites

Before diving in, ensure you have:

Step 1: Setting Up Your Environment

Best practice is to create an isolated virtual environment for each Django project. This prevents dependency conflicts between projects.

# Create a project directory and navigate into it
mkdir django_blog && cd django_blog

# Create a virtual environment (use python3 if on macOS/Linux)
python -m venv venv

# Activate the virtual environment
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate

# Install Django
pip install django

# Verify the installation
python -m django --version
# Output should show something like: 4.2.7

Step 2: Creating Your First Django Project

A Django project is the top-level container that holds all settings, URL configurations, and applications. Use the django-admin command to scaffold it:

# Create the project named 'myblog'
django-admin startproject myblog .

# The trailing dot places manage.py in the current directory.
# Project structure:
# django_blog/
# ├── manage.py          # Command-line utility for admin tasks
# ├── venv/              # Virtual environment (not tracked in git)
# └── myblog/
#     ├── __init__.py    # Marks this as a Python package
#     ├── settings.py    # Project configuration
#     ├── urls.py        # Root URL routing table
#     ├── asgi.py        # ASGI entry point for async servers
#     └── wsgi.py        # WSGI entry point for deployment

Let's examine the key files you just created:

Step 3: Your First Run — The Development Server

Django ships with a lightweight development server that auto-reloads when you change code. Start it to verify everything works:

python manage.py runserver

# Output:
# Watching for file changes with StatReloader
# Performing system checks...
# System check identified no issues.
# Django version 4.2.7, using settings 'myblog.settings'
# Starting development server at http://127.0.0.1:8000/
# Quit the server with CONTROL-C.

Open http://127.0.0.1:8000/ in your browser. You'll see Django's default welcome page with the rocket ship. This confirms your installation is working perfectly.

Step 4: Understanding Django Applications

A Django project is composed of multiple applications. Each app is a self-contained Python package that handles a specific domain of functionality. This modular design lets you plug apps into different projects or reuse them across projects.

Create your first app — we'll call it posts to handle blog articles:

python manage.py startapp posts

# New directory structure:
# posts/
# ├── __init__.py
# ├── admin.py      # Configuration for Django's auto-generated admin interface
# ├── apps.py       # App configuration class
# ├── migrations/   # Database migration files (auto-generated)
# │   └── __init__.py
# ├── models.py     # Database models (your data schema)
# ├── tests.py      # Test cases
# └── views.py      # View functions/classes that handle HTTP requests

Now register the app in your project's settings. Open myblog/settings.py and add 'posts' to the INSTALLED_APPS list:

# myblog/settings.py (excerpt)
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Add your app here
    'posts',
]

Step 5: Defining Models — Your Database Schema

Models are Python classes that define your database tables. Django's ORM translates these classes into SQL automatically. Let's create a Post model for blog articles:

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


class Post(models.Model):
    """Represents a single blog post."""
    
    # Fields
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True, 
                            help_text="URL-friendly version of the title")
    author = models.ForeignKey(
        User, 
        on_delete=models.CASCADE, 
        related_name='blog_posts'
    )
    body = models.TextField()
    excerpt = models.TextField(max_length=500, blank=True,
                               help_text="Short summary for listing pages")
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    published_at = models.DateTimeField(default=timezone.now)
    status = models.CharField(
        max_length=10,
        choices=[
            ('draft', 'Draft'),
            ('published', 'Published'),
        ],
        default='draft'
    )
    
    class Meta:
        ordering = ['-published_at']  # Newest posts first
        indexes = [
            models.Index(fields=['status', 'published_at']),
        ]
    
    def __str__(self):
        return self.title
    
    def get_absolute_url(self):
        """Returns the canonical URL for this post."""
        from django.urls import reverse
        return reverse('post_detail', kwargs={'slug': self.slug})

Key model field types used here:

Step 6: Migrations — Syncing Models with the Database

Migrations are Django's version-control system for your database schema. They track changes to your models and generate the necessary SQL to keep the database in sync. Run these commands:

# Generate migration files based on your models.py changes
python manage.py makemigrations

# Output:
# Migrations for 'posts':
#   posts/migrations/0001_initial.py
#     - Create model Post

# Apply the migrations to your database (SQLite by default)
python manage.py migrate

# Output:
# Operations to perform:
#   Apply all migrations: admin, auth, contenttypes, posts, sessions
# Running migrations:
#   Applying posts.0001_initial... OK
#   ... (other migrations)

Behind the scenes, Django created an SQLite database file (db.sqlite3) in your project root. You can inspect the generated SQL at any time:

python manage.py sqlmigrate posts 0001

Step 7: Django's Interactive Shell — Playing with Models

Django provides an enhanced Python shell with your project context pre-loaded. Let's create some data:

python manage.py shell

# Now inside the Django shell:
>>> from posts.models import Post
>>> from django.contrib.auth.models import User
>>> from django.utils import timezone

# Create a superuser first (we'll need one later for the admin)
# Exit shell and run: python manage.py createsuperuser
# Then come back to the shell

# Fetch an existing user (the superuser you just created)
>>> user = User.objects.first()

# Create a blog post
>>> post = Post.objects.create(
...     title="Getting Started with Django",
...     slug="getting-started-with-django",
...     author=user,
...     body="Django is a powerful Python web framework...",
...     excerpt="Learn Django from scratch",
...     status="published"
... )

# Query all published posts
>>> Post.objects.filter(status='published')
]>

# Get a single post by slug
>>> Post.objects.get(slug='getting-started-with-django')


# Update a post
>>> post.title = "Django for Beginners"
>>> post.save()
>>> post.title
'Django for Beginners'

Step 8: Creating Views — Handling HTTP Requests

Views are functions (or classes) that receive an HTTP request and return an HTTP response. Every view must either return a response object or raise an exception. Let's create views for listing posts and showing a single post:

# posts/views.py
from django.shortcuts import render, get_object_or_404
from django.views import View
from .models import Post


# Function-based view for listing published posts
def post_list(request):
    """Display a list of all published blog posts."""
    posts = Post.objects.filter(
        status='published'
    ).select_related('author').all()
    context = {'posts': posts}
    return render(request, 'posts/list.html', context)


# Class-based view for a single post detail
class PostDetailView(View):
    """Display a single blog post with full content."""
    
    def get(self, request, slug):
        post = get_object_or_404(
            Post.objects.select_related('author'), 
            slug=slug, 
            status='published'
        )
        context = {'post': post}
        return render(request, 'posts/detail.html', context)

Important points:

Step 9: URL Routing — Mapping URLs to Views

URLs are dispatched through a hierarchical routing system. Each app gets its own urls.py file, which is included into the project's root URL configuration.

First, create posts/urls.py:

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

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

Then include it in the project's root URL configuration:

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

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('posts.urls')),  # Include the posts app URLs at root
]

The URL pattern <slug:slug> captures a slug from the URL and passes it as a keyword argument to the view. For example, /post/getting-started-with-django/ passes slug='getting-started-with-django' to the view.

Step 10: Templates — Rendering Dynamic HTML

Templates are HTML files with Django's template language sprinkled in. They support variables, filters, tags, template inheritance, and more.

Create the template directory structure inside your app:

mkdir -p posts/templates/posts

Now create a base template that all pages will inherit from:

# posts/templates/posts/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 Blog{% endblock %}</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 800px; 
               margin: 0 auto; padding: 20px; line-height: 1.6; }
        nav { margin-bottom: 30px; border-bottom: 1px solid #ddd; 
              padding-bottom: 10px; }
        nav a { margin-right: 15px; text-decoration: none; color: #0366d6; }
        .post-item { margin-bottom: 25px; border-bottom: 1px solid #eee; 
                     padding-bottom: 15px; }
        .post-meta { color: #666; font-size: 0.9em; }
        h1 a { text-decoration: none; color: #333; }
        footer { margin-top: 40px; border-top: 1px solid #ddd; 
                 padding-top: 15px; color: #888; font-size: 0.85em; }
    </style>
</head>
<body>
    <nav>
        <a href="{% url 'post_list' %}">Home</a>
        <a href="/admin/">Admin</a>
    </nav>
    
    {% block content %}
    <!-- Page-specific content goes here -->
    {% endblock %}
    
    <footer>
        <p>Built with Django. &copy; {% now "Y" %} My Blog.</p>
    </footer>
</body>
</html>

Create the post list template:

# posts/templates/posts/list.html
{% extends 'posts/base.html' %}

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

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

{% if posts %}
    {% for post in posts %}
        <div class="post-item">
            <h2>
                <a href="{% url 'post_detail' slug=post.slug %}">
                    {{ post.title }}
                </a>
            </h2>
            <p class="post-meta">
                By {{ post.author.username }} on 
                {{ post.published_at|date:"F j, Y" }}
            </p>
            <p>{{ post.excerpt|default:post.body|truncatewords:50 }}</p>
        </div>
    {% endfor %}
{% else %}
    <p>No posts have been published yet. Check back soon!</p>
{% endif %}
{% endblock %}

Create the post detail template:

# posts/templates/posts/detail.html
{% extends 'posts/base.html' %}

{% block title %}{{ post.title }} — My Blog{% endblock %}

{% block content %}
<article>
    <h1>{{ post.title }}</h1>
    
    <p class="post-meta">
        By <strong>{{ post.author.username }}</strong> 
        Published on {{ post.published_at|date:"F j, Y" }}
        {% if post.updated_at != post.published_at %}
            &mdash; Updated {{ post.updated_at|date:"F j, Y" }}
        {% endif %}
    </p>
    
    <div>
        {{ post.body|linebreaks }}
    </div>
    
    <p style="margin-top: 30px;">
        <a href="{% url 'post_list' %}">&larr; Back to all posts</a>
    </p>
</article>
{% endblock %}

Key template concepts demonstrated:

Now visit http://127.0.0.1:8000/ in your browser. You should see your published blog posts listed, and clicking a title takes you to the detail page.

Step 11: The Django Admin — Auto-Generated Interface

Django's admin interface is one of its most powerful features. It automatically generates a CRUD interface based on your models. Register your model in posts/admin.py:

# posts/admin.py
from django.contrib import admin
from .models import Post


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    """Configuration for the Post admin interface."""
    
    # Columns displayed in the list view
    list_display = ['title', 'author', 'status', 'published_at', 'created_at']
    
    # Fields that become clickable links to the detail page
    list_display_links = ['title']
    
    # Filters displayed in the sidebar
    list_filter = ['status', 'author', 'published_at']
    
    # Search fields (searches these fields in the database)
    search_fields = ['title', 'body', 'excerpt']
    
    # Automatically populate slug from title
    prepopulated_fields = {'slug': ('title',)}
    
    # Fields displayed in the edit form, organized into sections
    fieldsets = (
        ('Content', {
            'fields': ('title', 'slug', 'body', 'excerpt')
        }),
        ('Publication', {
            'fields': ('author', 'status', 'published_at')
        }),
    )
    
    # Raw ID fields for ForeignKey with many options
    raw_id_fields = ['author']

Create a superuser to access the admin:

python manage.py createsuperuser

# Follow the prompts:
# Username: admin
# Email address: admin@example.com
# Password: [enter a strong password]
# Password (again): [repeat]
# Superuser created successfully.

Now visit http://127.0.0.1:8000/admin/, log in with your superuser credentials, and you'll see a fully functional admin interface where you can create, edit, and delete posts.

Step 12: Adding Forms for User Input

Django's form system handles rendering HTML forms, validating submitted data, and converting data to Python types. Let's add a comment feature to demonstrate forms.

First, extend posts/models.py with a Comment model:

# Add to posts/models.py
class Comment(models.Model):
    """User comment on a blog post."""
    
    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.title}'

Create the migration and apply it:

python manage.py makemigrations
python manage.py migrate

Now create a form class. You can define forms in posts/forms.py:

# posts/forms.py
from django import forms
from .models import Comment


class CommentForm(forms.ModelForm):
    """Form for submitting comments on blog posts."""
    
    class Meta:
        model = Comment
        fields = ['name', 'email', 'body']
        widgets = {
            'body': forms.Textarea(attrs={'rows': 5, 'cols': 40}),
        }
        labels = {
            'body': 'Your Comment',
        }
    
    def clean_email(self):
        """Custom validation for the email field."""
        email = self.cleaned_data.get('email')
        if email and not '@' in email:
            raise forms.ValidationError('Please enter a valid email address.')
        return email

Step 13: Processing Forms in Views

Update posts/views.py to handle comment submissions on the detail page:

# Updated PostDetailView in posts/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.views import View
from django.urls import reverse
from .models import Post, Comment
from .forms import CommentForm


class PostDetailView(View):
    """Display a single blog post and handle comment submissions."""
    
    def get(self, request, slug):
        post = get_object_or_404(
            Post.objects.select_related('author'),
            slug=slug,
            status='published'
        )
        comments = post.comments.filter(active=True)
        form = CommentForm()
        context = {
            'post': post,
            'comments': comments,
            'form': form,
        }
        return render(request, 'posts/detail.html', context)
    
    def post(self, request, slug):
        post = get_object_or_404(Post, slug=slug, status='published')
        form = CommentForm(request.POST)
        
        if form.is_valid():
            # Create the comment but don't save to DB yet
            comment = form.save(commit=False)
            comment.post = post  # Assign the post relationship
            comment.save()
            return redirect(
                reverse('post_detail', kwargs={'slug': post.slug})
            )
        
        # Form is invalid, re-render with errors
        comments = post.comments.filter(active=True)
        context = {
            'post': post,
            'comments': comments,
            'form': form,
        }
        return render(request, 'posts/detail.html', context)

Update the detail template to include the comments and form:

# Update posts/templates/posts/detail.html — add after the article closing tag
# (but still inside the {% block content %})

<hr style="margin-top: 40px;">
<section>
    <h2>Comments ({{ comments.count }})</h2>
    
    {% if comments %}
        {% for comment in comments %}
            <div style="margin-bottom: 20px; padding: 10px; 
                        background: #f9f9f9; border-radius: 4px;">
                <p><strong>{{ comment.name }}</strong> 
                   <small>{{ comment.created_at|date:"M j, Y" }}</small></p>
                <p>{{ comment.body|linebreaks }}</p>
            </div>
        {% endfor %}
    {% else %}
        <p>No comments yet. Be the first to share your thoughts!</p>
    {% endif %}
</section>

<hr>
<section>
    <h2>Leave a Comment</h2>
    
    <form method="post" action="{{ request.path }}">
        {% csrf_token %}
        
        <div style="margin-bottom: 15px;">
            <label for="{{ form.name.id_for_label }}">Name:</label><br>
            {{ form.name }}
            {% if form.name.errors %}
                <small style="color: red;">{{ form.name.errors }}</small>
            {% endif %}
        </div>
        
        <div style="margin-bottom: 15px;">
            <label for="{{ form.email.id_for_label }}">Email:</label><br>
            {{ form.email }}
            {% if form.email.errors %}
                <small style="color: red;">{{ form.email.errors }}</small>
            {% endif %}
        </div>
        
        <div style="margin-bottom: 15px;">
            <label for="{{ form.body.id_for_label }}">Comment:</label><br>
            {{ form.body }}
            {% if form.body.errors %}
                <small style="color: red;">{{ form.body.errors }}</small>
            {% endif %}
        </div>
        
        <button type="submit">Submit Comment</button>
    </form>
</section>

Critical security note: {% csrf_token %} is mandatory in every POST form. Django validates this token to prevent Cross-Site Request Forgery attacks. If you omit it, submissions will fail with a 403 error.

Step 14: Class-Based Generic Views — Even Less Code

Django ships with generic views that handle common patterns. Let's refactor the post list to use a ListView:

# Alternative posts/views.py using generic views
from django.views.generic import ListView, DetailView
from django.shortcuts import get_object_or_404
from .models import Post


class PostListView(ListView):
    """Generic list view for published blog posts."""
    
    queryset = Post.objects.filter(status='published').select_related('author')
    template_name = 'posts/list.html'
    context_object_name = 'posts'  # Variable name in the template
    paginate_by = 10  # Show 10 posts per page with automatic pagination


class PostDetailView(DetailView):
    """Generic detail view for a single post."""
    
    queryset = Post.objects.filter(status='published').select_related('author')
    template_name = 'posts/detail.html'
    context_object_name = 'post'
    slug_url_kwarg = 'slug'  # The URL capture group name

Generic views reduce boilerplate dramatically. The ListView automatically handles queryset execution, pagination, and context rendering. The DetailView fetches a single object by slug and returns a 404 if not found — all without writing explicit method code.

Step 15: Static Files — CSS, JavaScript, and Images

Static files are CSS, JavaScript, and images that don't change per request. In development, Django serves them automatically from app-level static/ directories.

Create a static directory and a CSS file:

mkdir -p posts/static/posts/css
/* posts/static/posts/css/blog.css */
body {
    font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
    line-height: 1.7;
    color: #333;
}

nav {
    margin-bottom: 30px;
    padding-bottom: 15px;
    border-bottom: 2px solid #e2e8f0;
}

nav a {
    margin-right: 20px;
    text-decoration: none;
    color: #0366d6;
    font-weight: 500;
}

nav a:hover {
    text-decoration: underline;
}

.post-item {
    margin-bottom: 30px;
    padding-bottom: 20px;
    border-bottom: 1px solid #f0f0f0;
}

.post-item h2 {
    margin-bottom: 5px;
}

.post-item h2 a {
    color: #1a1a1a;
    text-decoration: none;
}

.post-item h2 a:hover {
    color: #0366d6;
}

.post-meta {
    color: #666;
    font-size: 0.9em;
    margin-bottom: 10px;
}

article h1 {
    font-size: 2em;
    margin-bottom: 10px;
}

footer {
    margin-top: 50px;
    border-top: 2px solid #e2e8f0;
    padding-top: 20px;
    color: #888;
    font-size: 0.85em;
}

.comment {
    margin-bottom: 20px;
    padding: 15px;
    background: #f8fafc;
    border-radius: 6px;
    border: 1px solid #e2e8f0;
}

form button {
    background: #0366d6;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1em;
}

form button:hover {
    background: #0255b3;
}

.errorlist {
    color: #d73a49;
    list-style: none;
    padding: 0;
    margin: 5px 0 0 0;
    font-size: 0.85em;
}

Update the base template to load static files:

# At the top of posts/templates/posts/base.html, add:
{% load static %}

# Replace the inline <style> block in the <head> with:
<link rel="stylesheet" href="{% static 'posts/css/blog.css' %}">

The {% load static %} tag loads the static file template library, and {% static 'posts/css/blog.css' %} resolves the URL to the actual file path. In production, this URL will point to your CDN or static file server automatically.

Step 16: Testing — Ensuring Your Code Works

Django's test

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles