Skip to main content

๐Ÿšฆ Views and URL Patterns

If models are Django's memory, views and URLs are its nervous system. The URLconf decides which code runs for a given address; the view decides what that code does. Master this pair and you can turn any request into any response โ€” a page, a redirect, or JSON.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Route requests with URLconf, path converters, and named namespaces
  • Write function-based views that return pages, redirects, and JSON
  • Handle form submissions safely with the Post/Redirect/Get pattern
  • Use generic class-based views (List, Detail, Create, Update, Delete) to slash boilerplate
  • Reuse logic with mixins and avoid hard-coded links with reverse URL resolution

Estimated Time: 40โ€“50 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a list-and-detail pair two ways โ€” as a function and as a class-based view.

In This Lesson

How Views and URLs Fit Together

Together, URL patterns and views are the "controller" of Django's MVT architecture. The division of labor is clean:

  • URL patterns match an incoming path to a view.
  • Views take the request, do the work, and return a response.
graph LR A[Browser] -->|GET /blog/hello/| B[URLconf] B -->|match & call| C[View] C -->|query| D[(Database)] D -->|data| C C -->|render| E[Template] E -->|HTML| F[HTTP Response] F --> A
๐Ÿ“ฎ The postal-service analogy: The URLconf is the sorting facility that reads the address and routes each letter; the view is the clerk at the destination who opens it and acts. Path parameters are the apartment number that pinpoints the recipient, and HTTP methods (GET, POST) are the class of mail โ€” a letter versus a parcel.

URL Configuration

URLs live in urls.py files, usually at two levels. The project URLconf is the front door; it hands path prefixes off to each app's URLconf with include().

Project-level URLs

# myproject/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),
    path("accounts/", include("accounts.urls")),
    path("", include("pages.urls")),
]

# Serve uploaded media during development only
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

App-level URLs

Each app defines its own routes and โ€” importantly โ€” sets an app_name so URL names never collide between apps:

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

app_name = "blog"  # namespace for URL names

urlpatterns = [
    path("", views.post_list, name="post_list"),
    path("<int:year>/<int:month>/<int:day>/<slug:slug>/",
         views.post_detail, name="post_detail"),
    path("category/<slug:category_slug>/",
         views.post_list_by_category, name="post_list_by_category"),
    path("search/", views.post_search, name="post_search"),
]

๐Ÿ“– Why namespaces matter

With app_name = "blog", you reference a route as "blog:post_detail". Two apps can each have a post_detail without ambiguity โ€” a small habit that prevents painful refactors later.

Path Converters

The angle-bracket syntax in a path โ€” <int:year> โ€” is a path converter. It captures a URL segment, validates its shape, converts it to a Python type, and passes it to the view as a keyword argument.

ConverterMatchesExample
strAny non-empty text without /<str:username>
intZero or a positive integer<int:year>
slugLetters, numbers, hyphens, underscores<slug:post_slug>
uuidA UUID-formatted string<uuid:id>
pathAny text, including /<path:file_path>
from django.urls import path
from . import views

urlpatterns = [
    path("about/", views.about, name="about"),
    path("product/<slug:slug>/", views.product_detail, name="product_detail"),
    path("archive/<int:year>/<int:month>/", views.archive, name="archive"),
    path("order/<uuid:order_id>/", views.order_detail, name="order_detail"),
]

When a plain converter isn't precise enough, re_path lets you use a regular expression โ€” powerful, but harder to read, so reach for it only when you must:

from django.urls import re_path

urlpatterns = [
    # A 4-digit year and a valid month 01-12
    re_path(r"^archive/(?P<year>[0-9]{4})/(?P<month>0[1-9]|1[0-2])/$",
            views.archive, name="archive"),
]

Function-Based Views

The simplest view is a function: it takes a request (plus any captured URL parameters) and returns a response.

from django.shortcuts import render, get_object_or_404
from .models import Post

def post_list(request):
    """Show all published posts."""
    posts = Post.published.all()
    return render(request, "blog/post_list.html", {"posts": posts})

def post_detail(request, year, month, day, slug):
    """Show a single post, or 404."""
    post = get_object_or_404(
        Post, slug=slug, status=Post.Status.PUBLISHED,
        published_at__year=year, published_at__month=month, published_at__day=day,
    )
    comments = post.comments.filter(approved=True)
    return render(request, "blog/post_detail.html",
                  {"post": post, "comments": comments})

๐Ÿ’ก Reach for get_object_or_404

