Skip to content

Django Fundamentals

Django is a Python tool that helps you build websites faster and with less effort. Instead of writing everything from scratch, Django gives you ready-made pieces you can use right away. It follows a pattern called MVT (Model-View-Template), which is just a way to keep your code organized by splitting it into three parts.

Think of Django like a restaurant:

  1. Model - This is the kitchen where all the data lives. It talks to the database (where your information is stored) and knows how to read and save things. In Django, you write models as Python classes.

  2. View - This is the waiter. When a user makes a request (like clicking a button), the View decides what to do - it gets the right data from the Model and sends it to the Template.

  3. Template - This is the plate the food is served on. It’s the HTML page the user actually sees in their browser. Django uses a simple system to fill in the data before showing it to the user.

graph TD Model["Model<br/>(Data Management)"] -->|Interacts with| View["View<br/>(Request Handling)"] View -->|Renders| Template["Template<br/>(Presentation)"] Template -->|Displays| User["User Interface"]

When you start a new Django project, it automatically creates a bunch of files and folders for you. Here’s what they all mean:

myproject/
├── manage.py
├── myproject/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
└── app1/
├── migrations/
│ ├── __init__.py
│ └── 0001_initial.py
├── __init__.py
├── admin.py
├── apps.py
├── models.py
├── form.py
├── tests.py
└── views.py
  • manage.py - Your main helper tool. You run commands like starting the server or creating the database using this file.
  • myproject/ - The main settings folder for your whole project.
  • app1/ - A mini-project inside your big project. Each app handles one specific thing (like a blog, a shop, etc.). You can have many apps.
  • migrations/ - Keeps track of changes you make to your database over time. Think of it as a history of your database.
  • models.py - Where you describe what your data looks like (e.g. a “Post” has a title and a body).
  • views.py - Where you write the logic for what happens when someone visits a page.
  • admin.py - Lets you manage your data from a nice built-in admin panel.
  • settings.py - The main config file. Database, installed apps, security settings - it all lives here.
  • urls.py - Maps web addresses (like /about/) to the right view function.
  • asgi.py and wsgi.py - Entry points that let your Django app talk to a web server.

The settings.py file controls how your Django project behaves. Here’s how to write one that works well for both development (on your computer) and production (on a real server).

Instead of putting passwords and secret keys directly in your code (which is unsafe), you store them in a separate .env file. Django reads them from there using environment variables.

First, install the tool that reads .env files:

Terminal window
uv add python-dotenv

Then in settings.py:

from pathlib import Path
import os
import sys
import dotenv
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
# Load values from .env if file exists
env_path = BASE_DIR / ".env"
if env_path.exists():
dotenv.load_dotenv(env_path)
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ImproperlyConfigured("SECRET_KEY not set")
# Keep this exact pattern for clean environment control
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
PRODUCTION = "production"
ENVIRONMENT = os.getenv("ENVIRONMENT", PRODUCTION).lower()
  • DEBUG = True means you see detailed error pages. Turn it off on a live site.
  • ENVIRONMENT lets you know if you’re running locally or on a real server, so you can behave differently in each case.

Django needs a secret key to keep things like cookies and sessions secure. Never share it publicly. Generate one like this:

from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())

Copy the output and put it in your .env file.

This setting tells Django which website addresses are allowed to use your app. It’s a security check - without it, anyone could pretend to be your site.

ALLOWED_HOSTS = [
host.strip().lower()
for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
]

Every feature you build in Django lives inside an “app”. You need to register each app here so Django knows about it.

INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"accounts.apps.AccountsConfig",
"third_party_app", # add your third-party apps here
"add_custom_apps_here", # add your custom apps here
]

The ones starting with django.contrib are built-in features Django provides out of the box (like user login, admin panel, etc.).

Middleware is code that runs on every single request before it reaches your view, and on every response before it goes back to the user. Think of it as a series of checkpoints.

MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # serve static files efficiently
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Each line is a different security or functionality check. The order matters - they run top to bottom.

This tells Django where to find all your URL rules:

ROOT_URLCONF = "myproject.urls"

Replace myproject with your actual project folder name.

These are just the “doors” that let your Django app connect to a web server. Most beginners don’t need to change this:

