Flatpages, Redirects & Humanize
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text
says so. Every example was run on that stack in a scratch project on SQLite with the Django test runner (43 tests) and
the runserver development server plus curl; none of them needs PostgreSQL or Redis. The three apps share a theme:
they are small, optional parts of django.contrib that solve a recurring chore without a custom app. Flatpages
stores one-off pages such as "About" or "Privacy policy" in the database so that non-developers can edit them in the
admin; redirects keeps a table of moved URLs and answers requests for them with a 301, or a 410 when the content is
gone; humanize is a template-filter library that turns 1200000 into "1.2 million" and a timestamp into "4 minutes
ago". The running example is the Bookshelf application (the catalog app built across this section).
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Editor-managed static pages |
Install |
|
Serve a page without a URLconf entry |
|
|
Login-protected page |
Tick |
|
Menu of flatpages |
|
|
Keep old links alive after a rename |
A |
|
Tell clients a URL is gone for good |
A |
|
Temporary instead of permanent redirects |
Subclass the middleware and set |
|
Friendlier numbers and dates in templates |
Add |
|
Is this the right tool? |
Flatpages for a handful of editor pages, redirects for a modest table, humanize for display only. |
What the three apps share
Flatpages and redirects work the same way, and the same way as the pieces of a Django project covered on the Middleware page: a middleware looks at every response, and when the status is 404 it consults a database table as a last resort. Humanize is different (it only adds template filters) but is documented in the same part of the reference, so it shares the page.
-
Both database-backed apps need the sites framework. A flatpage belongs to one or more
Siterows, and a redirect belongs to exactly one. The current site is the one whose id equals theSITE_IDsetting (withoutSITE_ID, the sites framework matches the request host againstSite.domain).RedirectFallbackMiddlewareraisesImproperlyConfiguredat start-up ifdjango.contrib.sitesis not installed. The sites framework itself has its own page in this section; here it is only a prerequisite. -
Both only act on 404 responses. If a view or another middleware produces a 200, a 500 or any other status, the fallback middleware returns the response untouched. Conversely, a view that handles a request can never be shadowed by a flatpage or a redirect row.
-
Both cost one query per 404. The fallback runs for every not-found response, including bots probing for
/wp-login.php. For a busy site this is worth knowing; see Pitfalls and behaviour worth knowing. -
Order in
MIDDLEWAREmatters, and the usual advice is "at the end". Response processing runs in reverse list order, so a middleware near the end sees the response first, which is what a last-resort fallback wants.
# config/settings.py (the parts these apps need)
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites", # prerequisite of flatpages and redirects
"django.contrib.sitemaps", # only for the FlatPageSitemap example
"django.contrib.flatpages",
"django.contrib.redirects",
"django.contrib.humanize",
"catalog",
]
SITE_ID = 1
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"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",
"django.contrib.flatpages.middleware.FlatpageFallbackMiddleware",
"django.contrib.redirects.middleware.RedirectFallbackMiddleware",
]
Source: Flatpages: installation
The fallback middleware classes are written with MiddlewareMixin, which declares itself both sync-capable and
async-capable, so they work under WSGI and ASGI alike: under ASGI Django adapts them, and their ORM queries run in a
worker thread. They do not need to be rewritten for async. The request/response cycle position is the same in both
modes: after URL resolution failed and the 404 response (often rendered from 404.html) is on its way out.
Run python manage.py migrate after adding the apps. It creates django_flatpage, django_flatpage_sites and
django_redirect, and the sites app creates the default example.com site with id 1 if the table is empty.
Flatpages
A flatpage is a row with a URL, a title and HTML content, optionally a template name and a "login required" flag,
and a many-to-many link to sites. It exists for pages that deserve to be editable by a non-developer but not worth a
model, a view and a template each: "About", "Contact details", "Terms", "Privacy policy". A page whose content is
better kept in a template (because it has layout, loops or translations) can leave content blank and name a custom
template.
Installation
The documented steps are: install the sites framework and set SITE_ID; add django.contrib.flatpages to
INSTALLED_APPS; connect it either through a URLconf entry or through the middleware; run migrate. The two
connection styles are alternatives, and the settings block above lists the middleware; the URLconf version follows.
Connecting through the URLconf
There are three documented patterns. The first dedicates a path prefix to flatpages:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("pages/", include("django.contrib.flatpages.urls")),
]
Source: Flatpages: using the URLconf
The included URLconf holds one pattern, path("<path:url>", views.flatpage, name="django.contrib.flatpages.views.flatpage").
Two details follow from it. First, the view prepends a slash to the captured value, so a request for
/pages/about-us/ looks up the flatpage whose url field is /about-us/: the prefix is not part of the stored
URL. Second, FlatPage.get_absolute_url() reverses that named pattern, so the same page’s absolute URL is
/pages/about-us/. Verified in the scratch project:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8057/pages/about-us/
200
The second pattern is a catch-all that turns the whole site into flatpage territory. It must be the last pattern, and its regular expression assumes a trailing slash:
from django.contrib.flatpages import views
from django.urls import re_path
# Your other patterns here
urlpatterns += [
re_path(r"^(?P<url>.*/)$", views.flatpage),
]
Source: Flatpages: using the URLconf
If APPEND_SLASH is False, remove the slash from the pattern or flatpages without a trailing slash will not match.
The catch-all is functionally close to the middleware, with one difference: a URL that matches the pattern but has no
flatpage raises Http404 from inside the view, so process_view and the other view-level machinery have already run.
The third pattern names a fixed, small set of pages and passes the lookup URL as a keyword argument, which also makes them reversible by name:
from django.contrib.flatpages import views
from django.urls import path
urlpatterns += [
path("about-us/", views.flatpage, kwargs={"url": "/about-us/"}, name="about"),
path("license/", views.flatpage, kwargs={"url": "/license/"}, name="license"),
]
Source: Flatpages: using the URLconf
reverse("about") gives /about-us/ and the page is served at that path. The kwargs value is the lookup URL, the
one stored in FlatPage.url. This pattern is the most explicit and keeps the URL map visible in one file, which is why
it is the best fit for the handful of pages most sites actually have. See the
URLs & Routing page for path(), re_path() and reverse().
Connecting through the middleware
FlatpageFallbackMiddleware needs no URLconf entry. Whenever any part of the project returns a 404, it asks the
flatpage view for the request’s path_info as a last resort. The documented algorithm:
-
Find a flatpage with that
urlwhose sites include the current site (SITE_ID). -
If it has a
template_name, load that template; otherwise loadflatpages/default.html. -
Render with
RequestContextand a single context variable namedflatpage. -
If the URL lacks a trailing slash,
APPEND_SLASHis on, and a flatpage exists with the slash, answer with a permanent (301) redirect. The redirect is only issued when it would lead to a real flatpage. -
If nothing matches, or the response was not a 404, the original response is returned unchanged.
Observed against the development server, with the flatpage /about-us/ stored and no URLconf entry for it:
$ curl -s -o /dev/null -D - http://127.0.0.1:8057/about-us | grep -i '^HTTP\|^location'
HTTP/1.1 301 Moved Permanently
Location: /about-us/
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8057/nope/
404
Source: Flatpages: using the middleware
Four consequences are easy to miss.
-
View middleware does not run for a flatpage. The flatpage response is produced after URL resolution failed, so
process_view()methods never see it. That includes the login-required machinery ofLoginRequiredMiddleware(added in Django 5.1): protect a flatpage with theregistration_requiredflag, not with view decorators. -
The 404 must be a real 404 response. If another view or middleware tries to produce a 404 but raises an exception instead (for example because
404.htmlfails to render), the result is a 500 and the flatpage is not consulted. Keep the 404 template simple and test it. -
Errors are swallowed in production. The middleware catches
Http404and, whenDEBUGisFalse, any other exception while rendering, and returns the original 404. A broken flatpage template therefore shows up as an ordinary "not found" page rather than a traceback; withDEBUG = Truethe exception propagates. Test the template. -
Placing it last is usually right. Because response middleware runs in reverse, a fallback at the end of the list runs first on the way out, and every other response-processing middleware sees the real flatpage response instead of the 404.
The FlatPage model
| Field | Meaning |
|---|---|
|
Required, 100 characters or fewer, indexed. The lookup path, with a leading slash, and a trailing slash when |
|
Required, 200 characters or fewer. |
|
Optional ( |
|
A boolean that flatpages itself never reads and that does not appear in the admin; it is a hook for your project or a third-party app. |
|
Optional, 70 characters or fewer; the template used to render the page. Defaults to |
|
If set, only logged-in users can see the page; others are redirected to the login page. |
|
Many-to-many to |
get_absolute_url() returns the relative URL of the page, computed from url and the URLconf as described above, and
falls back to the script prefix plus url when no URLconf entry can reverse it (which is the situation in a
middleware-only setup).
Source: Flatpages: the FlatPage model
Creating flatpages
Through the admin, flatpages appear under "Flatpages" with the fields above (and registration_required and
template_name in a collapsed "Advanced options" fieldset). Through code, a flatpage is an ordinary model:
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
site = Site.objects.get_current()
page = FlatPage.objects.create(
url="/about-us/",
title="About Bookshelf",
content="<p>A catalogue of books.</p>",
)
page.sites.add(site)
members = FlatPage.objects.create(
url="/members/club/",
title="Reading club",
content="<p>Members only.</p>",
template_name="flatpages/members.html",
registration_required=True,
)
members.sites.add(site)
Source: Flatpages: via the Python API
A page created without sites.add() belongs to no site and is therefore never served: the lookup filters on the current
site. This is the most common reason a freshly created flatpage returns 404.
To ship starter pages with the project, create them in a data migration. One wrinkle, verified in the scratch project:
on a brand-new database the default Site row is created by a post_migrate handler, which has not run yet while
migrations apply, so the migration creates it itself:
from django.db import migrations
def create_default_pages(apps, schema_editor):
FlatPage = apps.get_model("flatpages", "FlatPage")
Site = apps.get_model("sites", "Site")
# The default Site row is created by a post_migrate handler, which has not
# run yet on a brand-new database, so create it here if it is missing.
site, _ = Site.objects.get_or_create(
pk=1, defaults={"domain": "example.com", "name": "example.com"}
)
page, _created = FlatPage.objects.get_or_create(
url="/about-us/",
defaults={"title": "About Bookshelf", "content": "<p>A catalogue of books.</p>"},
)
page.sites.add(site)
def remove_default_pages(apps, schema_editor):
FlatPage = apps.get_model("flatpages", "FlatPage")
FlatPage.objects.filter(url="/about-us/").delete()
class Migration(migrations.Migration):
dependencies = [
("flatpages", "0001_initial"),
("sites", "0002_alter_domain_unique"),
]
operations = [migrations.RunPython(create_default_pages, remove_default_pages)]
Source: Migrations: data migrations
See the Migrations page for RunPython and reversibility.
Checking for duplicates
Two flatpages with the same url on the same site would be ambiguous. The database does not forbid it, but the form
used by the admin does, and it can be reused in your own views: django.contrib.flatpages.forms.FlatpageForm also
validates the slashes.
from django.contrib.flatpages.forms import FlatpageForm
form = FlatpageForm(data={"url": "/about-us/", "title": "Dup", "content": "x", "sites": [1]})
form.is_valid() # False
form.errors.as_json()
# {"__all__": [{"message": "Flatpage with url /about-us/ already exists for site example.com",
# "code": "duplicate_url"}]}
form = FlatpageForm(data={"url": "about", "title": "t", "sites": [1]})
form.is_valid() # False: "URL is missing a leading slash." (code missing_leading_slash)
Source: Flatpages: via the Python API
When APPEND_SLASH is on and CommonMiddleware is installed, the form additionally demands a trailing slash
(missing_trailing_slash).
Showing enable_comments in the admin
The admin’s default FlatPageAdmin hides enable_comments. To expose it, unregister the model and register a
subclass with its own fieldsets:
from django.contrib import admin
from django.contrib.flatpages.admin import FlatPageAdmin
from django.contrib.flatpages.models import FlatPage
from django.utils.translation import gettext_lazy as _
# Define a new FlatPageAdmin
class BookshelfFlatPageAdmin(FlatPageAdmin):
fieldsets = [
(None, {"fields": ["url", "title", "content", "sites"]}),
(
_("Advanced options"),
{
"classes": ["collapse"],
"fields": [
"enable_comments",
"registration_required",
"template_name",
],
},
),
]
# Re-register FlatPageAdmin
admin.site.unregister(FlatPage)
admin.site.register(FlatPage, BookshelfFlatPageAdmin)
The app that holds this module must come after django.contrib.flatpages in INSTALLED_APPS so that the default
registration exists when unregister() runs. Posting the extended form in a test saved enable_comments=True. For
admin customisation in general see the Admin Customization page.
Flatpage templates
The template flatpages/default.html is your responsibility: the app ships no template, so without one every
flatpage raises TemplateDoesNotExist (which, as noted above, the middleware turns into a 404 when DEBUG is off).
Create flatpages/default.html in a templates directory of your project or app. The template receives one variable,
flatpage:
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ flatpage.title }} | Bookshelf</title>
</head>
<body>
<main>{{ flatpage.content }}</main>
</body>
</html>
Source: Flatpages: templates
Note the missing |safe. Because staff already type raw HTML into the admin, the view marks both flatpage.title and
flatpage.content as safe before rendering, so they are not auto-escaped. That is convenient and also the source of
the main risk; see Security: flatpages are raw HTML.
A page can name its own template. The context is still just flatpage, but because the view renders with a
RequestContext, the context processors apply, so request and user are available. A members-only template that
contains a form must include the CSRF token; the page view is wrapped in csrf_protect internally for exactly that
reason:
<!DOCTYPE html>
<html lang="en">
<head><title>{{ flatpage.title }}</title></head>
<body>
<h1>{{ flatpage.title }}</h1>
<p>Hello {{ request.user.get_username }}</p>
<form method="post" action="{{ request.path }}">
{% csrf_token %}
<button>Join the reading list</button>
</form>
{{ flatpage.content }}
</body>
</html>
Source: Flatpages: templates
With registration_required set, an anonymous visitor is redirected to the login page with a next parameter, and a
logged-in user sees the page. A test of both paths:
from django.contrib.auth import get_user_model
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
from django.test import TestCase
class MembersPageTests(TestCase):
@classmethod
def setUpTestData(cls):
page = FlatPage.objects.create(
url="/members/club/",
title="Reading club",
content="<p>Members only.</p>",
template_name="flatpages/members.html",
registration_required=True,
)
page.sites.add(Site.objects.get(pk=1))
def test_anonymous_is_sent_to_login(self):
response = self.client.get("/members/club/")
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/accounts/login/?next=/members/club/"))
def test_logged_in_user_sees_the_page(self):
user = get_user_model().objects.create_user("ana")
self.client.force_login(user)
response = self.client.get("/members/club/")
self.assertContains(response, "Hello ana")
self.assertContains(response, "csrfmiddlewaretoken")
Source: Client.force_login()
The redirect target is settings.LOGIN_URL, which defaults to /accounts/login/. See the
Authentication page. For more on template inheritance, blocks and context
processors see Templates.
Listing flatpages with get_flatpages
The flatpages template-tag library provides {% get_flatpages %}, which puts the pages of the current site into a
context variable. Load it first, like every custom tag library:
{% load flatpages %}
{% get_flatpages as flatpages %}
<ul>
{% for page in flatpages %}
<li><a href="{{ page.get_absolute_url }}">{{ page.title }}</a></li>
{% endfor %}
</ul>
The official example links with page.url. That works when the pages are served at the site root (the middleware or a
catch-all), but with the pages/ prefix from the URLconf pattern above the stored /about-us/ would produce a link to
a path that is not a flatpage; get_absolute_url applies the prefix. In the scratch project the menu rendered
<a href="/pages/about-us/">.
Three refinements, all documented:
-
Registration-required pages are hidden by default. The tag returns only pages with
registration_required = False. Pass a user withforto include the protected ones when that user is authenticated:{% get_flatpages for user as member_pages %}. An anonymous user behaves like no user. -
Limit by prefix. An optional first argument,
starts_with, takes a string or a context variable:{% get_flatpages '/legal/' as legal_pages %}. -
Combine both.
{% get_flatpages '/members/' for user as pages %}.
{% load flatpages %}
{% get_flatpages '/legal/' as legal_pages %}
{% get_flatpages '/members/' for user as member_pages %}
<ul class="legal">{% for p in legal_pages %}<li>{{ p.url }}</li>{% endfor %}</ul>
<ul class="members">{% for p in member_pages %}<li>{{ p.url }}</li>{% endfor %}</ul>
Source: Flatpages: limiting by base URL
Rendered for a logged-in user with the example data, the lists contained /legal/privacy/ and /members/club/
respectively, and /about-us/ appeared in neither. Each use of the tag is one query, so call it once per page and
reuse the variable (or cache the fragment with the Caching page’s cache tag) rather
than once per menu item. The tag uses the request’s site when the request context processor is enabled, and
SITE_ID otherwise.
Flatpages in the sitemap
django.contrib.flatpages.sitemaps.FlatPageSitemap lists every public flatpage of the current site. Entries carry only
location, not lastmod, changefreq or priority. It requires both django.contrib.sites and
django.contrib.sitemaps to be installed.
from django.contrib.flatpages.sitemaps import FlatPageSitemap
from django.contrib.sitemaps.views import sitemap
from django.urls import path
urlpatterns = [
# ...
# the sitemap
path(
"sitemap.xml",
sitemap,
{"sitemaps": {"flatpages": FlatPageSitemap}},
name="django.contrib.sitemaps.views.sitemap",
),
]
With the example data and the pages/ include, the response was:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url><loc>http://localhost:8057/pages/about-us/</loc></url>
</urlset>
The host in <loc> comes from the current Site’s `domain, which is why that field must match the real host name in
production (the default example.com is a classic mistake). The protected /members/club/ page was left out, and the
location uses get_absolute_url(), so the prefix appears.
Security: flatpages are raw HTML
The official reference carries a warning that is worth repeating on its own: permissions to add or edit flatpages
should be restricted to trusted users. Content is raw HTML that Django does not sanitise, so a malicious flatpage can
cause various vulnerabilities, including permission escalation. A script in content runs in the visitors' browsers
under your origin, including administrators':
page = FlatPage.objects.create(url="/x/", title="<i>t</i>", content="<script>alert(1)</script>")
# GET /x/ renders the script tag verbatim, because the view marks content (and title) as safe.
Practical rules:
-
Grant the
add_flatpageandchange_flatpagepermissions only to people you would trust with the site’s source; see Permissions & Authorization. -
A flatpage editor can write script that steals an administrator’s session. Combined with a lax Content Security Policy, that is full account takeover; a strict CSP limits what inline script in
contentcan do. The Security page covers the wider picture. -
If the requirement is "marketing writes copy, but must not write script", a flatpage is the wrong tool; use a model with a field that is escaped or sanitised on output.
Testing flatpages
The test client exercises the whole stack, including the middleware, so no mocking is needed. Create the page and its
site link in setUpTestData:
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
from django.test import TestCase
class FlatpageTests(TestCase):
@classmethod
def setUpTestData(cls):
page = FlatPage.objects.create(
url="/legal/privacy/", title="Privacy", content="<p>None.</p>"
)
page.sites.add(Site.objects.get(pk=1))
def test_served_by_the_middleware_after_a_404(self):
response = self.client.get("/legal/privacy/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["flatpage"].title, "Privacy")
def test_missing_slash_is_redirected_permanently(self):
response = self.client.get("/legal/privacy")
self.assertEqual(response.status_code, 301)
self.assertEqual(response["Location"], "/legal/privacy/")
def test_unknown_path_stays_404(self):
self.assertEqual(self.client.get("/legal/nothing").status_code, 404)
Source: The test client
A page attached to a different site returned 404 on the current one, which is a useful test for multi-site projects.
Redirects
The redirects app keeps a table of "if someone asks for this path, send them there". It is the pragmatic answer to
"we renamed /books/dune/ and the old link is on a hundred other websites".
Installation
The documented steps: make sure django.contrib.sites is installed; add django.contrib.redirects to
INSTALLED_APPS; add django.contrib.redirects.middleware.RedirectFallbackMiddleware to MIDDLEWARE; run migrate.
The settings block at the top of the page shows all of it. migrate creates django_redirect, a lookup table with
site_id, old_path and new_path columns.
Source: Redirects: installation
How a request is matched
RedirectFallbackMiddleware does all of the work. Each time any application returns a 404, it looks for a Redirect
with the requested path and the current site, and:
| Match | Response |
|---|---|
a row with a non-empty |
a |
a row with an empty |
a |
no row |
the original 404 response, so the rest of the request processing continues as usual |
Non-404 responses are never touched, so a redirect row cannot hide a URL that a view serves. new_path can be an
absolute path or a full URL beginning with a scheme such as https://, which makes the table usable for sending an old
host-relative path to a new domain.
Source: Redirects: how it works
Observed on the development server with two rows (/contact-us/ to /about-us/, and /old-promo/ with an empty target):
$ curl -s -o /dev/null -D - http://127.0.0.1:8057/contact-us/ | grep -i '^HTTP\|^location'
HTTP/1.1 301 Moved Permanently
Location: /about-us/
$ curl -s -o /dev/null -D - http://127.0.0.1:8057/old-promo/ | head -1
HTTP/1.1 410 Gone
The 410 is meaningful: unlike a 404 it tells search engines that the resource was removed on purpose and will not come
back, so they drop it from the index sooner. Use an empty new_path for retired promotions and withdrawn products.
The Redirect model and the Python API
Redirect has three fields: site (a foreign key to Site), old_path and new_path. old_path is limited to 200
characters, indexed, must be an absolute path without the domain, and is unique together with site; new_path is
limited to 200 characters and may be blank. str() of a redirect reads old --→ new. The admin lists the model under
"Redirects". The reference gives this session:
from django.conf import settings
from django.contrib.redirects.models import Redirect
# Add a new redirect.
redirect = Redirect.objects.create(
site_id=1,
old_path="/contact-us/",
new_path="/contact/",
)
# Change a redirect.
redirect.new_path = "/contact-details/"
redirect.save()
redirect # <Redirect: /contact-us/ ---> /contact-details/>
# Delete a redirect.
Redirect.objects.filter(site_id=1, old_path="/contact-us/").delete()
# (1, {'redirects.Redirect': 1})
Source: Redirects: via the Python API
The unique constraint means a second row for the same site and old_path raises IntegrityError; use
update_or_create() when loading data repeatedly, as the next two examples do.
Keeping old URLs alive when a slug changes
The most valuable use is automatic: when a book’s slug changes, leave a redirect from the old URL behind. The
example keeps it in save() so that every code path (admin, shell, API) is covered, and wraps both writes in one
transaction so a failure cannot leave a half-applied rename:
from django.contrib.redirects.models import Redirect
from django.contrib.sites.models import Site
from django.db import models, transaction
from django.urls import reverse
class Book(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("book-detail", args=[self.slug])
def save(self, *args, **kwargs):
old_url = None
if self.pk:
previous = Book.objects.filter(pk=self.pk).only("slug").first()
if previous is not None and previous.slug != self.slug:
old_url = reverse("book-detail", args=[previous.slug])
with transaction.atomic():
super().save(*args, **kwargs)
if old_url:
Redirect.objects.update_or_create(
site=Site.objects.get_current(),
old_path=old_url,
defaults={"new_path": self.get_absolute_url()},
)
Source: Redirects: via the Python API
And the test that proves it end to end:
from django.test import TestCase
from catalog.models import Book
class SlugRedirectTests(TestCase):
def test_renaming_slug_leaves_a_redirect(self):
book = Book.objects.create(title="Dune", slug="dune")
self.assertEqual(self.client.get("/books/dune/").status_code, 200)
book.slug = "dune-1965"
book.save()
response = self.client.get("/books/dune/")
self.assertEqual(response.status_code, 301)
self.assertEqual(response["Location"], "/books/dune-1965/")
self.assertEqual(self.client.get("/books/dune-1965/").status_code, 200)
Source: The test client
The view behind book-detail uses get_object_or_404(), so the old slug produces a 404 and the middleware takes
over; this only works because the old URL is no longer routed to a successful response. If the book is renamed twice,
the first redirect still points at the middle slug and visitors take two hops; update rows whose new_path equals the
previous URL if that matters. See Models & Fields and
Relationships for the model side.
Loading a redirect map in bulk
After a site migration there are often hundreds of old URLs. A management command that reads a two-column CSV is the repeatable way to load them:
import csv
from django.contrib.redirects.models import Redirect
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Load old_path,new_path rows from a CSV file into the redirects table."
def add_arguments(self, parser):
parser.add_argument("csv_file")
def handle(self, *args, **options):
site = Site.objects.get_current()
created = 0
with open(options["csv_file"], newline="") as handle:
for old_path, new_path in csv.reader(handle):
_, was_created = Redirect.objects.update_or_create(
site=site,
old_path=old_path,
defaults={"new_path": new_path},
)
created += was_created
self.stdout.write(f"{created} new redirects")
Running it on a CSV with /a/,/b/ and /gone/, (an empty second column) reported 2 new redirects, and requesting
/gone/ afterwards returned 410. See Management Commands & Shell
for the command machinery. A very large import is better done in batches inside a transaction.
Changing the response classes
The middleware reads two class attributes, so a subclass changes behaviour without copying code:
-
response_redirect_class: theHttpResponseclass that handles the redirect. DefaultHttpResponsePermanentRedirect. -
response_gone_class: the class used for a row with a blanknew_path. DefaultHttpResponseGone. (The reference also mentions the not-found case, but in the code a path with no row simply keeps its original 404 response.)
The reference’s own example of the first is a switch from 301 to 302 while you are still deciding on the final URL structure, which avoids having browsers and search engines cache a permanent answer you may revoke:
from django.contrib.redirects.middleware import RedirectFallbackMiddleware
from django.http import HttpResponseRedirect
class TemporaryRedirectFallbackMiddleware(RedirectFallbackMiddleware):
response_redirect_class = HttpResponseRedirect
Replace the dotted path in MIDDLEWARE with catalog.middleware.TemporaryRedirectFallbackMiddleware. In a test with
override_settings(MIDDLEWARE=…) the same row answered 302 instead of 301.
Source: Redirects: middleware
A 301 or 302 is allowed to turn a POST into a GET on the follow-up request. The redirect response classes accept
preserve_request, which switches to 307 (temporary) or 308 (permanent) so the client repeats the same method and
body. The middleware instantiates response_redirect_class(new_path) with only the path, so wrap the class to set the
keyword:
from django.contrib.redirects.middleware import RedirectFallbackMiddleware
from django.http import HttpResponsePermanentRedirect
class PermanentRedirectKeepingMethod(HttpResponsePermanentRedirect):
"""A 308 response: clients repeat the same method and body."""
def __init__(self, redirect_to, *args, **kwargs):
super().__init__(redirect_to, *args, preserve_request=True, **kwargs)
class MethodPreservingRedirectMiddleware(RedirectFallbackMiddleware):
response_redirect_class = PermanentRedirectKeepingMethod
Source: HttpResponsePermanentRedirect
A POST to a redirected path returned 308 with the new Location. This matters for webhooks and API clients whose
old endpoint moved; browsers following a plain link are fine with 301. Compare the generic view
RedirectView, which gained a preserve_request attribute in Django 6.1, on the
Generic Views page: that is the code-level equivalent for URLs you route yourself.
Pitfalls and behaviour worth knowing
Everything below was checked against the source and in tests.
-
The query string is part of the match. The middleware compares
old_pathwithrequest.get_full_path(), which includes?q=dune. A row for/search/does not match/search/?q=dune; the request stays a 404. To redirect a URL that carries a query string, store the whole thing asold_path(and the target asnew_path, including its own query). A mapping that must ignore the query is a job for a subclass or for the web server. -
APPEND_SLASHis honoured. If there is no exact match, the path does not end in a slash andAPPEND_SLASHisTrue, a second lookup tries the path with a slash. A row for/old/therefore also serves/old. -
One row per
(site, old_path). Rows belong to one site; a row of another site never matches. Adding the same mapping for two sites means two rows. -
Only 404s. A row for a path that a view serves with 200 is dead data.
-
Order relative to flatpages. Both middleware classes act on the 404. With the order in the settings block (flatpages listed first, redirects last) the redirect middleware sees the response first on the way out, so when a path has both a flatpage and a redirect row the redirect wins (verified: 301). Reverse the order to make the flatpage win. Avoid the ambiguity by not creating both.
-
No loop detection. Rows
/a/to/b/and/b/to/a/produce a loop that the browser stops after a few hops.new_pathis not validated againstold_path. -
One query per 404, per request. There is no caching, and bots generate many 404s. If that matters, put a per-site cache in front of the table in a subclass of the middleware, or serve the map from the web server or CDN and keep the table for what editors maintain. See Caching.
-
Open redirects.
new_pathmay be anyhttps://URL, so whoever can edit redirects can send visitors anywhere. Treat the admin permission with the same care as for flatpages, and see Security. -
Chains cost hops. Point new rows at the final URL, not at another redirect.
-
RedirectFallbackMiddlewaredoes not run for responses that are not 404. A 403 or 500 page is never replaced.
Redirects table or code?
Use the table for URLs that editors or a migration script maintain, and code for fixed rules. A single moved page is cleaner as a URLconf entry:
from django.urls import path
from django.views.generic import RedirectView
urlpatterns += [
path("get-in-touch/", RedirectView.as_view(pattern_name="contact", permanent=True)),
]
Source: RedirectView
That entry answers without a database query and is checked by reverse() and the test suite. The table wins when the
set of redirects changes without deployments, which is the "editors manage it in the admin" case.
Humanize
django.contrib.humanize is a set of template filters that add a "human touch" to data. It has no models, no
migrations and no middleware: add 'django.contrib.humanize' to INSTALLED_APPS (already in the settings block above)
and put {% load humanize %} at the top of each template that uses it. As with every tag library, load is
per-template; it is not inherited from a base template.
Source: django.contrib.humanize
The six filters
| Filter | What it does | Examples (verified) |
|---|---|---|
|
Spells out 1-9 following Associated Press style; any other number is returned as is. Accepts an integer or its string form. |
|
|
Converts an integer or float (or the string form of either) to a string with a separator every three digits. Respects format localisation. |
|
|
Converts a large integer to a friendly text; best for numbers over 1 million. Translates 1.0 as a singular phrase and every other value as plural. Supports values up to 10^100 (a googol). |
|
|
For a date today, tomorrow or yesterday, returns "today", "tomorrow" or "yesterday". Any other date is formatted with the format string argument (as for the |
Today’s date gives |
|
For datetimes, "how many seconds, minutes or hours ago", falling back to the |
|
|
Converts an integer to its ordinal. Negative integers are returned unchanged. Accepts an integer or its string form. |
|
Source: humanize filters
A single template shows them together. The view passes plain Python values:
{% load humanize %}
<table>
{% for b in books %}
<tr>
<td>{{ b.title }}</td>
<td class="pages">{{ b.pages|intcomma }}</td>
<td class="copies">{{ b.copies_sold|intword }}</td>
<td class="edition">{{ b.edition|ordinal }}</td>
<td class="reviews">{{ b.review_count|apnumber }} reviews</td>
<td class="published">{{ b.published|naturalday }}</td>
<td class="added">{{ b.added_at|naturaltime }}</td>
</tr>
{% endfor %}
</table>
from datetime import timedelta
from django.shortcuts import render
from django.utils import timezone
def book_list(request):
now = timezone.now()
books = [
{
"title": "Dune",
"pages": 612,
"copies_sold": 20_000_000,
"edition": 3,
"review_count": 4,
"published": now.date(),
"added_at": now - timedelta(minutes=4),
},
{
"title": "Emma",
"pages": 1_204_500,
"copies_sold": 1_200_000_000,
"edition": 22,
"review_count": 12,
"published": now.date() - timedelta(days=1),
"added_at": now - timedelta(hours=2),
},
]
return render(request, "catalog/book_list.html", {"books": books})
Source: django.contrib.humanize
The rendered rows, as returned by the test client (non-breaking spaces inside "4 minutes" are produced by the
timesince machinery):
<td>Dune</td> <td class="pages">612</td> <td class="copies">20.0 million</td>
<td class="edition">3rd</td> <td class="reviews">four reviews</td>
<td class="published">today</td> <td class="added">4 minutes ago</td>
<td>Emma</td> <td class="pages">1,204,500</td> <td class="copies">1.2 billion</td>
<td class="edition">22nd</td> <td class="reviews">12 reviews</td>
<td class="published">yesterday</td> <td class="added">2 hours ago</td>
Testing the filters
The filters are plain functions registered in a library, so they can be tested through Template without a request:
from datetime import timedelta
from django.template import Context, Template
from django.test import SimpleTestCase
from django.utils import timezone
def render(source, **context):
return Template("{% load humanize %}" + source).render(Context(context))
class HumanizeTests(SimpleTestCase):
def test_numbers(self):
self.assertEqual(render("{{ v|apnumber }}", v=3), "three")
self.assertEqual(render("{{ v|apnumber }}", v=10), "10")
self.assertEqual(render("{{ v|intcomma }}", v=4500000), "4,500,000")
self.assertEqual(render("{{ v|intword }}", v=1200000), "1.2 million")
self.assertEqual(render("{{ v|intword }}", v=1000000), "1.0 million")
self.assertEqual(render("{{ v|ordinal }}", v=22), "22nd")
self.assertEqual(render("{{ v|ordinal }}", v=-3), "-3")
def test_dates(self):
today = timezone.localdate()
self.assertEqual(render("{{ d|naturalday }}", d=today), "today")
self.assertEqual(
render("{{ d|naturalday }}", d=today - timedelta(days=1)), "yesterday"
)
now = timezone.now()
self.assertEqual(render("{{ t|naturaltime }}", t=now), "now")
self.assertEqual(
render("{{ t|naturaltime }}", t=now - timedelta(seconds=29)), "29\xa0seconds ago"
)
Source: humanize: naturaltime
Two things make date tests fragile: time moves (so build values relative to timezone.now(), as above, rather than
using fixed dates), and the timesince phrases contain non-breaking spaces ("4\xa0minutes from now"), so compare
against those or normalise whitespace. SimpleTestCase suffices because no database is touched.
Localisation
intcomma and intword respect format localisation, and every phrase is translated through gettext. With the German
locale active the filters produced:
from django.utils import translation
with translation.override("de"):
render("{{ v|intcomma }}", v=45000) # "45.000"
render("{{ v|intword }}", v=1200000) # "1,2 Millionen"
render("{{ v|intword }}", v=1000000) # "1,0 Million"
render("{{ v|apnumber }}", v=3) # "drei"
render("{{ v|ordinal }}", v=3) # "3."
Source: humanize: intcomma
That is the documented behaviour: intword translates 1.0 as a singular phrase and everything else as plural, which the
official text flags as possibly wrong for some languages. For a language where the plural rules differ, check the
output with a native speaker. For the wider story (language activation, USE_I18N, LocaleMiddleware, formats) see the
Internationalization & Translation and
Localization & Time Zones pages.
Pitfalls
-
Display only. Humanize returns strings for people. Never feed its output to a calculation, a URL, an
href, a JSON API or a CSV: numbers go out raw and are formatted by the client or the template. -
Inputs that do not fit pass through. A string such as
"abc"is returned unchanged byordinal,intcommaandintword, andNoneproduces the textNone(so|default_if_nonefirst when a value can be missing). A filter never raises on bad input, which hides data problems; validate upstream. -
apnumberandordinalencode English conventions.apnumberis Associated Press style: nine is "nine", ten is "10". In another language the translated words are used, but the style rule itself is English-centric. -
intwordfor money. "1.2 million" is a rounding; show exact amounts withintcomma(or a currency formatter) and useintwordonly for headline figures. -
Time zones. Pass aware datetimes to
naturaltime(USE_TZ = Trueis the default since Django 5.0) and build test values fromtimezone.now()andtimezone.localdate(). See Localization & Time Zones. -
Caching. A cached fragment containing
naturaltimefreezes the phrase ("4 minutes ago" stays "4 minutes ago" until the fragment expires); use short timeouts or format on the client for live-updating values. -
Not a replacement for
floatformatanddate. Use those built-in filters for fixed decimals and fixed date formats; humanize is for the "friendly" variants. For templates in general see Templates and for writing your own filters Custom Template Tags & Filters.
When to use each, and when not to
| Situation | Use | Prefer something else when |
|---|---|---|
A handful of pages edited by non-developers |
Flatpages, ideally hardcoded in the URLconf with |
the pages have structure, translations or per-page behaviour: write a |
A page that mixes static HTML with dynamic data |
a template (possibly named by |
editors must change the data: a model |
A blog, news or documentation section |
a model, views and the admin |
never flatpages; they have no dates, authors, tags or drafts |
A whole CMS |
a purpose-built CMS package |
flatpages is a deliberately tiny feature set |
Moved URLs maintained by editors or a script |
Redirects |
there are a few fixed rules: |
Thousands of moved URLs |
Redirects loaded in bulk, plus a cache |
the map is large and static: the web server or CDN |
Friendly numbers and dates in HTML |
Humanize |
the value is needed by code or an API: keep it raw |
For the broader picture of choosing between features see the Project Structure & Apps page.
Where the books are outdated, and what changed recently
The books consulted for this section (see the section bibliography) show these apps as they were in Django 1.x and 2.x. The apps themselves are stable: a search of the Django release notes from 3.0 to 6.1 finds no new flatpages or redirects feature, only the small humanize changes in the table below. What differs is the configuration around them.
-
MIDDLEWARE_CLASSES. Django 1.x books listFlatpageFallbackMiddlewareandRedirectFallbackMiddlewareinMIDDLEWARE_CLASSES. That setting was replaced byMIDDLEWAREin Django 1.10 and removed in 2.0; copy the dotted paths intoMIDDLEWARE. The classes themselves are written withMiddlewareMixinand need no change. -
url()andinclude()patterns. Older recipes writeurl(r"^pages/", include("django.contrib.flatpages.urls")).django.conf.urls.url()was removed in Django 4.0; usepath("pages/", include(…)), orre_path()for the catch-all. -
USE_L10N. Books and old Stack Overflow answers say humanize’sintcommaneedsUSE_L10N = True. Since Django 3.2intcommaandintwordno longer depend on that setting, and the setting itself was removed in Django 5.0 (format localisation is always on, governed by the active language). -
Time zones. Books that call
pytzto prepare values fornaturaltimeare out of date: Django switched tozoneinfoby default in 4.0 and removedpytzsupport in 5.0, andUSE_TZdefaults toTruefrom 5.0. -
Template-level changes.
{% load staticfiles %}in sample flatpage templates was replaced by{% load static %}(the old library went away with Django 3.0). Check any template copied from a book. -
Guidance. The books treat flatpages as the answer to "I need a CMS". The current reference describes it as a way to store "one-off, special-case pages" and warns that content is unsanitised HTML.
| Version | Change |
|---|---|
1.10 / 2.0 |
|
3.0 |
|
3.1 |
|
3.2 |
|
4.0 |
|
5.0 |
|
6.1 |
No change to flatpages, redirects or humanize. |
Source: Django 6.1 release notes
Checklist
-
django.contrib.sitesis installed,SITE_IDis set, and theSiterow’sdomainis the real host name. -
Every flatpage is attached to at least one site;
flatpages/default.htmlexists and renders underDEBUG = False. -
Flatpage links in templates use
get_absolute_url, and the connection style (prefix, catch-all, hardcoded or middleware) is one deliberate choice. -
Only trusted staff hold the add/change permissions for flatpages and redirects.
-
Fallback middleware sits at the end of
MIDDLEWARE, and the flatpages-versus-redirects order is intentional. -
Redirect rows store the query string when it matters, point at final URLs, and use an empty
new_pathfor content that is gone. -
Redirects that must keep the method (webhooks, APIs) use a 307/308 subclass.
-
Humanize output is used for display only, tested with values relative to
timezone.now(), and the library is loaded in each template. -
python manage.py checkpasses and the tests cover a served flatpage, a redirect, a 410 and a humanised view.