Calling .get() raises DoesNotExist and produces an ugly 500 error when a row is missing. get_object_or_404() returns a clean 404 instead โ€” the right behavior for "this page doesn't exist."

The request object

request.method    # "GET", "POST", ...
request.GET       # query-string parameters
request.POST      # submitted form data
request.FILES     # uploaded files
request.user      # the logged-in User (or AnonymousUser)
request.path      # the URL path

Response types

from django.shortcuts import render, redirect
from django.http import HttpResponse, JsonResponse

def html_view(request):
    return render(request, "template.html", {"key": "value"})

def redirect_view(request):
    return redirect("blog:post_list")        # by URL name

def api_view(request):
    return JsonResponse({"items": [1, 2, 3]})

def teapot(request):
    return HttpResponse("Not found", status=404)

Form Handling & PRG

A view that accepts input branches on the request method: show an empty form on GET, process the submission on POST.

from django.shortcuts import render, redirect, get_object_or_404
from .forms import CommentForm
from .models import Post

def add_comment(request, post_id):
    post = get_object_or_404(Post, id=post_id, status=Post.Status.PUBLISHED)

    if request.method == "POST":
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = form.save(commit=False)   # build but don't save yet
            comment.post = post
            if request.user.is_authenticated:
                comment.author = request.user
            comment.save()
            return redirect(post.get_absolute_url())   # PRG: redirect after POST
    else:
        form = CommentForm()

    return render(request, "blog/add_comment.html", {"form": form, "post": post})

โœ… The Post/Redirect/Get (PRG) pattern

Always redirect after a successful POST. Then a browser refresh re-requests the destination page (a GET) instead of resubmitting the form โ€” no duplicate comments, no "confirm form resubmission" warning.

  1. User POSTs the form.
  2. Server saves and redirects.
  3. Browser GETs the success page.

โš ๏ธ Don't disable CSRF protection

Every HTML form that POSTs needs a {% csrf_token %} tag, and Django's CsrfViewMiddleware must stay enabled. It's your defense against cross-site request forgery โ€” turning it off to "make the form work" opens a serious hole.

Class-Based Views

Class-based views (CBVs) package view logic into classes. Their real payoff is Django's generic CBVs, which implement the common patterns โ€” listing, showing, creating, editing, deleting โ€” so you write only what's different.

Compare the function version of a list to the generic ListView:

from django.views.generic import ListView, DetailView
from .models import Post

class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"
    paginate_by = 10               # free pagination!

    def get_queryset(self):
        return Post.published.all()

class PostDetailView(DetailView):
    model = Post
    template_name = "blog/post_detail.html"
    context_object_name = "post"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["comments"] = self.object.comments.filter(approved=True)
        return context

Wire a CBV into the URLconf with .as_view():

path("", views.PostListView.as_view(), name="post_list"),
path("<int:pk>/", views.PostDetailView.as_view(), name="post_detail"),

The editing trio โ€” CreateView, UpdateView, DeleteView โ€” builds full CRUD from a model and a form:

from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .models import Post
from .forms import PostForm

class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = "blog/post_form.html"

    def form_valid(self, form):
        form.instance.author = self.request.user   # stamp the author
        return super().form_valid(form)