WSGI_APPLICATION = "myproject.wsgi.application"

This tells Django where to look for your HTML files:

TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
  • "DIRS": [BASE_DIR / "templates"] - tells Django to look in a templates/ folder in your project root.
  • "APP_DIRS": True - also looks inside each app’s own templates/ folder automatically.

Django needs to know where to store your data. For learning and local development, SQLite is perfect (it’s just a file on your computer). For a real website, use something like MySQL.

DB_ENGINE = os.getenv("DB_ENGINE", "sqlite")
if DB_ENGINE == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME"),
"USER": os.getenv("DB_USER"),
"PASSWORD": os.getenv("DB_PASSWORD"),
"HOST": os.getenv("DB_HOST"),
"PORT": os.getenv("DB_PORT", "3306"),
"OPTIONS": {"charset": "utf8mb4"},
}
}
else:
DATA_DIR = BASE_DIR / "_data"
DATA_DIR.mkdir(exist_ok=True)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
}
}

By reading DB_ENGINE from the environment, the same settings.py works for both local and production without any code changes.

Static files are things like CSS, JavaScript, and images - files that don’t change based on user data.

First, install WhiteNoise (a tool that helps serve these files efficiently):

Terminal window
uv add whitenoise

Add it to middleware (it must go right after SecurityMiddleware):

MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# other middleware ...
]
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

Before deploying, run this command to collect all static files into one folder:

Terminal window
uv run python manage.py collectstatic --noinput

WhiteNoise is great because:

  • It serves your static files directly from Django - no extra server needed.
  • It compresses files so they load faster for users.

Media files are files that users upload (like profile pictures). They’re different from static files because they change over time.

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
  • MEDIA_URL - the web address prefix for uploaded files (e.g. /media/profile.jpg).
  • MEDIA_ROOT - the folder on your server where uploads are actually saved.

These settings control where Django sends users when they log in or out:

LOGIN_URL = "/accounts/login/" # where to go if not logged in
LOGIN_REDIRECT_URL = "/" # where to go after logging in
LOGOUT_REDIRECT_URL = "/accounts/login/" # where to go after logging out
AUTH_USER_MODEL = "appname.User" # use a custom user model

If you’re building an API (using DRF), you can ignore the first three - they’re only for regular HTML websites.

When your site is live, you want extra security turned on. This block only runs when DEBUG is off and you’re in production:

if not DEBUG and ENVIRONMENT == PRODUCTION:
CSRF_TRUSTED_ORIGINS = [
origin for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") if origin
]
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # force HTTPS for 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_SAVE_EVERY_REQUEST = True
X_FRAME_OPTIONS = "DENY"

Each line here closes a common security hole. Don’t worry about memorizing what each one does - just keep this block and it will protect your site.

Logging means recording what your app is doing. It’s like a diary - when something goes wrong, you check the log to see what happened.

Django lets you send logs to:

  • The terminal/console - great while developing
  • A log file - great for production so you can check later

Log levels (from least serious to most serious): DEBUG -> INFO -> WARNING -> ERROR -> CRITICAL

If you set the level to INFO, Django only records INFO and above (not debug noise). If you set it to ERROR, you only get serious problems.

LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
"file": {
"class": "logging.FileHandler",
"filename": LOG_DIR / "app.log",
"formatter": "verbose",
},
"error_file": {
"class": "logging.FileHandler",
"filename": LOG_DIR / "error.log",
"formatter": "verbose",
"level": "ERROR",
},
},
"loggers": {
"": {
"handlers": ["console", "file", "error_file"],
"level": os.getenv("DJANGO_LOG_LEVEL", "INFO").upper(),
},
"appname": {
"handlers": ["console", "file", "error_file"],
"level": os.getenv("DJANGO_LOG_LEVEL", "INFO").upper(),
},
},
"formatters": {
"verbose": {
"format": "{asctime} ({levelname}) - {name}: {message}",
"style": "{",
},
"simple": {
"format": "{levelname} - {message}",
"style": "{",
},
},
}

