Sites, Sitemaps & Feeds
|
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 in a scratch project on SQLite: the 30 tests of the page’s test module pass with the Django test
runner, and the same URLs were fetched with curl from runserver and from uvicorn (ASGI). None of the examples needs
PostgreSQL or Redis. Three small contrib packages sit together here because they share one idea: a Django project knows
its own public identity and publishes it for machines. The sites framework stores the domain and display name of each
website the project serves; sitemaps tell search engines which URLs exist and when they changed; syndication feeds
(RSS and Atom) tell feed readers, podcast clients and other programs what is new. The running scenario is Bookshelf,
the book catalogue built across this section: one Django project serves books.example.org and a second storefront
shop.example.net from one database, lists new books in a feed, and publishes a multilingual sitemap. The app is called
catalog, as on the earlier pages.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Know the current domain and site name |
Add |
|
Serve several sites from one database |
Give content a |
|
Build absolute URLs and e-mail links |
|
|
Publish an XML sitemap |
A |
|
More than 50,000 URLs, or several sections |
Use the index view plus the section view; Django paginates each section for you. |
|
Localised pages |
|
|
Publish an RSS or Atom feed |
A |
|
One feed per object (per author, per category) |
Implement |
|
Podcast or attachment feed |
|
|
Make the sitemap and feed cheap to serve |
|
The Bookshelf scenario
The editors of Bookshelf run two storefronts from one project. A book belongs to one or more sites, drafts must never be
published, and both machines (search engines, feed readers) and humans (links in e-mails) need correct absolute URLs.
The catalog models used by every example below are small.
from django.contrib.sites.managers import CurrentSiteManager
from django.contrib.sites.models import Site
from django.db import models
from django.urls import reverse
class Author(models.Model):
name = models.CharField(max_length=100)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("catalog:author-detail", kwargs={"pk": self.pk})
class Book(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
summary = models.TextField(blank=True)
published = models.DateTimeField()
updated = models.DateTimeField(auto_now=True)
is_draft = models.BooleanField(default=False)
authors = models.ManyToManyField(Author, related_name="books")
sample_path = models.CharField(max_length=200, blank=True)
sample_size = models.PositiveIntegerField(default=0)
has_spanish_edition = models.BooleanField(default=True)
sites = models.ManyToManyField(Site)
objects = models.Manager() # first: the admin and generic views use it
on_site = CurrentSiteManager() # finds the "sites" field by itself
class Meta:
ordering = ["-published"]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.slug})
Source: Django: The CurrentSiteManager
Two things in that listing matter for everything that follows. get_absolute_url() returns a path without a domain,
which is what the sitemap and syndication frameworks expect (they add the domain themselves). And the sites field is
called sites, one of the two names CurrentSiteManager looks for by default. The models, URLs and views are
introduced on Models & Fields, Relationships
and URLs & Routing.
The sites framework
The framework is optional and tiny: one model, a manager, a middleware and a shortcut. The documentation describes it as a hook for associating objects and functionality with particular websites and a holding place for their domain names and "verbose" names. Use it when one Django installation powers more than one site and you need to tell them apart; use it even for a single site, because several contrib applications consult it to build absolute URLs.
The model is django.contrib.sites.models.Site with two fields: domain (the fully qualified domain name, for example
www.example.com, unique and validated to contain no whitespace) and name (a human-readable name, up to 50
characters). Its natural key is the domain, so fixtures can refer to a site by domain instead of by id (see
Fixtures, Serialization & Initial Data).
Source: Django: The "sites" framework
Enabling it
Enabling takes three steps: add django.contrib.sites to INSTALLED_APPS, define SITE_ID, and run migrate.
SITE_ID is the database id of the Site row that belongs to this settings file. Read it from the environment so one
code base can run as several sites (the deployment section below shows a second option).
import os
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.sitemaps",
"catalog",
]
MIDDLEWARE = [
# ... the usual security, session, locale, common, CSRF, auth, messages ...
"django.contrib.sites.middleware.CurrentSiteMiddleware", # request.site
"django.middleware.http.ConditionalGetMiddleware", # 304 for feeds and sitemaps
]
SITE_ID = int(os.environ.get("SITE_ID", "1"))
django.contrib.sites registers a post_migrate handler that creates a default site called example.com with the
domain example.com, but only when the Site table is empty (the handler’s source checks Site.objects.exists()). It
also runs after Django creates the test database, so tests always find a site with the primary key SITE_ID. Nobody
wants example.com in production. The documentation recommends a data migration, and there is one trap: because the
default site is created by post_migrate, which runs after all migrations, a migration that only calls
Site.objects.filter(pk=1).update(…) finds nothing on a fresh database. Use update_or_create() on the primary key
instead.
import os
from django.conf import settings
from django.db import migrations
def set_site(apps, schema_editor):
Site = apps.get_model("sites", "Site")
Site.objects.update_or_create(
pk=settings.SITE_ID,
defaults={
"domain": os.environ.get("SITE_DOMAIN", "books.example.org"),
"name": "Bookshelf",
},
)
class Migration(migrations.Migration):
dependencies = [
("catalog", "0001_initial"),
("sites", "0002_alter_domain_unique"),
]
operations = [migrations.RunPython(set_site, migrations.RunPython.noop)]
The migration is a plain data migration (see Migrations). Running
python manage.py migrate on an empty SQLite database in the scratch project left exactly one site, with domain
books.example.org and name Bookshelf. Rename or add sites later in the admin, or with Site.objects.create().
Finding the current site
Everything revolves around one question: which Site row describes the request I am serving? Django answers it in this
order.
The same logic sits behind the manager method Site.objects.get_current(request=None). With SITE_ID set, the request
is not needed at all, which is why the documentation shows Site.objects.get_current() for code that has no request
(management commands, tasks, signals). Without SITE_ID, get_current() needs the request and raises
ImproperlyConfigured if you do not pass one. The lookup by host compares domain case-insensitively, tries the host
with its port first (shop.example.net:8000) and then retries without the port.
from django.contrib.sites.models import Site
from django.contrib.sites.shortcuts import get_current_site
def about(request):
site = get_current_site(request) # Site, or RequestSite without the framework
return HttpResponse("About %s (%s)" % (site.name, site.domain))
def from_a_task():
return Site.objects.get_current() # needs SITE_ID: there is no request
def in_a_view_with_the_middleware(request):
return request.site # set by CurrentSiteMiddleware
Prefer get_current_site(request) in views: it works whether or not the framework is installed (it then returns a
RequestSite, described below), and it tells you the domain even when SITE_ID is unset. Add
django.contrib.sites.middleware.CurrentSiteMiddleware to MIDDLEWARE when many views or templates need the site: it
calls get_current_site() once per request and stores the result in request.site, so a template can print
{{ request.site.name }} (the request context processor is enabled by default in new projects). It needs no other middleware to run before it; see Middleware.
SITE_ID or the Host header
The two strategies have different deployment shapes.
-
SITE_IDset. Every process serves exactly one site. Run one settings module (or one environment variable value) per site. The database is shared, the process identity is explicit, and no request is needed to find the site. This is the shape the documentation describes ("a separate settings file with each `SITE_ID`"). -
SITE_IDunset. One process serves every site and picks the row from theHostheader. It is the more flexible option, but every host that reaches the application must exist in the table, andALLOWED_HOSTSmust list them all. A host that is allowed but has no row raisesSite.DoesNotExist; in the test run, a request forlocalhostwithSITE_IDremoved raised exactly that.
Never hard-code SITE_ID == 3 in views, as the documentation points out: ids change between environments. Compare
get_current_site(request).domain instead.
The cached site object
Each call to Site.objects.get_current() would be a query, so Django caches the object in a module-level dictionary
(SITE_CACHE) the first time it is read; later calls return it without touching the database. Saving or deleting a
Site clears the matching entry, and Site.objects.clear_cache() clears everything. The dictionary lives in one Python
process, so after you rename a site in the admin, other worker processes keep serving the old value until they restart
or clear the cache themselves.
Site.objects.get_current()
with self.assertNumQueries(0):
Site.objects.get_current() # served from the cache
Site.objects.clear_cache()
with self.assertNumQueries(1):
Site.objects.get_current() # forced query
In tests, call Site.objects.clear_cache() in setUp() whenever a test modifies or switches sites, otherwise an earlier
test’s cached row can leak into the next. See Caching for Django’s real cache framework,
which is unrelated to this dictionary.
RequestSite: when there is no sites table
Some contrib code is written so that it also works when the database-backed framework is not installed.
django.contrib.sites.requests.RequestSite has the same domain and name attributes as Site, both taken from
request.get_host() (so the port is included). Its save() and delete() exist only to match the interface and raise
NotImplementedError. get_current_site() returns one when django.contrib.sites is not installed, so view code that
only reads .domain and .name stays portable.
from django.contrib.sites.requests import RequestSite
site = RequestSite(request) # request.get_host() == "localhost:8000"
site.domain, site.name # ("localhost:8000", "localhost:8000")
site.save() # NotImplementedError: RequestSite cannot be saved.
Source: Django: RequestSite objects
Scoping content to sites
The framework’s main job is to let rows say which sites they belong to. Two relationships cover almost everything.
Many sites, one row: the same book appears on both storefronts, edited once in the admin.
class Article(models.Model):
headline = models.CharField(max_length=200)
sites = models.ManyToManyField(Site)
One site only: use a foreign key.
class Article(models.Model):
headline = models.CharField(max_length=200)
site = models.ForeignKey(Site, on_delete=models.CASCADE)
A view that serves such content must check the current site, otherwise a book that only belongs to the shop would be
reachable on the other domain by guessing its URL. Doing it by hand repeats the same filter(sites__id=…) everywhere,
so Django offers a manager.
CurrentSiteManager
django.contrib.sites.managers.CurrentSiteManager is a manager that filters every query down to objects associated with
the current site, according to SITE_ID. It is only usable when SITE_ID is defined. By default it looks for a
ForeignKey called site or a ManyToManyField called sites; for any other field name pass it to the constructor:
CurrentSiteManager("publish_on"). Passing a name that does not exist raises ValueError.
Keep a plain objects = models.Manager() as the first manager. As the documentation explains, defining a manager
removes the automatic objects, and the admin and generic views use whichever manager is defined first, so the first
one should be the unfiltered one or the admin would hide other sites' rows from the editors. Book.on_site (the second
manager in the model above) is the scoped one.
from django.shortcuts import get_object_or_404, render
from .models import Book
def book_list(request):
books = Book.on_site.filter(is_draft=False)
return render(request, "catalog/book_list.html", {"books": books})
def book_detail(request, slug):
book = get_object_or_404(Book.on_site, slug=slug, is_draft=False)
return render(request, "catalog/book_detail.html", {"book": book})
Source: Django: The CurrentSiteManager
In the test run, Book.objects.count() was 5 (three published books, a draft and a book that belongs only to the shop),
Book.on_site.count() was 4 on SITE_ID = 1, and Book.on_site.all() under override_settings(SITE_ID=<shop id>)
returned only the shop’s book. The detail view answered 200 for a book on the current site and 404 both for the draft
and for the book that lives only on the other site. The two statements below are, as the documentation says,
equivalent.
Book.objects.filter(sites=settings.SITE_ID)
Book.on_site.all()
Source: Django: The CurrentSiteManager
Related managers (author.books) and querysets obtained through relations use the model’s default manager (the first
one), so they are not site-scoped. The author feed below filters with Book.on_site.filter(authors=obj) for that
reason. Custom managers and querysets are covered on
Managers & Custom QuerySets.
Absolute URLs and e-mail
get_absolute_url() is deliberately domain-less, which suits links inside a page. E-mails, feeds, sitemaps, Open Graph
tags and canonical links need the scheme and domain as well, and the site row is the right place to get the domain.
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail import send_mail
def full_url(request, obj):
site = get_current_site(request)
return "https://%s%s" % (site.domain, obj.get_absolute_url())
def welcome(request, user):
site = get_current_site(request)
send_mail(
"Thanks for subscribing to %s alerts" % site.name,
"Thanks for your subscription.\n\n-The %s team." % site.name,
"editor@%s" % site.domain,
[user.email],
)
In the scratch project this printed https://books.example.org/books/sketch-of-the-analytical-engine/ for the first
book and sent a message with the subject Thanks for subscribing to Bookshelf alerts from
editor@books.example.org. For more per-site variation (different wording, not just a different name) the documentation
suggests per-site template directories in DIRS. For sending, see Email.
The scheme is not stored on Site: use https unconditionally for public sites, or derive it from
request.scheme (which is only right behind a proxy if SECURE_PROXY_SSL_HEADER is configured, see
Security).
One settings file per site
With SITE_ID set, each deployment is "this code base, this site id". The simplest way to express that is a small
settings module per site that imports the shared settings.
from .settings import * # noqa: F403
SITE_ID = 2
python manage.py check --settings=config.settings_shop passed, and a shell started with that module reported
shop.example.net as the current site and Book.on_site.count() of 0 against 2 books in the table (the demo books
belong to the first site). Start each process with the matching DJANGO_SETTINGS_MODULE, or use the environment-variable
form of SITE_ID shown earlier. See Settings & Configuration for split
settings in general.
Where Django itself uses the sites framework
The documentation lists the places that consult it, which is also the best argument for creating a proper site row even for a single-site project.
-
Redirects. Each redirect belongs to a site, and the lookup takes the current site into account.
-
Flatpages. Each flatpage belongs to a site, and
FlatpageFallbackMiddlewarechecks the current site. -
Syndication. Item title and description templates get a
{{ site }}variable, and relative item URLs get the current site’s domain. -
Authentication.
LoginViewpasses the current site’s name to the template as{{ site_name }}. -
Content types. The
shortcutview indjango.contrib.contenttypes.viewsuses the current site’s domain to build an object’s URL. -
Admin. The "view on site" link uses the current site to work out the domain it redirects to.
The last item is easy to see: the test run created a superuser, requested /admin/r/<content type id>/<book id>/ and
received a redirect to http://books.example.org/books/book-1/, and the change form of a book contained that same
/admin/r/… link. See The Admin Site for the "view on site" link.
Pitfalls
-
SITE_IDof a row that does not exist.Site.DoesNotExiston the first request. Create the row first (data migration) and keep the id stable. -
The default
example.comrow survives. Sitemaps, feeds and e-mails then point atexample.com. The data migration above, or an admin edit during deployment, prevents it. -
A stale cache after renaming a site in a multi-process deployment (see the cache section): restart after changing site rows, or avoid changing them.
-
Hard-coded ids and domains in views, templates and fixtures. Use the natural key (
domain) in fixtures andget_current_site()in code. -
Putting a
CurrentSiteManagerfirst. Editors lose sight of other sites' rows in the admin. -
Using sites for per-tenant data isolation. The framework scopes content by a shared database column and a convention; it is not an access-control boundary. Row-level authorisation still belongs in Permissions & Authorization.
Sitemaps
A sitemap is an XML file that tells search-engine crawlers which URLs exist, when they last changed, how often they
change and how important they are relative to the rest of the site. The framework builds that file from Python classes,
much like the syndication framework later on this page. It does not add database tables; django.contrib.sitemaps is in
INSTALLED_APPS only so that the template loader finds the default sitemap.xml and sitemap_index.xml templates, and
the TEMPLATES setting must contain a DjangoTemplates backend with APP_DIRS set to True (the default). The scratch
run confirmed the failure mode: removing the app from INSTALLED_APPS turns a sitemap request into
TemplateDoesNotExist: sitemap.xml. The sites framework should be installed too, because the generated URLs need a
domain. Without it the views fall back to a RequestSite built from the request host, and building URLs outside a request
(Sitemap.get_urls() in a management command) raises ImproperlyConfigured unless you pass a Site or RequestSite. For
search-engine optimisation in general (titles, metadata, structured data) see SEO.
A first sitemap
A sitemap class subclasses django.contrib.sitemaps.Sitemap and may live anywhere. The only required member is
items(), which returns a sequence or QuerySet of any objects at all; those objects are handed to the optional
location, lastmod, changefreq and priority hooks. If location is missing, Django calls get_absolute_url() on
each item. A section label maps to each class in a sitemaps dictionary that the view receives as an extra argument.
from django.contrib.sitemaps import Sitemap
from .models import Book
class BookSitemap(Sitemap):
changefreq = "weekly"
priority = 0.8
protocol = "https"
def items(self):
# Ordered: the sitemap paginates its items.
return Book.on_site.filter(is_draft=False).order_by("pk")
def lastmod(self, obj):
return obj.updated
from django.contrib.sitemaps.views import sitemap
from django.urls import path
from catalog.sitemaps import BookSitemap
sitemaps = {"books": BookSitemap}
urlpatterns = [
path(
"sitemap.xml",
sitemap,
{"sitemaps": sitemaps},
name="django.contrib.sitemaps.views.sitemap",
),
]
Source: Django: Sitemaps, initialization
The file name is arbitrary, the location is not. Crawlers only trust a sitemap for URLs at its own path level and below:
a sitemap at /sitemap.xml may list any URL on the site, one at /content/sitemap.xml may only list URLs that start
with /content/. Always put the sitemap at the root. Two details are worth knowing. The items are passed through
a Paginator, so an unordered queryset triggers Django’s unordered-pagination warning: always order. And items() may
return anything: the static-pages example later returns URL names.
The Sitemap class reference
Every option the framework understands, with its default.
| Member | Meaning | Default |
|---|---|---|
|
Required. Sequence or queryset of objects; passed to the hooks below. |
(none; the base class returns an empty list) |
|
Optional attribute or method returning an absolute path (no scheme, no domain) for an item. |
|
|
Attribute or one-argument method returning a |
none |
|
Attribute or method: |
none |
|
Attribute or method returning a string or float such as |
|
|
|
the request’s scheme; |
|
Maximum URLs per page of the sitemap; must not exceed the protocol’s 50,000. |
|
|
Property returning a |
|
|
Generate URLs for every language in |
|
|
Language codes to use when |
|
|
With |
|
|
With |
|
|
Latest value of |
computed from |
|
The language codes in which an item exists. |
|
Source: Django: Sitemap class reference
The protocol default is the one worth remembering. It used to be 'http' outside a request; the 4.0 release notes
announced the change to 'https' and Django 5.0 made it. Anything that builds a sitemap in a management command or a
task (sitemap.get_urls(site=…)) now gets https URLs unless protocol says otherwise.
lastmod, Last-Modified and conditional requests
If every item has a lastmod, the sitemap view sends a Last-Modified header equal to the latest one. Add
django.middleware.http.ConditionalGetMiddleware to MIDDLEWARE and Django will answer a request carrying
If-Modified-Since with a bodiless 304 Not Modified when nothing changed, which saves both bandwidth and the cost of
rendering the XML. The test run fetched /sitemap-books.xml, replayed the Last-Modified value in If-Modified-Since
and received 304. lastmod must return a datetime; the generated <lastmod> element is shortened to a date (for
example 2026-10-03).
GenericSitemap
When a section is simply "all rows of a queryset", django.contrib.sitemaps.GenericSitemap needs no subclass. Its
info_dict must contain a queryset and may contain a date_field used for lastmod and for
get_latest_lastmod(); priority, changefreq and protocol apply to every URL.
from django.contrib.sitemaps import GenericSitemap
from .models import Author
author_sitemap = GenericSitemap(
{"queryset": Author.objects.order_by("pk")},
priority=0.4,
changefreq="monthly",
)
Source: Django: Sitemap shortcuts
Note that a GenericSitemap is created at import time, so the queryset is built once and evaluated lazily at request
time; the class makes a clone (queryset.filter()) for each use. A dictionary value may be a class or an instance, as
the two entries in the combined sitemaps dictionary below show.
Static pages
Not every URL is a model instance: the home page, an "about" page or a licence page have none. The documented solution
is a sitemap whose items are URL names, resolved with reverse() in location.
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
class StaticViewSitemap(Sitemap):
priority = 0.5
changefreq = "daily"
def items(self):
return ["catalog:book-list", "catalog:about"]
def location(self, item):
return reverse(item)
Source: Django: Sitemap for static views
The test run requested this section and found http://books.example.org/ and http://books.example.org/about/
(plain http because the class sets no protocol and the test request used it).
A sitemap index
The protocol caps one sitemap file at 50,000 URLs, and a large site usually wants one file per kind of content anyway.
A sitemap index is an XML file that references the individual sitemap files. The URLconf then uses two views: index
at /sitemap.xml, and sitemap with a section keyword argument. The classes and the sitemaps dictionary do not
change.
sitemaps = {
"books": BookSitemap,
"static": StaticViewSitemap,
"authors": author_sitemap,
}
from django.contrib.sitemaps import views as sitemap_views
from django.urls import path
from django.views.decorators.cache import cache_page
from catalog.sitemaps import sitemaps
urlpatterns = [
path(
"sitemap.xml",
cache_page(86400)(sitemap_views.index),
{"sitemaps": sitemaps, "sitemap_url_name": "sitemaps"},
),
path(
"sitemap-<section>.xml",
cache_page(86400)(sitemap_views.sitemap),
{"sitemaps": sitemaps},
name="sitemaps",
),
]
Source: Django: Creating a sitemap index
Because the section view is wrapped in cache_page(), the documentation requires naming it and telling the index view
the name through sitemap_url_name (the default name is django.contrib.sitemaps.views.sitemap). Without that, the
index cannot reverse the section URLs. The scratch run produced this index (abridged):
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://books.example.org/sitemap-books.xml</loc><lastmod>2026-10-03T01:56:54.165102+00:00</lastmod></sitemap>
<sitemap><loc>http://books.example.org/sitemap-static.xml</loc></sitemap>
<sitemap><loc>http://books.example.org/sitemap-authors.xml</loc></sitemap>
</sitemapindex>
Source: Django: Creating a sitemap index
Observe how each <loc> takes its protocol from its own sitemap (https for books, the request’s http for the other
two) and the domain from the current site, and how only the books entry has a lastmod: the index shows one when
get_latest_lastmod() returns a value, and sends a Last-Modified header only if every section has one. Both views
also add X-Robots-Tag: noindex, noodp, noarchive to the response (seen in the curl headers), so the XML files
themselves are not indexed as pages.
Pagination. A section with more than limit items is split automatically: the index lists
sitemap-books.xml?p=2, ?p=3 and so on, and the section view serves the requested page. With a limit of 2 the test
run produced an index with sitemap-books.xml and sitemap-books.xml?p=2, a second page with the single remaining
book, and a 404 for page 9. With i18n = True the pages count one entry per item and language, so a catalogue of
30,000 books in two languages needs two pages.
URL order. Django resolves the first matching pattern, and sitemap-<section>.xml matches any section name. A
special-purpose sitemap such as sitemap-news.xml (see the template section) must be listed before it, or the
section view answers 404 because "news" is not a key of sitemaps.
Localised sitemaps and hreflang
For a site with i18n_patterns() (see Internationalization & Translation),
search engines want one <url> per language and links that connect the language versions. Three attributes, all added in
3.2, do it.
class BookSitemap(Sitemap):
changefreq = "weekly"
priority = 0.8
protocol = "https"
i18n = True
alternates = True
x_default = True
def items(self):
return Book.on_site.filter(is_draft=False).order_by("pk")
def lastmod(self, obj):
return obj.updated
def get_languages_for_item(self, item):
return ["en", "es"] if item.has_spanish_edition else ["en"]
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
urlpatterns += i18n_patterns(
path("", include("catalog.urls")),
prefix_default_language=False,
)
With i18n = True, Django activates each language while calling location, so reversed URLs come out in that
language. alternates = True adds xhtml:link rel="alternate" hreflang="…" entries to every <url>; x_default adds an
hreflang="x-default" fallback built from LANGUAGE_CODE. get_languages_for_item(item) (4.2) lists the languages in
which an item exists, so an item without a Spanish edition has no Spanish URL and no Spanish alternate. The test run
confirmed it: three books produced six <url> entries, and after marking one book as English-only five entries
remained and /es/books/book-2/ was gone.
<url><loc>https://books.example.org/books/notes-on-the-engine/</loc><lastmod>2026-10-03</lastmod>
<changefreq>weekly</changefreq><priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://books.example.org/books/notes-on-the-engine/"/>
<xhtml:link rel="alternate" hreflang="es" href="https://books.example.org/es/books/notes-on-the-engine/"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://books.example.org/books/notes-on-the-engine/"/>
</url>
The pitfall to know about is x_default. The implementation builds the fallback URL for LANGUAGE_CODE and then removes
the /<language code>/ segment from it. That is right when the default language has no URL prefix
(prefix_default_language=False, as above) and wrong when it does: with the default prefix_default_language=True the
scratch run produced an x-default link without /en/, a URL that is not served by the project. Choose one convention
and test the output.
Custom templates and context variables
Both views return TemplateResponse objects and accept template_name and content_type arguments, so the XML can be
customised per URL, and the response can be modified before rendering (see
Templates). The index view gives the template a sitemaps list whose entries have
location and lastmod. The sitemap view gives it a urlset list whose entries expose alternates, changefreq,
item, lastmod, location and priority. The item attribute is what makes extensions such as Google News
sitemaps possible.
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9">
{% spaceless %}
{% for url in urlset %}
<url>
<loc>{{ url.location }}</loc>
{% if url.lastmod %}<lastmod>{{ url.lastmod|date:"Y-m-d" }}</lastmod>{% endif %}
<news:news>
<news:publication_date>{{ url.item.published|date:"Y-m-d" }}</news:publication_date>
<news:title>{{ url.item.title }}</news:title>
</news:news>
</url>
{% endfor %}
{% endspaceless %}
</urlset>
urlpatterns = [
path(
"sitemap-news.xml",
sitemap_views.sitemap,
{
"sitemaps": {"news": NewsSitemap},
"section": "news",
"template_name": "catalog/sitemap_news.xml",
},
name="sitemap-news",
),
# ... the generic sitemap-<section>.xml route comes after ...
]
class NewsSitemap(BookSitemap):
# With i18n = True, url.item in a template is an (item, lang_code) tuple.
i18n = False
alternates = False
x_default = False
The second gotcha is in the class: with i18n = True the item seen by the template is a tuple of the object and its
language code, because each (item, language) pair is a separate entry. The first run of this template against the
multilingual BookSitemap printed empty news:title elements; the non-i18n subclass fixed it (the test checks that the
output contains <news:title>Book 1</news:title> and parses as XML). The news example is the documentation’s template
reduced to two fields, used here to prove that url.item reaches the template.
Telling crawlers where the sitemap is
Crawlers find sitemaps through robots.txt (a Sitemap: line with an absolute URL) or by submitting the URL in a search
console. A template makes the line follow the current site.
User-agent: *
Disallow: /admin/
Sitemap: https://{{ request.site.domain }}/sitemap.xml
from django.views.generic import TemplateView
urlpatterns += [
path(
"robots.txt",
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
),
]
Source: Django: Sitemaps, initialization
curl -H 'Host: books.example.org' http://127.0.0.1:8956/robots.txt returned Sitemap: https://books.example.org/sitemap.xml.
The template relies on CurrentSiteMiddleware (for request.site). Search engines used to offer a "ping" URL for
announcing a new sitemap; Django’s ping_google() function and the ping_google management command were removed in
5.0 after Google deprecated its endpoint, so do not follow older tutorials that call them, and use robots.txt or the
search console instead.
Syndication feeds (RSS and Atom)
The syndication framework turns Python classes into RSS or Atom feeds. A Feed subclass describes the feed; an instance
of it is a view. It needs no entry in INSTALLED_APPS, no tables and no templates (the scratch project does not list
django.contrib.syndication in INSTALLED_APPS and its feeds work), but it uses the sites framework for the domain when one
is installed. There is also a lower-level generator, django.utils.feedgenerator, for feeds outside a web request.
A first feed
title, link and description map to the standard RSS <title>, <link> and <description> elements (for Atom, set
subtitle instead of description; see the tandem section). items() returns the objects to publish; as with
sitemaps they need not be model instances. For each item Django calls item_title() and item_description(), which
default to the string representation of the item, and takes the item’s link from item_link() or, failing that,
get_absolute_url().
from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Enclosure
from .models import Book
class LatestBooksFeed(Feed):
title = "Bookshelf: new books"
link = "/"
description = "The latest books added to the catalogue."
ttl = 600
language = "en"
categories = ["books"]
author_name = "Bookshelf editors"
feed_copyright = "Bookshelf"
def items(self):
return Book.on_site.filter(is_draft=False).order_by("-published")[:5]
def item_title(self, item):
return item.title
def item_description(self, item):
return item.summary
def item_pubdate(self, item):
return item.published
def item_updateddate(self, item):
return item.updated
def item_author_name(self, item):
return ", ".join(a.name for a in item.authors.all()) or None
def item_categories(self, item):
return [a.name for a in item.authors.all()]
from django.urls import path
from catalog import feeds
urlpatterns += [
path("feeds/books.rss", feeds.LatestBooksFeed(), name="books-rss"),
]
Source: Django: A simple example
Note the parentheses: feeds.LatestBooksFeed() is an instance, and instances of Feed are callable views. Reverse
it with the name. The scratch run returned (abridged) a document with <rss version="2.0" xmlns:atom=…>, a channel
with the title, a link of http://books.example.org/ (the site domain was added to the relative "/"), an
atom:link rel="self" pointing at the feed, <language>en</language>, <ttl>600</ttl> and one <item> per book with
pubDate, guid, category and dc:creator (RSS 2.0 puts the item author name into dc:creator when no e-mail is
given). The Content-Type header was application/rss+xml; charset=utf-8.
How the framework resolves each setting is the same everywhere: title, link, description and items may be a
method taking the object returned by get_object() (title(self, obj)), a method with no arguments, or a plain class
attribute, tried in that order. The item_* hooks work the same way with the item as the argument, or a fixed value
for every item.
Descriptions are HTML
An RSS <description> is conventionally HTML, and feed readers render it as such. Django XML-escapes whatever
item_description() returns, once. A summary containing < or & therefore reaches the reader as markup. The test
data uses Summary <1> & more on purpose: parsing the feed gives back the raw string, which a reader then treats as
HTML. Two safe approaches exist: return text that is already valid HTML (build it with format_html()), or render the
description through a template, whose auto-escaping handles it (the next section). Never put user-supplied text into a
feed description unescaped. See Security for escaping rules.
Title and description templates
For anything richer than a one-liner, set title_template and description_template to template names. They are
rendered once per item with two context variables: {{ obj }} (the current item, whatever items() returned) and
{{ site }} (the current Site, or a RequestSite when the framework is not installed). The get_context_data()
hook adds more; it receives item, obj (the object from get_object()), site and request, and, like the generic
views' method, should call super() and return the modified dictionary.
{{ obj.summary }} (from {{ site_name }}, {{ site.domain }})
Source: Django: A simple example
The next section’s feed uses this template. Because it is a Django template, obj.summary is auto-escaped: the same
summary with <2> and & came back as <2> and & in the parsed XML text, which is exactly one level of
HTML escaping inside the XML escaping. See Templates for the template language.
A feed per object: get_object()
A site often needs one feed per author, category or tag. Creating a class per object would couple data to code; instead
the URL carries the argument. Django passes the request and the URL arguments to get_object(request, *args, **kwargs),
and title, link, description and items can then take the resulting obj. get_object() should raise
ObjectDoesNotExist when the object is missing, which Django turns into a 404.
class AuthorBooksFeed(Feed):
description_template = "feeds/author_book_description.html"
def get_object(self, request, author_id):
# Author.DoesNotExist is an ObjectDoesNotExist: Django turns it into a 404.
return Author.objects.get(pk=author_id)
def title(self, obj):
return "Books by %s" % obj.name
def link(self, obj):
return obj.get_absolute_url()
def description(self, obj):
return "Books written by %s." % obj.name
def items(self, obj):
return Book.on_site.filter(authors=obj, is_draft=False).order_by("-published")[:30]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["site_name"] = context["site"].name
return context
path("feeds/authors/<int:author_id>/", feeds.AuthorBooksFeed(), name="author-feed"),
Source: Django: A complex example
curl -H 'Host: books.example.org' http://127.0.0.1:8956/feeds/authors/1/ returned a channel titled Books by Ada Lovelace
with the two books and their descriptions followed by (from Bookshelf, books.example.org); author 99 answered 404.
This feed has no item_pubdate(), so its items carry no dates and no Last-Modified header is sent (the conditional-GET
section explains why that matters).
RSS or Atom: feed_type
By default feeds are RSS 2.0 (Rss201rev2Feed, which the documentation calls RSS 2.01). To change the format set
feed_type to a class, not an instance. The available classes in django.utils.feedgenerator are
Rss201rev2Feed (default), RssUserland091Feed (RSS 0.91) and Atom1Feed. Atom has no feed-level description but has
a subtitle, and Django does not copy one into the other, so define subtitle for Atom explicitly. The usual way to
offer both formats is subclassing.
from django.utils.feedgenerator import Atom1Feed
class AtomLatestBooksFeed(LatestBooksFeed):
feed_type = Atom1Feed
subtitle = LatestBooksFeed.description
path("feeds/books.atom", feeds.AtomLatestBooksFeed(), name="books-atom"),
The Atom response had Content-Type: application/atom+xml; charset=utf-8, an xml:lang="en" attribute on <feed>, a
<subtitle>, a <link rel="self"> with the feed’s own URL and one <entry> per book with published, updated,
author, id, a summary type="html" and the enclosure as <link rel="enclosure">. Atom requires updated on every
entry, which is why item_updateddate() is worth defining in the shared base class; item_pubdate() fills
published in Atom and pubDate in RSS.
In the page <head>, advertise both feeds so readers and browsers can auto-discover them:
<link rel="alternate" type="application/rss+xml" title="New books (RSS)" href="{% url 'books-rss' %}">
<link rel="alternate" type="application/atom+xml" title="New books (Atom)" href="{% url 'books-atom' %}">
Enclosures (podcasts and attachments)
An enclosure attaches a file (an MP3, a PDF) to an item. If an item has a single enclosure use the three hooks
item_enclosure_url, item_enclosure_length (bytes) and item_enclosure_mime_type. For any number of enclosures per
item use item_enclosures, which returns a list of django.utils.feedgenerator.Enclosure(url, length, mime_type)
objects and takes precedence over the three single-enclosure hooks.
class LatestBooksFeed(Feed):
# ... as above ...
def item_enclosures(self, item):
if not item.sample_path:
return []
return [Enclosure(item.sample_path, str(item.sample_size), "application/pdf")]
Source: Django: Enclosures
The RSS output contained <enclosure length="1024" type="application/pdf" url="https://books.example.org/media/s1.pdf"/>
and the Atom output the equivalent link rel="enclosure". The URL must be absolute (an enclosure URL is not completed
with the site domain), and the length must be the real size in bytes: clients use it to preview downloads.
The Feed reference at a glance
Every feed-level hook accepts the three forms (method with obj, method without, attribute).
| Feed-level hook | Meaning |
|---|---|
|
Required. |
|
Atom’s counterpart of |
|
The feed’s own URL (otherwise built from the request) and, for Atom, its feed-level |
|
Feed author; the link must include scheme and domain. |
|
Iterable of strings; copyright notice; time-to-live (ignored for Atom). |
|
Language code; default |
|
A list of URL strings or |
|
A |
|
Template names for item titles and descriptions. |
|
Per-URL object; extra template context. |
| Item-level hook | Meaning |
|---|---|
|
Item text and URL; the link falls back to |
|
Stable identifier (default: the item link) and the |
|
Item author; e-mail and link require the name. |
|
|
|
Categories; Atom-only copyright; comments page URL (3.2). |
|
Enclosures, as above. |
Source: Django: Feed class reference
A guid is what readers use to decide whether they have seen an item: if you change the URL scheme, or the item’s link
varies per site, set item_guid() explicitly so that readers do not see every item as new (and set
item_guid_is_permalink to False when it is not a URL).
Language and URLs
Feeds include <language> (RSS) or xml:lang (Atom) automatically. Since 3.0 the default is
get_language() rather than LANGUAGE_CODE, so a feed requested under an active translation reports that language;
the language attribute overrides it. The scratch run activated Spanish around a feed that sets no language and got
xml:lang="es". For URLs, link may be an absolute path or a full URL; without a domain, Django inserts the current
site’s, according to SITE_ID (or the host when that is unset). Atom’s mandatory rel="self" link is filled the same
way. The scheme follows the request (request.is_secure()), which is why the curl output above shows http://: behind
a TLS-terminating proxy, configure SECURE_PROXY_SSL_HEADER or the feed will advertise plain http links.
Browser-friendly feeds: stylesheets (5.2)
Opened directly, an RSS file is raw XML. Since Django 5.2 every SyndicationFeed accepts a stylesheets argument, and
Feed a stylesheets attribute: for each entry Django writes an xml-stylesheet processing instruction at the top of
the document, so a browser can render the feed through XSLT or CSS. An entry is a URL string (the MIME type is guessed
from the extension) or a Stylesheet(url, mimetype=…, media=…) when you need to override the guess or the default
media of screen.
from django.templatetags.static import static
from django.utils.feedgenerator import Stylesheet
class StyledBooksFeed(LatestBooksFeed):
stylesheets = [
static("catalog/feed.xsl"),
Stylesheet(static("catalog/feed-print.css"), media="print"),
]
Source: Django: Feed stylesheets
The run produced <?xml-stylesheet href="/static/catalog/feed.xsl" media="screen" type="text/xsl"?> followed by a
second instruction with media="print" type="text/css". The documentation also shows reverse_lazy() for a view that
serves the XSLT, and a hardcoded URL. Use static() and the staticfiles system (see
Static Files & Front-End Assets) for files you ship. Django 6.1
adds an unrelated asset class that is also called Stylesheet, for form media; it lives in a different module than
django.utils.feedgenerator.Stylesheet.
The low-level feed generator
django.utils.feedgenerator is the engine under Feed; use it directly to produce feeds outside a view (a management
command that writes a static file, a task, a test). SyndicationFeed and its three subclasses share one interface:
construct with the feed metadata (title, link, description required; language, author_*, subtitle,
categories, feed_url, feed_copyright, feed_guid, ttl, stylesheets optional), call add_item() for each item
(title, link, description required; pubdate, updateddate, unique_id, enclosures, categories, comments
and the author fields optional), then write(outfile, encoding) or writeString(encoding).
import datetime
from django.utils import feedgenerator
f = feedgenerator.Atom1Feed(
title="My Blog",
link="https://www.example.com/",
description="In which I write.",
language="en",
author_name="Myself",
feed_url="https://example.com/atom.xml",
)
f.add_item(
title="Hot dog today",
link="https://www.example.com/entries/1/",
pubdate=datetime.datetime(2026, 10, 3, 12, tzinfo=datetime.UTC),
description="<p>Pink, plump and perfect.</p>",
)
print(f.writeString("UTF-8"))
Source: Django: SyndicationFeed classes
The output starts with <?xml version="1.0" encoding="UTF-8"?> and contains the entry. Note that write() and
writeString() put the encoding string you pass in the declaration: the test passes "utf-8" to one and "UTF-8" to
the other and the two outputs differ only in the case of that word. Control characters that XML forbids (for example
\x08) raise ValueError when the feed is serialised; sanitise user content before it reaches a feed.
Custom generators (podcasts, GeoRSS)
If the format is a spin-off of RSS or Atom, subclass Rss201rev2Feed or Atom1Feed and extend the callbacks:
root_attributes() (attributes of the root element), add_root_elements(handler) (extra elements inside the channel
or feed, with handler an XMLGenerator from Python’s SAX library), item_attributes(item) and
add_item_elements(handler, item). Always call super(), which writes the required elements. Plug the class in with
feed_type.
from django.utils.feedgenerator import Rss201rev2Feed
class DublinCoreRssFeed(Rss201rev2Feed):
def root_attributes(self):
attrs = super().root_attributes()
attrs["xmlns:dc"] = "http://purl.org/dc/elements/1.1/"
return attrs
def add_root_elements(self, handler):
super().add_root_elements(handler)
handler.addQuickElement("dc:publisher", "Bookshelf")
class DublinCoreBooksFeed(LatestBooksFeed):
feed_type = DublinCoreRssFeed
Source: Django: Custom feed generators
The generated XML declared the namespace on the channel (<channel xmlns:dc="http://purl.org/dc/elements/1.1/">, since
the SAX handler writes root attributes there) and contained <dc:publisher>Bookshelf</dc:publisher>; the output still
parsed as well-formed XML in the test. Podcast feeds follow the same recipe with the iTunes namespace, which is the
documentation’s own example.
Serving sitemaps and feeds cheaply
Crawlers and readers poll. Both the sitemap and the feed views are ordinary Django views, so the whole toolbox applies; three levers matter most.
-
Conditional GET.
ConditionalGetMiddlewareanswersIf-Modified-Sincerequests with 304 when the response’sLast-Modifiedmatches. Sitemaps sendLast-Modifiedwhen every item has alastmod. A feed does when the class definesitem_pubdateoritem_updateddate. The scratch run fetched/feeds/books.rss, replayed itsLast-Modifiedvalue and received 304 (both from the test client and withcurl), and the author feed, which defines neither hook, sent no such header. The same applies to the sitemap. -
Page cache.
cache_page(86400)around the sitemap views (as in the index example) means the XML is generated at most once a day per site. Because the cache key includes the host, per-site sitemaps do not collide. Wrap feeds the same way:path("feeds/books.rss", cache_page(600)(feeds.LatestBooksFeed()), name="books-rss"). Keep the duration at or below the feed’sttl. See Caching. -
Cheap queries. A feed that shows the authors of every item runs one query per item unless
items()prefetches them (prefetch_related("authors")); the same holds for a sitemaplastmod()that walks a relation. Measure with the tools on Query Optimization & Fetch Modes. The sitemapget_latest_lastmod()evaluateslastmodfor every item to find the maximum, so on very large catalogues setlastmodas a cheap attribute or overrideget_latest_lastmod()with an aggregate query.
Source: Django: Sitemap.lastmod
Sync and async
Both Sitemap and Feed are synchronous: the sitemap view and Feed.call are plain functions that use the ORM
directly. Under ASGI Django runs sync views in a thread pool, so they work unchanged; the scratch project served
/sitemap-books.xml and /feeds/books.atom from uvicorn config.asgi:application with status 200. There is no async
variant to write. If a sitemap or feed becomes a bottleneck, the fix is caching or a cheaper query, not async def. For
the async model see The Request/Response Cycle.
Testing
Everything above is plain HTTP plus XML, which makes it easy to test with the Django test client and the standard
library’s xml.etree.ElementTree. The page’s test module checks the data migration, host-based lookup with SITE_ID
removed, the cache, CurrentSiteManager, every sitemap view and attribute and every feed. A typical case:
import xml.etree.ElementTree as ET
from django.test import TestCase
AT = "{http://www.w3.org/2005/Atom}"
class FeedTests(TestCase):
def test_atom(self):
r = self.client.get("/feeds/books.atom")
self.assertEqual(r["Content-Type"], "application/atom+xml; charset=utf-8")
root = ET.fromstring(r.content)
self.assertEqual(root.tag, AT + "feed")
rels = {l.get("rel"): l.get("href") for l in root.findall(AT + "link")}
self.assertEqual(rels["self"], "http://books.example.org/feeds/books.atom")
Source: Django: Testing tools
Run it with python manage.py test: Django found 30 tests and all passed on Django 6.1.1. Two reminders: the test host is
testserver, so it must be a Site domain or SITE_ID must be set; and when a test changes a site or SITE_ID, call
Site.objects.clear_cache() first.
Where older material is outdated
Tutorials and books written for Django 1.x-2.x cover this area well, but several details have moved. When you meet any of the following in an older source, replace it.
| Older material | Current approach | Since |
|---|---|---|
Calling |
Remove it; list the sitemap in |
Removed in 5.0 |
|
Class removed; no replacement is needed. |
Removed in 5.0 |
Sitemaps built outside a request default to |
Default is |
Changed in 5.0 (deprecation announced in 4.0) |
Sitemap index templates that iterate a flat list of URLs |
Use the |
Context changed in 4.1; old form removed in 5.0 |
Hand-written alternate language URLs in sitemap templates |
|
3.2 (and |
Feed |
Default is the active language; set |
3.0 |
A comments link per item added by a custom generator |
The |
3.2 |
Post-processing feed XML to attach an XSL or CSS stylesheet |
The |
5.2 |
|
|
|
Python 2 compatibility imports in feed and sitemap code |
Delete them; Python 3.12-3.14 only. |
Removed in 3.0 |
The sites framework itself has no entry in the 3.0 to 6.1 release notes: Site, SITE_ID, get_current_site() and
CurrentSiteManager work as they did in Django 2. The books' advice for it still applies; what changed is the
surroundings (settings split by environment, environment variables, MIDDLEWARE instead of MIDDLEWARE_CLASSES,
removed in 2.0).
What changed recently
| Version | Change |
|---|---|
3.0 |
|
3.2 |
Sitemap |
4.0 |
Deprecation of the |
4.1 |
The sitemap index template includes |
4.2 |
|
5.0 |
|
5.2 |
Feed |
6.0 |
No change in these packages in the release notes. |
6.1 |
No change in these packages in the release notes; the new form-media |
Source: Django 6.1 release notes
Looking ahead, Django 6.2 LTS is due in April 2027. From Django 2028 releases follow calendar versioning (DEP 20,
adopted in 6.1), and deprecation warnings are renamed accordingly, for example RemovedInDjango2028Warning; nothing in
the sites, sitemaps or syndication packages is currently deprecated.
Checklist
-
django.contrib.sitesis installed,SITE_IDis set (or hosts are deliberately resolved fromHost), and the defaultexample.comrow has been replaced by a data migration. -
Code uses
get_current_site(request)(orrequest.site), never a hard-coded domain or site id. -
Models that belong to sites have a
sitesorsitefield, and the first manager is the unfiltered one. -
ALLOWED_HOSTSlists every domain in theSitetable. -
django.contrib.sitemapsis installed,items()is ordered,get_absolute_url()returns paths, and the sitemap lives at the site root. -
Large or multi-section sitemaps use the index and section views, with
sitemap_url_namewhen wrapped by a decorator. -
Localised sitemaps use
prefix_default_language=Falsetogether withx_default, and the output has been inspected. -
robots.txthas aSitemap:line built from the current site. -
Feeds are instances in the URLconf, descriptions are valid escaped HTML, items define
item_pubdate(anditem_updateddatefor Atom) and a stable guid. -
ConditionalGetMiddlewareandcache_page()protect the sitemap and feed views;items()prefetches relations. -
A reverse proxy sets the scheme headers configured through
SECURE_PROXY_SSL_HEADER, so absolute URLs sayhttps. -
Tests cover the site lookup, the sitemap XML and the feed XML, and clear the site cache when they change sites.