class PostDeleteView(LoginRequiredMixin, DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")

The generic CBVs form an inheritance hierarchy you can lean on:

classDiagram class View { +as_view() +dispatch() } class ListView { +model +paginate_by +get_queryset() } class DetailView { +model +get_object() +get_context_data() } class FormView { +form_class +success_url +form_valid() } class CreateView { +model +form_class } View <|-- ListView View <|-- DetailView View <|-- FormView FormView <|-- CreateView

Mixins: reusable slices of behavior

Mixins add capabilities to a view through multiple inheritance. The most common are access checks:

from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import UpdateView
from .models import Post
from .forms import PostForm

class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm
    template_name = "blog/post_form.html"

    def test_func(self):
        # Only the author may edit their own post
        return self.request.user == self.get_object().author

โš ๏ธ Mixin order counts

Access-control mixins must appear before the generic view in the class's base list (left to right), so their checks run first. class V(LoginRequiredMixin, UpdateView) โ€” never the reverse.

FBV vs. CBV โ€” when to use which

Use a function-based view whenโ€ฆUse a class-based view whenโ€ฆ
The logic is simple or one-offThe view fits a standard pattern (list, detail, CRUD)
You want the flow readable top-to-bottomYou want to reuse behavior via mixins/inheritance
The view doesn't map to Django's genericsYou're building repetitive CRUD screens

Reverse URL Resolution

Hard-coding URLs like /blog/2024/9/15/my-post/ is fragile โ€” change a route and every link breaks. Instead, refer to routes by their name and let Django build the URL.

# In Python (views, models)
from django.urls import reverse, reverse_lazy

def go(request):
    return redirect(reverse("blog:post_detail", kwargs={
        "year": 2024, "month": 9, "day": 15, "slug": "my-post",
    }))

# In class attributes, use reverse_lazy (evaluated on first use, not import)
class PostDeleteView(DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")
{# In a template #}
<a href="{% url 'blog:post_detail' year=2024 month=9 day=15 slug='my-post' %}">
    My Post
</a>

๐Ÿ’ก reverse vs. reverse_lazy

Use plain reverse() inside functions that run per-request. Use reverse_lazy() for class attributes and other module-level values that are evaluated at import time, before the URLconf is fully loaded.

Even better, give models a get_absolute_url() so "the canonical URL of this object" lives in one place:

from django.urls import reverse

class Post(models.Model):
    # ... fields ...
    def get_absolute_url(self):
        return reverse("blog:post_detail", kwargs={
            "year": self.published_at.year,
            "month": self.published_at.month,
            "day": self.published_at.day,
            "slug": self.slug,
        })

Hands-on: List & Detail

๐Ÿ‹๏ธ Build a list-and-detail pair โ€” twice

Objective: Implement a post list and a post detail page first as function-based views, then again as generic class-based views, and route both.

Instructions:

  1. Write post_list and post_detail function-based views for the Post model from the previous lesson.
  2. Add the two routes in blog/urls.py with an app_name namespace.
  3. Rewrite both as ListView and DetailView, and swap the URLconf to use .as_view().
  4. Confirm both versions render the same pages.
๐Ÿ’ก Hint โ€” matching a detail by pk

Give the detail route a <int:pk> segment. DetailView looks for pk (or slug) automatically, so you won't even need to override get_object() for the simple case.

โœ… Sample solution
# blog/views.py  โ€” function-based
from django.shortcuts import render, get_object_or_404
from .models import Post

def post_list(request):
    return render(request, "blog/post_list.html",
                  {"posts": Post.published.all()})

def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk, status=Post.Status.PUBLISHED)
    return render(request, "blog/post_detail.html", {"post": post})
# blog/views.py  โ€” class-based equivalents
from django.views.generic import ListView, DetailView

class PostListView(ListView):
    queryset = Post.published.all()
    context_object_name = "posts"
    template_name = "blog/post_list.html"
    paginate_by = 10

class PostDetailView(DetailView):
    model = Post
    context_object_name = "post"
    template_name = "blog/post_detail.html"
# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
    # Function-based
    path("", views.post_list, name="post_list"),
    path("<int:pk>/", views.post_detail, name="post_detail"),

    # Class-based (swap in when ready)
    # path("", views.PostListView.as_view(), name="post_list"),
    # path("<int:pk>/", views.PostDetailView.as_view(), name="post_detail"),
]

Best Practices

โœ… Do

  • Namespace app URLs with app_name and reference routes by name.
  • Use get_object_or_404 for "missing means 404" lookups.
  • Always follow Post/Redirect/Get after a successful form submission.
  • Prefer generic CBVs for standard CRUD; drop to FBVs for the unusual.
  • Keep select_related/prefetch_related in mind for list views.

โš ๏ธ Don't

  • Hard-code URLs โ€” use reverse/{% url %}/get_absolute_url.
  • Disable CSRF protection to "fix" a form.
  • Put access-control mixins after the generic view in the base list.
  • Reach for re_path when a plain path converter would do.

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • The URLconf routes paths to views; path converters capture and type-check segments.
  • Function-based views are explicit and simple; generic CBVs erase CRUD boilerplate.
  • The Post/Redirect/Get pattern prevents duplicate submissions โ€” always redirect after POST.
  • Mixins add reusable behavior like login and permission checks (mind the order).
  • Reverse resolution keeps links robust โ€” never hard-code a URL.

๐ŸŽฏ Quick Quiz

Question 1: Why should a view redirect after a successful POST (the PRG pattern)?

Question 2: Which generic class-based view is designed to display a paginated list of objects?

Question 3: What does the <int:year> segment in a URL pattern do?

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You can route requests and shape responses. Next, in Django Admin Interface Customization, you'll unlock one of Django's signature features โ€” a full data-management back office you can tailor to your models with just a few lines.

๐ŸŽ‰ Well routed!

URLs and views are second nature now. Let's make the admin work for you.