Here’s what each part does in plain English:

  • handlers - Where do logs go?
    • console -> prints to your terminal while developing
    • file -> saves everything to logs/app.log
    • error_file -> saves only errors to logs/error.log (so serious problems are easy to find)
  • loggers - Which parts of your app get logged? The empty string "" means “everything”. You can also add a specific logger just for your app (replace appname with your app name).
  • formatters - How do the log messages look?
    • verbose -> shows time, level, name, and message (good for files)
    • simple -> shows just level and message (good for terminal)

This is the main urls.py for your project. It connects web addresses to the right views.

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from . import views
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("appname.urls")),
]
handler400 = "appname.views.error_400_view"
handler403 = "appname.views.error_403_view"
handler404 = "appname.views.error_404_view"
handler500 = "appname.views.error_500_view"
if settings.ENVIRONMENT != settings.PRODUCTION:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Things to remember:

  • Replace appname with your actual app name everywhere.
  • The handler404, handler500, etc. let you show a nice custom error page instead of Django’s default ugly ones.
  • The media static() line only works in development. On a real server, use Nginx to serve media files.

When a URL has a variable part (like a user ID or a post slug), you use path converters to tell Django what type of value to expect.

Format: <converter:variable-name>

ConverterTypeExamples
<int:id>Integer5, 100, 999
<str:name>Stringhello, product-name
<slug:slug>URL-safehello-world
<uuid:code>UUID550e8400-…

Example:

from django.urls import path
from . import views
urlpatterns = [
path("users/<int:id>/", views.user_detail, name="user-detail"),
path("categories/<str:name>/", views.category_detail, name="category-detail"),
path("posts/<slug:slug>/", views.post_detail, name="post-detail"),
path("invoices/<uuid:code>/", views.invoice_detail, name="invoice-detail"),
]

Django will check that /users/abc/ doesn’t match <int:id> because abc is not a number. This saves you from writing that check yourself.

A view is just a Python function (or class) that:

  1. Gets a request from the user
  2. Does some work (fetch data, check login, etc.)
  3. Returns a response (an HTML page, JSON data, a redirect, etc.)

Django has two styles of views:

  • Function-Based Views (FBV) - simple functions
  • Class-Based Views (CBV) - organized into classes

Both work great. Use whichever feels clearer to you.

When a user visits a URL with filters (like /products/?search=phone), the data after ? is called query params. You read them with request.GET.

When a user submits a form (like a login form), the data is sent as a POST request. You read it with request.POST.

Example URL: /products/?search=phone&category=electronics&page=2

def product_list(request):
search = request.GET.get("search", "")
category = request.GET.get("category")
page = request.GET.get("page", "1")
# For repeated values like: /products/?tag=python&tag=django
tags = request.GET.getlist("tag")
return JsonResponse(
{
"search": search,
"category": category,
"page": page,
"tags": tags,
}
)

For a form submission:

from django.shortcuts import redirect, render
def contact_submit(request):
if request.method == "POST":
name = request.POST.get("name", "").strip()
email = request.POST.get("email", "").strip().lower()
message = request.POST.get("message", "").strip()
if not name or not email:
return render(request, "contact.html", {"error": "Name and email are required."})
# save/send data
return redirect("contact-success")
return render(request, "contact.html")

Good habits:

  • Always use .get("key", default) so your code doesn’t crash if the key is missing.
  • Treat request.GET and request.POST data as untrusted - never trust user input directly.
  • For real forms, use Django Forms (is_valid() and cleaned_data) for proper validation.

FBVs are plain Python functions. They’re great when the logic is simple and you want full control.

from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.http import JsonResponse
from .models import Article
@login_required
def article_detail(request, slug):
article = get_object_or_404(Article, slug=slug)
if request.method == "POST":
article.views_count += 1
article.save(update_fields=["views_count"])
return redirect("article-detail", slug=article.slug)
context = {"article": article}
return render(request, "articles/detail.html", context)
def article_api(request):
data = {
"total_articles": Article.objects.count(),
"status": "ok",
}
return JsonResponse(data, status=200)

What’s happening here:

  • @login_required - blocks the page if the user is not logged in
  • get_object_or_404() - tries to find the article; shows a 404 page if not found
  • request.method == "POST" - separates read (GET) and write (POST) actions
  • redirect() - sends the user to another page after saving (prevents double-submit on refresh)
  • JsonResponse - returns data as JSON, useful for simple APIs

