Skip to content
C Codeloom
Django

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 %}.

·9 min read · By Yash Kesharwani
Intermediate 12 min read

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 to blog.urls.
  • <slug:slug> — captures a URL segment and passes it as a keyword argument. The first slug is 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:

  1. A templates/ directory inside each installed app.
  2. A project-wide templates/ directory listed in TEMPLATES in settings.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>&copy; 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 of post_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 &middot; 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 }} &middot; 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' %}">&larr; 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 HttpRequest and returns a HttpResponse
  • render(request, template, context) is the workhorse view helper
  • path() wires URLs to views with typed converters and a name=
  • {% extends %} + {% block %} keep layouts DRY
  • {% for %}, {% if %}, and {{ ... }} are the main template constructs
  • {% url %} reverses named URLs so refactoring is safe
  • get_object_or_404 is 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.