A practical, step-by-step reference for building a Django web application from scratch — environment setup, routing, templates, forms, models, authentication, file uploads, error handling, and deployment to Render.
This guide is written as a working reference for Django beginners and as a personal knowledge base compiled while learning the framework hands-on.
- Django Setup & Development Guide
- Table of Contents
- 1. Prerequisites
- 2. Environment Setup
- 3. Creating the Django Project
- 4. Creating and Registering an App
- 5. Templates
- 6. Views
- 7. URL Routing & Linking Pages
- 8. Template Inheritance with Blocks
- 9. Handling Form Submissions
- 10. Displaying Backend Messages
- 11. Redirects
- 12. Class-Based Views
- 13. Models & the Database
- 14. Admin Panel
- 15. User Authentication (Signup, Login, Logout)
- 16. Restricting Access to Logged-In Users
- 17. Static Files (CSS, JS, Images)
- 18. Media & File Uploads
- 19. .gitignore
- 20. Rendering and Linking Model Data
- 21. Editing Existing Records
- 22. JSON Responses
- 23. Readable Admin Labels
- 24. Slugs (SEO-Friendly URLs)
- 25. Custom Error Pages (404 / 500)
- 26. Secret Keys & Environment Variables
- 27. Cloud Storage for Media
- 28. Deploying to Render
- 29. Serving Static Files in Production (WhiteNoise)
Open Command Prompt and run:
python --versionIf Python isn't installed, download the latest version from python.org and run the installer. Accept all the default prompts during installation.
Reopen Command Prompt afterward and re-run python --version to confirm the installation succeeded.
pip --versionpip install virtualenvWhy use a virtual environment? A virtual environment isolates the dependencies for each project. For example, if Project A uses Python 3 and Project B uses a newer version, a virtual environment keeps their packages separate so that installing or updating one project never breaks the other.
Run this in your project's terminal (VS Code or otherwise). You can name it anything, but env or venv is recommended — .gitignore templates for Django already exclude env by default. If you use a different name, you'll need to add it to .gitignore manually.
python -m venv envWindows:
env\scripts\activatemacOS / Linux:
source env/bin/activateNote: If Windows blocks the script with a "disabled" error, this is usually caused by PowerShell's execution policy. Switch to Command Prompt and run the activation command again.
Once active, your terminal prompt will be prefixed with (env), confirming you're working inside the virtual environment — for example:
(env) PS C:\Users\user\OneDrive\Desktop\PYTHON>Install all project packages while this prefix is visible. You'll need to reactivate the environment (env\scripts\activate) every time you return to the project in a new terminal session.
Install Django:
pip install djangoCreate the project:
django-admin startproject projectname .The trailing
.matters — without it, Django creates a nested project folder instead of placing files in the current directory.
Do not modify manage.py.
# Single-line comment
"""
Multi-line comment
or docstring
"""python manage.py runserverThen open the printed local address in your browser. Press Ctrl + C to stop the server.
Security tip: Change the default
admin/path inurls.pyto something less predictable, to reduce the risk of automated attacks:urlpatterns = [ path('anynameyouwant/', admin.site.urls), ]
Create an app:
django-admin startapp appnameor
python manage.py startapp appnameIn the project's settings.py, add the app to INSTALLED_APPS:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'apptest',
]In the app folder, create urls.py:
from django.urls import path
urlpatterns = [
# app-specific paths go here
]In the project's urls.py, include the app's URLs:
from django.urls import path, include
urlpatterns = [
path('', include('apptest.urls')), # root routes to the app
path('apptest/', include('apptest.urls')), # or under a prefix
]In settings.py, under the TEMPLATES setting, either point 'DIRS': [] to a shared templates folder, or — the more common approach — create a templates/ folder inside each app so every app owns its own templates.
Create your .html files inside that folder.
Django supports two styles of views:
- Function-based views (FBVs) — simpler to learn, but require more repeated code.
- Class-based views (CBVs) — a slightly steeper learning curve, but more concise (see Section 12).
def homepage(request):
return render(request, 'home.html')Import the view and register its path:
from apptest.views import homepage
urlpatterns = [
path('', homepage),
]- Create the new template in the templates folder.
- Define the view:
def aboutpage(request): return render(request, 'about.html')
- Register the path:
path('about', aboutpage)
Name your URL in urls.py:
path('', homepage, name='home')Then reference it in a template using the url tag rather than a hardcoded path:
<a href="{% url 'about' %}">About Her</a>Blocks let child templates override specific sections of a shared base template — ideal for elements common to every page, like the title or main content area, while still letting each page customize its own content.
base.html:
<title>{% block titlename %}{% endblock titlename %}</title>Pages that extend base.html don't need to repeat the <!DOCTYPE> or other boilerplate — they inherit everything automatically:
{% extends 'base.html' %}
{% block title %}Homepage{% endblock title %}
{% block main %}
<h1>A girl named Fola</h1>
<a href="{% url 'about' %}">About Her</a>
{% endblock main %}Use {% include %} to embed one template inside another — useful for reusable sections like a testimonials block on the homepage:
{% include 'testimony.html' %}Avoid the GET method for forms with sensitive data — it exposes all submitted values in the URL. Use POST instead, along with {% csrf_token %} in the form to protect against cross-site request forgery. Django generates a hidden, unique token automatically.
def contactpage(request):
if request.method == "POST":
fullname = request.POST["fullname"]
print(f'User submitted name {fullname}')
return render(request, 'contact.html')
return render(request, 'contact.html')Prefer
.get()over direct key access. Accessingrequest.POST["key"]raises an error if the key is missing.request.POST.get("key")returnsNoneinstead, so validation logic can run without crashing:
def contactpage(request):
if request.method == "POST":
fullname = request.POST.get("fullname")
email = request.POST.get("email")
about = request.POST.get("about")
print(f'User submitted name {fullname}, about {about}')Django's built-in messages framework (already part of INSTALLED_APPS) sends feedback from the backend to the frontend.
from django.contrib import messages
def contactpage(request):
if request.method == "POST":
fullname = request.POST.get("fullname")
email = request.POST.get("email")
about = request.POST.get("about")
if not fullname:
messages.error(request, "Please provide your full name")
return render(request, 'contact.html')
if not email or not about:
messages.error(request, "All fields are required")
return render(request, 'contact.html')
if len(fullname) < 5:
messages.error(request, "Name is too short")
return render(request, 'contact.html')
messages.success(request, "Details submitted")Django templates use the Jinja-style template language, similar in spirit to how React uses JSX.
Since every page inherits from base.html, placing the message loop there makes feedback appear on any page:
<div class="error_container">
{% for msg in messages %}
<p>{{ msg }}</p>
{% endfor %}
</div>from django.shortcuts import render, redirect, resolve_url
return redirect(homepage)
# or, when the target view lives in a different file:
return redirect(resolve_url('home'))from django.views import View
class Contactview(View):
def get(self, request):
return render(request, 'contact.html')
def post(self, request):
return render(request, 'contact.html')Class names should use PascalCase (first letter capitalized).
Import and register the class-based view:
from apptest.views import homepage, aboutpage, contactpage, testimonialpage, ContactView
path('contact', ContactView.as_view(), name='contact')python manage.py checkThis check isn't exhaustive — it won't catch every possible error.
Define a model:
from django.db import models
class ContactMessage(models.Model):
fullname = models.CharField(max_length=250)
email = models.EmailField()Django automatically adds an
idfield to every model.
Generate and apply migrations whenever a model is created or changed:
python manage.py makemigrations
python manage.py migrateRunning migrate also clears warnings like:
You have 19 unapplied migration(s). Your project may not work properly
until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
from app.models import ModelName
ModelName.objects.create(databaseField=value)
# e.g.
ContactMessage.objects.create(fullname=fullname, email=email, about=about)To avoid predictable, sequential IDs:
from django.db import models
import uuid
class Product(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
name = models.CharField(max_length=250)
description = models.TextField()
price = models.PositiveBigIntegerField()
quantity = models.PositiveIntegerField()
image = models.ImageField()If, for example, products are posted by different users:
from django.contrib.auth.models import User
user = models.ForeignKey(User, on_delete=models.CASCADE)Open the admin path defined in your project's urls.py to access the dashboard.
Django has three user types: SuperUser, StaffUser, and NormalUser.
Create a superuser:
python manage.py createsuperuserYou'll be prompted for a username, email, and password (input is hidden while typing).
If prompted with a "password not strong enough" warning, type
yto bypass it if desired.
Models aren't visible in the admin panel by default. Register them in admin.py:
from app.models import ModelName
admin.site.register(ModelName)Add a __str__ method so records display meaningfully in the admin list, rather than as generic objects:
class ContactMessage(models.Model):
fullname = models.CharField(max_length=250)
email = models.EmailField()
def __str__(self):
return f'Name: {self.fullname} Email: {self.email}'Create an authz (or similarly named) app dedicated to authentication, with a signup.html template and a class-based view.
from django.contrib.auth.models import User
from django.contrib import messages
class SignupView(View):
def get(self, request):
return render(request, 'signup.html')
def post(self, request):
username = request.POST.get("username")
email = request.POST.get("email")
firstname = request.POST.get("firstname")
lastname = request.POST.get("lastname")
password = request.POST.get("password")
if not username or not email or not firstname or not lastname or not password:
messages.error(request, "All fields are required")
return render(request, 'signup.html')
if len(password) < 8:
messages.error(request, "Password is too short")
return render(request, 'signup.html')
# Normalize case to prevent duplicate accounts differing only by case
username = username.lower()
email = email.lower()
if User.objects.filter(username=username).exists():
messages.error(request, "Username is taken")
return render(request, 'signup.html')
if User.objects.filter(email=email).exists():
messages.error(request, "Email already exists")
return render(request, 'signup.html')
user = User.objects.create(
username=username,
email=email,
first_name=firstname,
last_name=lastname,
)
# Passwords are always hashed — never assign them directly on create()
user.set_password(password)
user.save()Inspect Django's built-in
Usermodel (Ctrl+Click onUserin your editor) to confirm exact field names, such asfirst_name.
Register the view:
from authz.views import SignupView
path('signup', SignupView.as_view(), name='signup')Add a link to signup in your site navigation (base.html).
from django.contrib.auth import login, logout, authenticate
def Loginview(request):
if request.method == "POST":
username = request.POST.get("username")
password = request.POST.get("password")
if not username or not password:
messages.error(request, "All fields are required")
return render(request, 'login.html')
username = username.lower()
username_exists = User.objects.filter(username=username).first()
if not username_exists:
messages.error(request, "Invalid login credentials")
return render(request, 'login.html')
user = authenticate(username=username, password=password)
login(request, user)
messages.success(request, "Successful Login")
return redirect(resolve_url('home'))
return render(request, 'login.html')def Logoutview(request):
logout(request)
return render(request, 'login.html')Register both paths in urls.py and add them to the site navigation.
{% if user.is_authenticated %}
<li><a href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li><a href="{% url 'signup' %}">Signup</a></li>
<li><a href="{% url 'login' %}">Login</a></li>
{% endif %}from django.contrib.auth.decorators import login_required
@login_required
def homepage(request):
return render(request, 'home.html')Tell Django where to send unauthenticated users, in settings.py:
LOGIN_URL = 'authz/login'Capture the next parameter so users land on the page they originally tried to access after logging in:
def Loginview(request):
if request.method == "POST":
next_page = request.GET.get('next')
username = request.POST.get("username")
password = request.POST.get("password")
if not username or not password:
messages.error(request, "All fields are required")
return render(request, "login.html")Create a static/ folder at the project root for CSS, JS, and images.
Load the static template tag at the top of the page, before <!DOCTYPE>, and link the stylesheet:
{% load static %}
<link rel="stylesheet" href="{% static 'css/style.css' %}">In settings.py, after the pathlib import, add:
import os
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)For per-page styling, define a style block in the base template and override it per page. Remember to load static below extends:
{% extends 'base.html' %}
{% load static %}Referencing an image:
<img src="{% static 'assets/6.png' %}" alt="contact image" width="250px">Create a media/ folder at the project root, then configure it in settings.py:
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')image = models.ImageField(upload_to="product/")upload_to organizes uploads into subfolders inside media/ — useful since uploads can originate from different parts of the app.
Track creation and update timestamps:
created_at = models.DateTimeField(auto_now_add=True) # newest first
updated_at = models.DateTimeField(auto_now=True)Any model with an image or file field requires Pillow:
pip install pillow
Generate a .gitignore file at gitignore.io for your stack (e.g., Django, React), and paste its contents into your project.
Pass model data to a template via a context dictionary:
class Products(LoginRequiredMixin, View):
def get(self, request):
all_products = Product.objects.all().order_by('-created_at')
context = {'all_productskey': all_products}
return render(request, 'products.html', context)Loop through it in the template:
<div class="all_products">
{% for prod in all_productskey %}
<div class="product-card">
<h3>Name: {{ prod.name }}</h3>
<img src="{{ prod.image.url }}" alt="{{ prod.name }}">
<p>Description: {{ prod.description }}</p>
<p>Quantity: {{ prod.quantity }}</p>
<p>Seller: {{ prod.user.username }}</p>
</div>
{% endfor %}
</div>Images not displaying? Add media URL handling to your project's
urls.py:from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Forms that upload files must include enctype="multipart/form-data", or the file data won't be sent.
Use a dynamic URL segment for the record's ID:
path('edit-product/<str:product_id>', EditProduct.as_view(), name='edit-product')Verify ownership before allowing edits:
class EditProduct(LoginRequiredMixin, View):
def get(self, request, product_id):
product = Product.objects.filter(id=product_id).first()
if not product:
return redirect(resolve_url("products"))
if product.user != request.user:
return redirect(resolve_url("products"))
context = {"product": product}
return render(request, 'edit_product.html', context)
def post(self, request, product_id):
product = Product.objects.filter(id=product_id).first()
if not product:
return redirect(resolve_url("products"))
if product.user != request.user:
return redirect(resolve_url("products"))
name = request.POST.get("name")
description = request.POST.get("description")
quantity = request.POST.get("quantity")
price = request.POST.get("price")
image = request.FILES.get("image")
product.name = name or product.name
product.description = description or product.description
product.price = price or product.price
product.quantity = quantity or product.quantity
product.image = image or product.image
product.save()
messages.success(request, "Product successfully updated")
return redirect(resolve_url("products"))Pre-populate the edit form with existing values:
<label for="">Name</label><br>
<input type="text" name="name" required value="{{ product.name }}"><br>
<label for="">Description</label><br>
<textarea name="description">{{ product.description }}</textarea><br>Return a JSON list of records — useful for API-style endpoints.
from django.http import JsonResponse
from django.forms import model_to_dict
def list_products(request):
all_products = Product.objects.all()
data = [
{'name': x.name, 'id': x.id, 'quantity': x.quantity, 'image': x.image.url}
for x in all_products
]
return JsonResponse(data, safe=False)Register the view:
path("products/all", list_products, name="all-prod")Add a __str__ method to any model so it's easy to identify in the admin dashboard:
def __str__(self):
return f'{self.name} || {self.id}'Slugs make URLs more readable and search-engine friendly:
Without slug: 127.0.0.1:8000/members/details/1
With slug: 127.0.0.1:8000/members/details/emil-refsnes
Add a slug field to the model:
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True, blank=True)Run migrations after adding the field.
To auto-populate the slug from another field (e.g., a title), configure it in admin.py:
class PostAdmin(admin.ModelAdmin):
list_display = ('title', 'author', 'status', 'featured', 'created_at')
prepopulated_fields = {'slug': ('title',)}
list_filter = ('status', 'featured', 'category')
search_fields = ('title', 'body')The slug is automatically "slugified" — hyphens replace spaces between words.
In templates:
{% for category in categories %}
<a href="{% url 'category' category.slug %}" class="category-chip">{{ category.name }}</a>
{% endfor %}
<h4><a href="{% url 'detail' post.slug %}">{{ post.title }}</a></h4>
<a href="{% url 'post_update' post.slug %}" class="btn-edit-action">Modify Content</a>In urls.py, switch from <int:id> to <slug:slug>:
path('', views.index_view, name='index'),
path('post/write/', views.PostCreateView.as_view(), name='post_create'),
path('post/<slug:slug>/', views.detail_view, name='detail'),
path('post/<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post_update'),
path('post/<slug:slug>/delete/', views.PostDeleteView.as_view(), name='post_delete'),
path('category/<slug:slug>/', views.category_view, name='category'),In views.py, handle the slug instead of an ID:
def category_view(request, slug):
category = get_object_or_404(Category, slug=slug)
posts = Post.objects.filter(category=category, status='published')
return render(request, 'blog/category.html', {'category': category, 'posts': posts})Create a template, e.g. error_404.html:
<h1>Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
<a href="{% url 'home' %}">
<button>Go Home</button>
</a>4xx errors originate on the client side (e.g., a broken or mistyped link). 5xx errors originate on the server side (e.g., an unhandled exception in your code).
Define the handler view:
def error_404(request, exception):
return render(request, 'error_404.html', status=404)Wire it up at the project level in urls.py:
handler404 = 'product.views.error_404'
handler500 = 'product.views.error_500'Before deploying:
- Replace
SECRET_KEYinsettings.pywith a securely generated value — for example, via Djecrety. - Set
DEBUG = False. - Replace the local
db.sqlite3database with a production-ready one, e.g. Neon or Supabase. - Install the PostgreSQL driver:
pip install psycopg2
Create a .env file in the project root (alongside manage.py) to store secret values. Since .env is listed in .gitignore, it won't be pushed to GitHub.
Install the packages needed to read it:
pip install python-decouple
pip install dj-database-urlIn settings.py:
from decouple import config
import dj_database_url
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)
DATABASES = {
'default': dj_database_url.parse(config('DATABASE_URL'))
}Add your database connection URL (from Neon, Supabase, etc.) to .env as well.
If your server doesn't recognize the new database, deactivate the virtual environment, open a fresh terminal, and reactivate it.
Since the online database is new, migrations must be re-applied:
python manage.py migrate
python manage.py createsuperuserFor production, store uploaded media in the cloud rather than locally — options include Cloudinary and Google Cloud Storage.
- Install Gunicorn (the WSGI server Render uses) — alternatives include Uvicorn or Daphne for ASGI:
pip install gunicorn
- Generate
requirements.txtfrom the project root:Re-run this command whenever new packages are installed.pip freeze > requirements.txt - Push to GitHub:
git add . git commit -m "Added requirements.txt" git push
- In the Render dashboard: New + → Web Service → connect your GitHub repo.
- Set the runtime to Python.
- Start command:
gunicorn project-name.wsgi:application
- Environment variables: import directly from your
.envfile by copying and pasting it to render (including the secret key). - Update
ALLOWED_HOSTSinsettings.py:Or, to allow any Render subdomain:ALLOWED_HOSTS = [ "django-python-test.onrender.com", "localhost", "127.0.0.1", ]
ALLOWED_HOSTS = [ "django-python-test.onrender.com", ".onrender.com", ]
- Set
DEBUG = False. - Push the changes:
git add . git commit -m "Configured ALLOWED_HOSTS for Render" git push
If static files don't display correctly after deployment, configure WhiteNoise.
- Confirm
STATIC_ROOTis set insettings.py:STATIC_ROOT = BASE_DIR / "static"
- Add the WhiteNoise middleware directly after
SecurityMiddleware:MIDDLEWARE = [ # ... "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", # ... ]
- Install WhiteNoise and update dependencies:
pip install whitenoise pip freeze > requirements.txt - Commit and push the changes.
This guide is a living document, updated as new concepts and patterns are learned.