Use FBVs when:

  • The logic is short or unique
  • You want to keep things simple and readable

CBVs group related actions (list, detail, create, etc.) into one class. Django gives you ready-made generic classes so you write less code.

from django.http import HttpResponse
from django.views import View
class HelloView(View):
def get(self, request):
return HttpResponse("Hello from GET")
def post(self, request):
return HttpResponse("Hello from POST")

Each HTTP method (GET, POST, etc.) becomes its own method in the class.

from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView, DetailView, ListView
from .models import Article
class ArticleListView(ListView):
model = Article
template_name = "articles/list.html"
context_object_name = "articles"
paginate_by = 10
class ArticleDetailView(DetailView):
model = Article
template_name = "articles/detail.html"
context_object_name = "article"
slug_field = "slug"
slug_url_kwarg = "slug"
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
fields = ["title", "slug", "content"]
template_name = "articles/form.html"
success_url = reverse_lazy("article-list")
  • ListView - shows a list of items with automatic pagination
  • DetailView - shows one item (looks it up by slug)
  • CreateView - shows a form and saves a new item when submitted
  • LoginRequiredMixin - blocks the page if not logged in (CBV version of @login_required)

Use CBVs when:

  • You’re building many similar pages (list, detail, create, edit, delete)
  • You want to reuse logic across views using mixins

Decorators are small add-ons you put above a view function to change how it behaves.

Only lets logged-in users see the page:

from django.contrib.auth.decorators import login_required
@login_required(login_url="/accounts/login/")
def dashboard(request):
return render(request, "accounts/dashboard.html")

Turns off CSRF protection for one view. Only use this for trusted webhook endpoints:

from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def webhook_receiver(request):
return JsonResponse({"received": True})

Don’t use @csrf_exempt on regular pages - it removes an important security check.

Other useful decorators:

  • @require_http_methods(["GET", "POST"]) - only allow specific HTTP methods
  • @require_POST - only allow POST requests
  • @permission_required("app_label.permission_name") - check for a specific permission
  • @cache_page(60 * 5) - cache the page for 5 minutes

Django gives you a few shortcuts that you’ll use all the time:

Fills in an HTML template with data and returns it to the user:

from django.shortcuts import render
def profile(request):
return render(request, "accounts/profile.html", {"user": request.user})

Sends the user to a different page:

from django.shortcuts import redirect
def go_home(request):
return redirect("home")

Returns data as JSON (useful for APIs):

from django.http import JsonResponse
def health_check(request):
return JsonResponse({"ok": True, "service": "django-app"})

Finds one object in the database, or shows a 404 page if it doesn’t exist:

from django.shortcuts import get_object_or_404
article = get_object_or_404(Article, slug=slug)

This is much cleaner than writing a try/except block yourself.

from django.urls import path
from .views import ArticleDetailView, ArticleListView, article_detail
urlpatterns = [
path("articles/", ArticleListView.as_view(), name="article-list"),
path("articles/<slug:slug>/", ArticleDetailView.as_view(), name="article-detail"),
path("legacy-article/<slug:slug>/", article_detail, name="legacy-article-detail"),
]

Key rule: CBVs need .as_view() at the end. FBVs are passed directly.

Good URL habits:

  • Always give your URLs a name= - you can use it in templates and redirects instead of hardcoding the path.
  • Use slugs or UUIDs in public URLs instead of database IDs for better security and readability.

CORS is a browser security feature. By default, browsers block JavaScript from one website from talking to a different website’s API. CORS rules let you decide which websites are allowed to call your Django API.

For example, if your frontend is on http://localhost:3000 and your Django API is on http://localhost:8000, without CORS the browser will block the request.

  1. Install the package:

    Terminal window
    uv add django-cors-headers
  2. Add it to your INSTALLED_APPS and MIDDLEWARE in settings.py:

    INSTALLED_APPS = [
    # other apps
    "corsheaders",
    ]
    MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware", # must be high in the list
    # other middleware
    ]
  3. Tell Django which origins (websites) are allowed to call your API:

    CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000",
    ]