PYTHON / DJANGO
URLs, views, and the request-response cycle
Trace a Django request from URLconf to view to HttpResponse, and write path() routes with converters, named URLs, and correct status codes.
What you will learn
- Route paths to view functions with path() and typed converters like <int:book_id>
- Read request.method, request.GET and request.path inside a view function
- Return HttpResponse or JsonResponse, and raise Http404 to produce a 404 status
- Rebuild URLs from named routes with reverse() instead of hardcoding path strings
Understanding URLs, views, and the request-response cycle
When a request arrives, the handler builds an HttpRequest holding the method, path, headers and body, then asks the URL resolver named by ROOT_URLCONF to find a view for that path. The resolver walks urlpatterns in order and stops at the first pattern that matches; there is no ranking by specificity, so order in the list is a real design decision. Anything the pattern captures becomes an argument for the view, and whatever HttpResponse the view returns is serialised back into HTTP bytes.
A view is just a callable whose first parameter is the request. Django's only contract is that it must return an HttpResponse; if it returns None because you forgot the return statement, Django raises ValueError rather than sending an empty page. The status code comes from the response object you pick, such as HttpResponse(status=201), JsonResponse or HttpResponseNotAllowed, or from raising Http404, which the exception-handling stage converts into a 404 response.
Path converters do two jobs at once: <int:book_id> narrows what the pattern matches, so /books/abc/ is rejected before your code runs, and it casts the captured text, so book_id arrives as an int. Because captures are passed as keyword arguments, the converter name and the view parameter name must be identical. Give each route a name= and build links with reverse() or {% url %}, so the shape of the URL lives in exactly one place, and use include() so each app owns its own urls.py under a prefix.
import django
from django.conf import settings
settings.configure(DEBUG=False, SECRET_KEY='demo-only', ROOT_URLCONF=__name__,
ALLOWED_HOSTS=['testserver'])
django.setup()
from django.http import Http404, HttpResponse
from django.test import Client
from django.urls import path, resolve
BOOKS = {1: 'Dune', 2: 'Solaris'}
def book_detail(request, book_id):
if book_id not in BOOKS:
raise Http404('unknown book')
fmt = request.GET.get('fmt', 'text')
return HttpResponse(f'{request.method} {request.path} [{fmt}] -> {BOOKS[book_id]}')
urlpatterns = [path('books/<int:book_id>/', book_detail, name='book-detail')]
match = resolve('/books/2/')
print(match.func.__name__, match.kwargs, type(match.kwargs['book_id']).__name__)
client = Client()
response = client.get('/books/2/?fmt=json')
print(response.status_code, response.content.decode())
print(client.get('/books/99/').status_code)
print(client.get('/books/abc/').status_code)A URLconf selects exactly one view for a request path, and that view's only job is to turn an HttpRequest into an HttpResponse.
Worked examples
First match wins, so order matters
Shows that the resolver tests patterns top to bottom instead of preferring the most specific route.
import django
from django.conf import settings
settings.configure(DEBUG=False, SECRET_KEY='x', ROOT_URLCONF=__name__,
ALLOWED_HOSTS=['testserver'])
django.setup()
from django.http import HttpResponse
from django.test import Client
from django.urls import path
def latest(request):
return HttpResponse('the latest post')
def by_slug(request, slug):
return HttpResponse(f'post slug={slug}')
urlpatterns = [
path('posts/latest/', latest),
path('posts/<slug:slug>/', by_slug),
]
client = Client()
for url in ('/posts/latest/', '/posts/django-urls/'):
print(url, '->', client.get(url).content.decode())Example explained
Line 1path('posts/latest/', latest) is listed first, so the resolver never tests the slug pattern for that exact path.
Line 2Swap the two entries and 'latest' matches <slug:slug>, making by_slug answer both URLs and latest() unreachable.
Line 3The slug converter accepts letters, digits, hyphens and underscores, so 'django-urls' is captured whole and passed as slug=.
Line 4latest() takes only request because its route captures nothing.
reverse() and the trailing-slash redirect
Builds a URL from a route name and shows that the missing-slash redirect comes from middleware, not from the resolver.
import django
from django.conf import settings
settings.configure(DEBUG=False, SECRET_KEY='x', ROOT_URLCONF=__name__,
ALLOWED_HOSTS=['testserver'],
MIDDLEWARE=['django.middleware.common.CommonMiddleware'])
django.setup()
from django.http import HttpResponse
from django.test import Client
from django.urls import path, reverse
def profile(request, username):
return HttpResponse(f'profile of {username}')
urlpatterns = [path('users/<str:username>/', profile, name='profile')]
print(reverse('profile', kwargs={'username': 'ada'}))
client = Client()
r = client.get('/users/ada')
print(r.status_code, r['Location'])
r = client.get('/users/ada', follow=True)
print(r.status_code, r.content.decode(), r.redirect_chain)Example explained
Line 1reverse('profile', ...) rebuilds the path from the pattern, so templates and redirects never hardcode '/users/ada/'.
Line 2CommonMiddleware notices that '/users/ada' does not resolve but '/users/ada/' does, and returns a 301 before any view is called.
Line 3follow=True makes the test Client replay the redirect, and redirect_chain records the hop it took.
Line 4Remove CommonMiddleware from MIDDLEWARE and the same request is a plain 404: APPEND_SLASH is middleware behaviour, not resolver behaviour.
One route, several HTTP methods
Demonstrates that the resolver ignores the method, so branching on request.method and choosing the status code is the view's job.
import django
from django.conf import settings
settings.configure(DEBUG=False, SECRET_KEY='x', ROOT_URLCONF=__name__,
ALLOWED_HOSTS=['testserver'])
django.setup()
from django.http import HttpResponseNotAllowed, JsonResponse
from django.test import Client
from django.urls import path
def notes(request):
if request.method == 'GET':
return JsonResponse({'notes': ['first']})
if request.method == 'POST':
return JsonResponse({'saved': request.POST.get('text', '')}, status=201)
return HttpResponseNotAllowed(['GET', 'POST'])
urlpatterns = [path('notes/', notes)]
client = Client()
r = client.get('/notes/')
print(r.status_code, r.content.decode())
r = client.post('/notes/', {'text': 'hello'})
print(r.status_code, r.content.decode())
r = client.delete('/notes/')
print(r.status_code, r['Allow'])Example explained
Line 1The URL pattern matches on path only, so GET, POST and DELETE all reach the same callable.
Line 2JsonResponse serialises the dict and sets Content-Type to application/json; status=201 replaces the default 200.
Line 3HttpResponseNotAllowed(['GET', 'POST']) sends 405 and fills in the Allow header from that list.
Line 4request.POST holds the parsed form body, which is why only the POST branch reads it.
Important notes
A route only ever sees the URL path; a query string such as ?page=2 plays no part in matching and must be read from request.GET inside the view.
These examples keep settings, URLs and views in one file via ROOT_URLCONF=__name__ so they run as plain scripts; in a real project they live in settings.py, urls.py and views.py, and requests come from runserver instead of the test Client.
Common mistakes
Falling off the end of a view with no return: Django raises "The view ... didn't return an HttpResponse object. It returned None instead." rather than sending an empty 200.
Capture name and parameter name disagreeing, as in path('books/<int:book_id>/', detail) with def detail(request, id): the call fails with TypeError: detail() got an unexpected keyword argument 'book_id'.
Writing path('/books/<int:pk>/', ...) with a leading slash: the resolver matches against the path with that slash already stripped, so the route never matches and the system checks report urls.W002.
Try it yourself
Change, predict, then run
Copy the main example and add a route 'add/<int:a>/<int:b>/' whose view returns the sum as text, then use the test Client to print the status and body for /add/2/3/ and /add/2/-3/ and say which stage rejects the second request.
Open the Python workspaceCheck your understanding
urlpatterns contains path('books/<str:slug>/', views.detail) followed by path('books/new/', views.create). A GET to /books/new/ runs which view?
- views.detail, because the resolver stops at the first pattern that matches and 'new' is a valid str capture
- views.create, because Django prefers the more specific literal route over one with a converter
- Neither, because two patterns can match the same path and Django raises an ImproperlyConfigured error
- views.create, because patterns containing converters are always tested after literal patterns
Show answer
The resolver scans urlpatterns top to bottom and uses the first pattern that matches; <str:slug> accepts any non-empty segment without a slash, so 'new' is captured and views.detail runs. Option 1 is tempting because some other routers score routes by specificity, but Django does no such ranking, which is why the literal route must be listed first.