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 django.contrib.sites and django.contrib.flatpages, then either include("django.contrib.flatpages.urls") under a prefix or add FlatpageFallbackMiddleware.

Installation

Serve a page without a URLconf entry

FlatpageFallbackMiddleware runs only after a 404 and looks the path up in django_flatpage.

Connecting through the middleware

Login-protected page

Tick registration_required; anonymous users are redirected to the login page.

Flatpage templates

Menu of flatpages

{% get_flatpages %} and link with get_absolute_url, not the raw url.

Listing flatpages with get_flatpages

Keep old links alive after a rename

A Redirect row (old_path to new_path) and RedirectFallbackMiddleware.

Redirects

Tell clients a URL is gone for good

A Redirect with an empty new_path answers 410 Gone.

How a request is matched

Temporary instead of permanent redirects

Subclass the middleware and set response_redirect_class.

Changing the response classes

Friendlier numbers and dates in templates

Add django.contrib.humanize and {% load humanize %}; use intcomma, intword, apnumber, ordinal, naturalday, naturaltime.

Humanize

Is this the right tool?

Flatpages for a handful of editor pages, redirects for a modest table, humanize for display only.

When to use each, and when not to

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 Site rows, and a redirect belongs to exactly one. The current site is the one whose id equals the SITE_ID setting (without SITE_ID, the sites framework matches the request host against Site.domain). RedirectFallbackMiddleware raises ImproperlyConfigured at start-up if django.contrib.sites is 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 MIDDLEWARE matters, 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",
]

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:

A prefix for flatpages (config/urls.py)
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("pages/", include("django.contrib.flatpages.urls")),
]

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:

A catch-all at the end of the URLconf
from django.contrib.flatpages import views
from django.urls import re_path

# Your other patterns here
urlpatterns += [
    re_path(r"^(?P<url>.*/)$", views.flatpage),
]

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:

Hardcoded flatpages (config/urls.py)
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"),
]

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:

  1. Find a flatpage with that url whose sites include the current site (SITE_ID).

  2. If it has a template_name, load that template; otherwise load flatpages/default.html.

  3. Render with RequestContext and a single context variable named flatpage.

  4. If the URL lacks a trailing slash, APPEND_SLASH is 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.

  5. 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

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 of LoginRequiredMiddleware (added in Django 5.1): protect a flatpage with the registration_required flag, 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.html fails 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 Http404 and, when DEBUG is False, 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; with DEBUG = True the 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

url

Required, 100 characters or fewer, indexed. The lookup path, with a leading slash, and a trailing slash when APPEND_SLASH is on.

title

Required, 200 characters or fewer.

content

Optional (blank=True); a TextField that typically holds the page’s HTML.

enable_comments

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.

template_name

Optional, 70 characters or fewer; the template used to render the page. Defaults to flatpages/default.html.

registration_required

If set, only logged-in users can see the page; others are redirected to the login page.

sites

Many-to-many to Site; the sites the page is available on.

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).

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:

Creating pages with the ORM (python manage.py shell)
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)

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:

A data migration (catalog/migrations/0001_default_pages.py)
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)]

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.

Reusing the admin’s form
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)

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:

Extending FlatPageAdmin (catalog/admin.py)
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:

The default template (catalog/templates/flatpages/default.html)
<!DOCTYPE html>
<html lang="en">
<head>
  <title>{{ flatpage.title }} | Bookshelf</title>
</head>
<body>
  <main>{{ flatpage.content }}</main>
</body>
</html>

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:

A custom template (catalog/templates/flatpages/members.html)
<!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>

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:

Testing the login-required page (catalog/tests.py)
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")

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 with for to 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 %}.

A footer with two lists (catalog/templates/catalog/footer_links.html)
{% 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>

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.

Adding flatpages to sitemap.xml (config/urls.py)
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_flatpage and change_flatpage permissions 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 content can 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:

Flatpage tests (catalog/tests.py)
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.

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 new_path

a 301 Moved Permanently redirect (class HttpResponsePermanentRedirect) to new_path

a row with an empty new_path

a 410 Gone response with no content (class HttpResponseGone)

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.

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:

Creating, changing and deleting redirects (python manage.py shell)
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})

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:

Leave a redirect behind (catalog/models.py)
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()},
                )

And the test that proves it end to end:

Testing the slug redirect (catalog/tests.py)
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:

A CSV importer (catalog/management/commands/import_redirects.py)
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: the HttpResponse class that handles the redirect. Default HttpResponsePermanentRedirect.

  • response_gone_class: the class used for a row with a blank new_path. Default HttpResponseGone. (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:

A temporary-redirect variant (catalog/middleware.py)
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.

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:

A method-preserving permanent redirect
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

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_path with request.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 as old_path (and the target as new_path, including its own query). A mapping that must ignore the query is a job for a subclass or for the web server.

  • APPEND_SLASH is honoured. If there is no exact match, the path does not end in a slash and APPEND_SLASH is True, 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_path is not validated against old_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_path may be any https:// 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.

  • RedirectFallbackMiddleware does 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.

The six filters

Filter What it does Examples (verified)

apnumber

Spells out 1-9 following Associated Press style; any other number is returned as is. Accepts an integer or its string form.

1 becomes one; 3 becomes three; 10 becomes 10; 0 stays 0.

intcomma

Converts an integer or float (or the string form of either) to a string with a separator every three digits. Respects format localisation.

4500000 becomes 4,500,000; 4500.2 becomes 4,500.2; Decimal("1234567.50") becomes 1,234,567.50.

intword

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).

1000000 becomes 1.0 million; 1200000 becomes 1.2 million; 1200000000 becomes 1.2 billion; -1200000000 becomes -1.2 billion; 999999 stays 999999.

naturalday

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 date filter) or DATE_FORMAT when there is none.

Today’s date gives today; yesterday’s gives yesterday; a date 40 days earlier gives Aug. 24, 2026, or 2026-08-24 when the format argument 'Y-m-d' is passed.

naturaltime

For datetimes, "how many seconds, minutes or hours ago", falling back to the timesince format beyond a day. A future datetime gets a "from now" phrase.

29 seconds ago; a minute ago; 4 minutes ago; an hour ago; 2 hours ago; 1 day, 2 hours ago; 4 minutes from now; 1 week, 2 days from now; now.

ordinal

Converts an integer to its ordinal. Negative integers are returned unchanged. Accepts an integer or its string form.

1 becomes 1st; 2 becomes 2nd; 3 becomes 3rd; 11 becomes 11th; 22 becomes 22nd; -3 stays -3.

A single template shows them together. The view passes plain Python values:

A template using all six filters (catalog/templates/catalog/book_list.html)
{% 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>
The view (catalog/views.py)
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})

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:

Filter tests (catalog/tests.py)
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"
        )

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."

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 by ordinal, intcomma and intword, and None produces the text None (so |default_if_none first when a value can be missing). A filter never raises on bad input, which hides data problems; validate upstream.

  • apnumber and ordinal encode English conventions. apnumber is 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.

  • intword for money. "1.2 million" is a rounding; show exact amounts with intcomma (or a currency formatter) and use intword only for headline figures.

  • Time zones. Pass aware datetimes to naturaltime (USE_TZ = True is the default since Django 5.0) and build test values from timezone.now() and timezone.localdate(). See Localization & Time Zones.

  • Caching. A cached fragment containing naturaltime freezes 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 floatformat and date. 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 kwargs

the pages have structure, translations or per-page behaviour: write a TemplateView or a model

A page that mixes static HTML with dynamic data

a template (possibly named by template_name)

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: RedirectView or the web server

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 list FlatpageFallbackMiddleware and RedirectFallbackMiddleware in MIDDLEWARE_CLASSES. That setting was replaced by MIDDLEWARE in Django 1.10 and removed in 2.0; copy the dotted paths into MIDDLEWARE. The classes themselves are written with MiddlewareMixin and need no change.

  • url() and include() patterns. Older recipes write url(r"^pages/", include("django.contrib.flatpages.urls")). django.conf.urls.url() was removed in Django 4.0; use path("pages/", include(…​)), or re_path() for the catch-all.

  • USE_L10N. Books and old Stack Overflow answers say humanize’s intcomma needs USE_L10N = True. Since Django 3.2 intcomma and intword no 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 pytz to prepare values for naturaltime are out of date: Django switched to zoneinfo by default in 4.0 and removed pytz support in 5.0, and USE_TZ defaults to True from 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.

Table 1. Changes by version (humanize, plus related changes)
Version Change

1.10 / 2.0

MIDDLEWARE introduced (1.10); MIDDLEWARE_CLASSES removed (2.0).

3.0

intword translates 1.0 as a singular phrase and every other numeric value as plural; this may be incorrect for some languages.

3.1

intword supports negative integers.

3.2

intcomma and intword no longer depend on USE_L10N.

4.0

url() removed; zoneinfo becomes the default time zone implementation.

5.0

USE_L10N removed; pytz support removed; USE_TZ defaults to True.

6.1

No change to flatpages, redirects or humanize. RedirectView.preserve_request is new, HttpResponseRedirect accepts a max_length argument, and calendar versioning (DEP 20) renames the deprecation warning classes (RemovedInDjango70Warning becomes RemovedInDjango2028Warning).

Checklist

  • django.contrib.sites is installed, SITE_ID is set, and the Site row’s domain is the real host name.

  • Every flatpage is attached to at least one site; flatpages/default.html exists and renders under DEBUG = 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_path for 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 check passes and the tests cover a served flatpage, a redirect, a 410 and a humanised view.