Django Views, URLs, and Templates
Wire Django models to web pages — function-based views, render(), URL routing with path(), template tags like {% for %} and {% if %}, and template inheritance with {% extends %}.
What you'll learn
- ✓How function-based views receive a request and return a response
- ✓How render() ties views, templates, and context together
- ✓How path() routes URLs to views, with captured parameters
- ✓How to use {% for %} and {% if %} inside templates
- ✓How template inheritance with {% extends %} keeps layouts DRY
- ✓How to link to URLs by name with the {% url %} tag
Prerequisites
- •A working model — see Django Models and the ORM
- •Comfortable reading basic HTML
Models give you data. Views decide what to do with a request, and templates turn that data into HTML. Together with the URL configuration, they form the entire request-handling pipeline of a Django site.
This post covers function-based views, URL routing, and the most important template tags. By the end you’ll have a working list-and-detail page combo backed by a real model.
If your model isn’t set up yet, work through Django Models and the ORM first.
Function-based views
The simplest Django view is a function that takes a request and returns a response:
# blog/views.py
from django.http import HttpResponse
def home(request):
return HttpResponse("Hello!")
That’s a valid view. The request is a HttpRequest instance carrying everything about the incoming request — method, headers, GET/POST data, the logged-in user. The return value must be an HttpResponse (or a subclass like JsonResponse or redirect).
You won’t write raw HttpResponse for long. Real views render templates.
render
render is the workhorse helper. It takes a request, a template name, and a context dictionary, and returns a rendered HttpResponse:
# blog/views.py
from django.shortcuts import render
from .models import Post
def post_list(request):
posts = Post.objects.filter(status="published").order_by("-created_at")
return render(request, "blog/post_list.html", {"posts": posts})
Three things to notice:
- The QuerySet doesn’t hit the database yet. It runs when the template iterates over it.
- The template name is a path relative to a templates directory (we’ll set that up below).
- The context dict is what becomes available inside the template — keys become variable names.
URL routing
Django matches incoming URLs to views using the URL configuration. The project’s root urls.py typically delegates to per-app files:
# mysite/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")),
]
And in the app:
# blog/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.post_list, name="post_list"),
path("<slug:slug>/", views.post_detail, name="post_detail"),
]
A few things deserve attention:
path("blog/", include(...))— anything starting with/blog/is handed toblog.urls.<slug:slug>— captures a URL segment and passes it as a keyword argument. The firstslugis the converter type; the second is the argument name. Available converters:int,str,slug,uuid,path.name=— every URL pattern should have a name. You reference URLs by name in templates and code, never by hardcoded string. Refactoring URLs becomes painless.
A detail view
Continuing the example:
# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post
def post_detail(request, slug):
# get_object_or_404 fetches the row or raises Http404
post = get_object_or_404(Post, slug=slug, status="published")
return render(request, "blog/post_detail.html", {"post": post})
get_object_or_404 is the right tool for “fetch by primary key or slug or 404.” Don’t reach for Post.objects.get in a view — an unmatched query would raise DoesNotExist, which renders as a 500 error instead of a clean 404.
Where templates live
Django looks for templates in two places:
- A
templates/directory inside each installed app. - A project-wide
templates/directory listed inTEMPLATESinsettings.py.
By convention, you create blog/templates/blog/post_list.html — note the nested blog/ folder. That namespace prevents collisions when two apps both have a post_list.html.
To enable a project-wide templates directory for the base layout, edit settings.py:
# mysite/settings.py
import os
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")], # add this
"APP_DIRS": True,
# ...
},
]
Then templates/base.html at the project root is available to every app.
A base template
Almost every Django site has a single base.html that defines the page chrome — <head>, navigation, footer — with {% block %} placeholders where each page slots its content:
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{% block title %}My Site{% endblock %}</title>
</head>
<body>
<header>
<a href="{% url 'post_list' %}">Home</a>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>© 2026</footer>
</body>
</html>
Two new template tags appear here:
{% block %}— defines a named region a child template can override.{% url 'post_list' %}— reverses a named URL into a path. If you ever change the URL ofpost_list, every template using{% url 'post_list' %}updates automatically.
Extending a template
Child templates declare what they’re extending and fill in the blocks:
<!-- blog/templates/blog/post_list.html -->
{% extends "base.html" %}
{% block title %}Latest posts · My Site{% endblock %}
{% block content %}
<h1>Latest posts</h1>
{% if posts %}
<ul>
{% for post in posts %}
<li>
<a href="{% url 'post_detail' post.slug %}">{{ post.title }}</a>
<small>{{ post.created_at|date:"j M Y" }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p>No posts yet.</p>
{% endif %}
{% endblock %}
What’s happening:
{% extends "base.html" %}— must be the first line. Pulls in the base layout.{% block content %}— overrides the matching block in the base.{% for ... %}/{% endfor %}— loops, like Python but with explicit closing tags.{% if ... %}/{% else %}/{% endif %}— conditionals.{{ post.title }}— outputs a variable, autoescaped against XSS.|date:"j M Y"— a filter. Many built-in filters exist:upper,lower,truncatechars,length,default,pluralize.{% url 'post_detail' post.slug %}— passes positional arguments to the URL reverse.
The detail template
<!-- blog/templates/blog/post_detail.html -->
{% extends "base.html" %}
{% block title %}{{ post.title }} · My Site{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<p><em>By {{ post.author.username }} on {{ post.created_at|date:"j M Y" }}</em></p>
{{ post.body|linebreaks }}
</article>
<p><a href="{% url 'post_list' %}">← Back to all posts</a></p>
{% endblock %}
linebreaks is a handy filter that wraps plain-text paragraphs in <p> tags and turns single newlines into <br>. For richer content, use a Markdown filter or store HTML directly with |safe (carefully).
Useful template tags and filters
A short cheatsheet you’ll reach for constantly:
<!-- Loops -->
{% for item in items %}
{{ forloop.counter }}. {{ item }}
{% empty %}
<p>Nothing here.</p>
{% endfor %}
<!-- Conditionals -->
{% if user.is_authenticated %}
Welcome, {{ user.username }}!
{% elif user.is_anonymous %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}
<!-- Include another template -->
{% include "blog/_post_card.html" with post=post %}
<!-- Comments -->
{# This is invisible in the rendered HTML #}
<!-- Filters -->
{{ title|upper }}
{{ body|truncatechars:200 }}
{{ items|length }}
{{ value|default:"unknown" }}
{{ count|pluralize }} <!-- "1 post" vs "2 posts" -->
{{ created_at|date:"Y-m-d" }}
{{ created_at|timesince }} <!-- "3 hours ago" -->
Try it yourself. Add a {% if posts %}{% else %} branch to your post_list.html that shows a friendly message when there are no posts. Then unpublish every post in your database and reload the page — you should see your fallback message. Re-publish one and the list reappears.
Handling form submissions
A peek at what a write-view looks like:
# blog/views.py
from django.shortcuts import redirect, render
from django.contrib.auth.decorators import login_required
from .models import Post
@login_required
def post_create(request):
if request.method == "POST":
# Pull values from request.POST and create the row
title = request.POST["title"]
body = request.POST["body"]
post = Post.objects.create(
author=request.user, title=title, body=body, slug=title.lower().replace(" ", "-")
)
# PRG pattern: redirect after a successful POST
return redirect("post_detail", slug=post.slug)
# GET: render an empty form
return render(request, "blog/post_form.html")
Patterns to notice:
request.method— branch on"GET"vs"POST".@login_required— decorator that bounces anonymous users to the login page.- PRG (Post-Redirect-Get) — after a successful write, redirect. Otherwise refreshing the page resubmits the form.
For real forms, use Django’s Form or ModelForm classes — they handle validation, error rendering, and CSRF. We’ll cover them in a later post.
URL patterns in depth
A few more path() patterns you’ll see:
# blog/urls.py
urlpatterns = [
path("", views.post_list, name="post_list"),
path("new/", views.post_create, name="post_create"),
path("<int:year>/", views.posts_by_year, name="posts_by_year"),
path("<slug:slug>/", views.post_detail, name="post_detail"),
path("<slug:slug>/edit/", views.post_edit, name="post_edit"),
]
Order matters: Django tries patterns top to bottom and uses the first match. Static segments (new/) should come before catch-all converters (<slug:slug>/).
Try it yourself. Add a posts_by_year view that filters posts by created_at__year=year and reuses your post_list.html template (pass the filtered queryset as posts). Wire it up at path("<int:year>/", ...). Visit /blog/2026/ and confirm only the matching posts render.
Class-based views (a brief note)
Django also offers class-based views — pre-built classes for common patterns like ListView and DetailView:
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
queryset = Post.objects.filter(status="published")
template_name = "blog/post_list.html"
context_object_name = "posts"
They’re concise once you know them, but the abstraction can be hard to debug. Start with function-based views, learn class-based ones when you find yourself repeating the same boilerplate.
Recap
You now know:
- A view is a function that takes a
HttpRequestand returns aHttpResponse render(request, template, context)is the workhorse view helperpath()wires URLs to views with typed converters and aname={% extends %}+{% block %}keep layouts DRY{% for %},{% if %}, and{{ ... }}are the main template constructs{% url %}reverses named URLs so refactoring is safeget_object_or_404is the right way to fetch-or-404 in detail views
Next steps
You have data, views, and pages. The next post — The Django Admin — shows how to expose every model through a polished CRUD interface in about three lines of code. It’s the single feature that sells Django to most teams.
Related: What Is Django?, Django Models and the ORM.
Questions or feedback? Email codeloomdevv@gmail.com.