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 django.contrib.sites, set SITE_ID, run migrate; call get_current_site(request).

Enabling it

Serve several sites from one database

Give content a ManyToManyField(Site) (or a ForeignKey), filter with CurrentSiteManager, one settings file per site.

Scoping content to sites

Build absolute URLs and e-mail links

get_current_site(request).domain plus get_absolute_url(); never hard-code the domain.

Absolute URLs and e-mail

Publish an XML sitemap

A Sitemap subclass with items(), one path() to sitemaps.views.sitemap; list it in robots.txt.

A first sitemap

More than 50,000 URLs, or several sections

Use the index view plus the section view; Django paginates each section for you.

A sitemap index

Localised pages

i18n = True, alternates = True, x_default = True, and prefix_default_language=False in the URLconf.

Localised sitemaps and hreflang

Publish an RSS or Atom feed

A Feed subclass with title, link, description and items(); put an instance in the URLconf.

A first feed

One feed per object (per author, per category)

Implement get_object() and let items(obj) use it.

A feed per object: get_object()

Podcast or attachment feed

item_enclosures() returning Enclosure objects.

Enclosures (podcasts and attachments)

Make the sitemap and feed cheap to serve

ConditionalGetMiddleware for 304 answers, cache_page() for the XML.

Serving sitemaps and feeds cheaply

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.

catalog/models.py
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})

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

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

config/settings.py
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.

catalog/migrations/0002_set_site_domain.py
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.

flowchart TD A["get_current_site(request)"] --> B{"django.contrib.sites installed?"} B -- no --> C["RequestSite built from request.get_host()"] B -- yes --> D{"SITE_ID set and not empty?"} D -- yes --> E["Site with pk SITE_ID, cached"] D -- no --> F["Site whose domain matches request.get_host(), cached"] F -- no match --> G["retry without the port; Site.DoesNotExist if still none"]

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.

Four ways to reach the current site
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_ID set. 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_ID unset. One process serves every site and picks the row from the Host header. It is the more flexible option, but every host that reaches the application must exist in the table, and ALLOWED_HOSTS must list them all. A host that is allowed but has no row raises Site.DoesNotExist; in the test run, a request for localhost with SITE_ID removed 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.

Cache behaviour, as asserted in the test run
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.

RequestSite from a bare request
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.

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.

catalog/views.py
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})

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

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.

A full URL, and an e-mail that names the current site
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.

config/settings_shop.py
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 FlatpageFallbackMiddleware checks the current site.

  • Syndication. Item title and description templates get a {{ site }} variable, and relative item URLs get the current site’s domain.

  • Authentication. LoginView passes the current site’s name to the template as {{ site_name }}.

  • Content types. The shortcut view in django.contrib.contenttypes.views uses 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_ID of a row that does not exist. Site.DoesNotExist on the first request. Create the row first (data migration) and keep the id stable.

  • The default example.com row survives. Sitemaps, feeds and e-mails then point at example.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 and get_current_site() in code.

  • Putting a CurrentSiteManager first. 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.

catalog/sitemaps.py (first version)
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
config/urls.py
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",
    ),
]

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

items()

Required. Sequence or queryset of objects; passed to the hooks below.

(none; the base class returns an empty list)

location

Optional attribute or method returning an absolute path (no scheme, no domain) for an item.

item.get_absolute_url()

lastmod

Attribute or one-argument method returning a datetime for the item (or one value for all items).

none

changefreq

Attribute or method: 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly' or 'never'.

none

priority

Attribute or method returning a string or float such as 0.4 or 1.0.

0.5 by the protocol when absent

protocol

'http' or 'https' for the generated URLs.

the request’s scheme; 'https' when built outside a request

limit

Maximum URLs per page of the sitemap; must not exceed the protocol’s 50,000.

50000

paginator

Property returning a Paginator over items(); override as a cached property when sitemaps are generated in batches.

Paginator(items, limit)

i18n

Generate URLs for every language in LANGUAGES.

False

languages

Language codes to use when i18n is on.

LANGUAGES

alternates

With i18n, add hreflang alternate links to the other language versions.

False

x_default

With alternates, add an x-default fallback entry built from LANGUAGE_CODE.

False

get_latest_lastmod()

Latest value of lastmod; feeds the index’s lastmod and the Last-Modified header.

computed from lastmod

get_languages_for_item(item)

The language codes in which an item exists.

languages

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.

GenericSitemap for authors
from django.contrib.sitemaps import GenericSitemap

from .models import Author

author_sitemap = GenericSitemap(
    {"queryset": Author.objects.order_by("pk")},
    priority=0.4,
    changefreq="monthly",
)

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.

catalog/sitemaps.py (static pages)
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)

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.

catalog/sitemaps.py (the combined dictionary)
sitemaps = {
    "books": BookSitemap,
    "static": StaticViewSitemap,
    "authors": author_sitemap,
}
config/urls.py (index plus sections, cached)
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",
    ),
]

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>

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.

catalog/sitemaps.py (the multilingual books sitemap)
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"]
config/urls.py (language prefix only for non-default languages)
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.

catalog/templates/catalog/sitemap_news.xml
<?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>
config/urls.py (a section served with its own template)
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 ...
]
catalog/sitemaps.py (NewsSitemap)
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.

catalog/templates/robots.txt
User-agent: *
Disallow: /admin/

Sitemap: https://{{ request.site.domain }}/sitemap.xml
config/urls.py
from django.views.generic import TemplateView

urlpatterns += [
    path(
        "robots.txt",
        TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
    ),
]

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

catalog/feeds.py
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()]
config/urls.py
from django.urls import path

from catalog import feeds

urlpatterns += [
    path("feeds/books.rss", feeds.LatestBooksFeed(), name="books-rss"),
]

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.

catalog/templates/feeds/author_book_description.html
{{ obj.summary }} (from {{ site_name }}, {{ site.domain }})

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.

catalog/feeds.py (AuthorBooksFeed)
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
config/urls.py
path("feeds/authors/<int:author_id>/", feeds.AuthorBooksFeed(), name="author-feed"),

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.

catalog/feeds.py (Atom variant)
from django.utils.feedgenerator import Atom1Feed


class AtomLatestBooksFeed(LatestBooksFeed):
    feed_type = Atom1Feed
    subtitle = LatestBooksFeed.description
config/urls.py
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:

catalog/templates/catalog/book_list.html
<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.

catalog/feeds.py (enclosures)
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")]

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

title, link, description

Required. link is the HTML counterpart of the feed; a relative path gets the current site’s domain.

subtitle

Atom’s counterpart of description.

feed_url, feed_guid

The feed’s own URL (otherwise built from the request) and, for Atom, its feed-level id (default: the feed link).

author_name, author_email, author_link

Feed author; the link must include scheme and domain.

categories, feed_copyright, ttl

Iterable of strings; copyright notice; time-to-live (ignored for Atom).

language

Language code; default django.utils.translation.get_language().

stylesheets

A list of URL strings or Stylesheet objects (5.2).

feed_type

A SyndicationFeed subclass (not an instance).

title_template, description_template

Template names for item titles and descriptions.

get_object(), get_context_data()

Per-URL object; extra template context.

Item-level hook Meaning

item_title, item_description, item_link

Item text and URL; the link falls back to get_absolute_url().

item_guid, item_guid_is_permalink

Stable identifier (default: the item link) and the isPermaLink flag.

item_author_name, item_author_email, item_author_link

Item author; e-mail and link require the name.

item_pubdate, item_updateddate

datetime objects: publication and last-update time.

item_categories, item_copyright, item_comments

Categories; Atom-only copyright; comments page URL (3.2).

item_enclosures, item_enclosure_url, item_enclosure_length, item_enclosure_mime_type

Enclosures, as above.

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.

catalog/feeds.py (stylesheets)
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"),
    ]

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

A feed without a request
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"))

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.

catalog/feeds.py (a Dublin Core publisher element)
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

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. ConditionalGetMiddleware answers If-Modified-Since requests with 304 when the response’s Last-Modified matches. Sitemaps send Last-Modified when every item has a lastmod. A feed does when the class defines item_pubdate or item_updateddate. The scratch run fetched /feeds/books.rss, replayed its Last-Modified value and received 304 (both from the test client and with curl), 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’s ttl. 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 sitemap lastmod() that walks a relation. Measure with the tools on Query Optimization & Fetch Modes. The sitemap get_latest_lastmod() evaluates lastmod for every item to find the maximum, so on very large catalogues set lastmod as a cheap attribute or override get_latest_lastmod() with an aggregate query.

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:

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

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 ping_google() or manage.py ping_google after publishing

Remove it; list the sitemap in robots.txt or submit it in the search console.

Removed in 5.0

django.contrib.sitemaps.SitemapNotFound handling

Class removed; no replacement is needed.

Removed in 5.0

Sitemaps built outside a request default to http

Default is https; set protocol explicitly if you need http.

Changed in 5.0 (deprecation announced in 4.0)

Sitemap index templates that iterate a flat list of URLs

Use the sitemaps list of objects with location and lastmod.

Context changed in 4.1; old form removed in 5.0

Hand-written alternate language URLs in sitemap templates

i18n, alternates, x_default, languages.

3.2 (and get_languages_for_item() in 4.2)

Feed language taken from LANGUAGE_CODE

Default is the active language; set language to pin it.

3.0

A comments link per item added by a custom generator

The item_comments hook.

3.2

Post-processing feed XML to attach an XSL or CSS stylesheet

The stylesheets attribute and Stylesheet.

5.2

url() and django.conf.urls in sitemap and feed URLconfs, render_to_response

path() and render(); feed instances in path().

url() removed in 4.0; render_to_response() in 3.0

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

Feed.language attribute; the default feed language is get_language(), not LANGUAGE_CODE.

3.2

Sitemap alternates, languages and x_default; feed item_comments hook.

4.0

Deprecation of the http default for sitemaps built outside a request.

4.1

The sitemap index template includes <lastmod> through get_latest_lastmod(); custom index templates must use the new context.

4.2

Sitemap.get_languages_for_item().

5.0

ping_google() and the management command removed; SitemapNotFound removed; sitemaps default to https outside a request.

5.2

Feed stylesheets and feedgenerator.Stylesheet.

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 Stylesheet class is unrelated.

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.sites is installed, SITE_ID is set (or hosts are deliberately resolved from Host), and the default example.com row has been replaced by a data migration.

  • Code uses get_current_site(request) (or request.site), never a hard-coded domain or site id.

  • Models that belong to sites have a sites or site field, and the first manager is the unfiltered one.

  • ALLOWED_HOSTS lists every domain in the Site table.

  • django.contrib.sitemaps is 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_name when wrapped by a decorator.

  • Localised sitemaps use prefix_default_language=False together with x_default, and the output has been inspected.

  • robots.txt has a Sitemap: line built from the current site.

  • Feeds are instances in the URLconf, descriptions are valid escaped HTML, items define item_pubdate (and item_updateddate for Atom) and a stable guid.

  • ConditionalGetMiddleware and cache_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 say https.

  • Tests cover the site lookup, the sitemap XML and the feed XML, and clear the site cache when they change sites.