Setting Up Static and Dynamic Sitemaps in Django

Learn how to create static and dynamic sitemaps in Django using the built-in sitemap framework, including model-backed URLs, static views, location(), and common routing mistakes to avoid.

A sitemap is an XML file that tells search engines which URLs exist on your site and, optionally, how often they change, how important they are, and when they were last modified. Django includes a built-in sitemap framework for generating these XML files from Python classes. That way we don't have to maintain them manually.

Django sitemaps usually fall into two categories:

  1. Static sitemaps for pages that are not backed by a database model, such as your homepage, about page, contact page, or legal pages.
  2. Dynamic sitemaps for pages generated from model instances, such as blog posts, categories, projects, products, or resources.

This article shows how to set up both.

1. Enable Django’s sitemap framework

First, add the sitemap framework to INSTALLED_APPS:

settings.py

python

1
2
3
4
INSTALLED_APPS = [
    # ...
    "django.contrib.sitemaps",
]

2. Create a sitemaps.py file

You can place this file wherever it makes sense. A common approach is to put it in your project config app:

project/
    settings.py
    urls.py
    sitemaps.py

Or inside individual apps:

apps/
    blog/
        models.py
        sitemaps.py

For a small or medium project, a central sitemaps.py is often easier than having a sitemap file for each app. Once the singular file grows too large, split into app specific.

Static Sitemaps

Static pages are views that exist in your URLconf but are not represented by database rows.

Examples:

urls.py

python

1
2
3
4
5
urlpatterns = [
    path("", views.index, name="index"),
    path("about/", views.about, name="about"),
    path("contact/", views.contact, name="contact"),
]

To add these pages to your sitemap:

  • Create a Sitemap class
  • Optional, but recommended:
    • changefreq
      • Tells the search engine how often content changes ('daily', 'weekly', 'monthly', ...)
    • priority
      • Tells the search engine how important you think these pages are (from 0 to 1)
  • Define a method called items()
    • Return all list of the names as specified inside urls.py (i.e. name='index')
  • Define a method called location()
    • It returns the URLs by resolving the names returned by location() with Django's reverse() function

sitemaps.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django.contrib.sitemaps import Sitemap
from django.urls import reverse


class StaticViewSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.8

    def items(self):
        return [
            "index",
            "about",
            "contact",
        ]

    def location(self, item):
        return reverse(item)

If your URLs are namespaced, include the namespace:

python

1
2
3
4
5
6
def items(self):
    return [
        "core:index",
        "core:about",
        "core:contact",
    ]

You can define as many of these classes as you want. However, it makes sense to group them if they share the same priority or changefreq.

Dynamic Sitemaps

Dynamic sitemaps are for model-backed pages and share the same base URL, with parts being dynamic.

For example:

apps/blog/models.py

python

1
2
3
4
5
6
7
8
class PostModel(models.Model):
    title = models.CharField(max_length=150)
    slug = models.SlugField(unique=True)
    active = models.BooleanField(default=False)
    published = models.DateTimeField(auto_now_add=True)

    category = models.ForeignKey("CategoryModel", on_delete=models.CASCADE)
    sub_category = models.ForeignKey("SubCategoryModel", on_delete=models.CASCADE)

Suppose your URL pattern looks like this:

apps/blog/urls.py

python

1
2
3
4
5
6
7
urlpatterns = [
    path(
        "articles/<slug:category>/<slug:subcategory>/<slug:slug>/",
        views.post,
        name="post",
    ),
]

Then your dynamic sitemap has to resolve the base URL ('articles/' in this example) and the dynmic parts ('/', '/', and '/' in this example)

So your dynamic sitemap for articles is going to look similar to this:

sitemaps.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from django.contrib.sitemaps import Sitemap
from django.urls import reverse

from apps.blog.models import PostModel


class PostSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.7

    def items(self):
        return PostModel.objects.filter(active=True)

    def lastmod(self, obj):
        return obj.published

    def location(self, obj):
        return reverse(
            "blog:post",
            kwargs={
                "category": obj.category.slug,
                "subcategory": obj.sub_category.slug,
                "slug": obj.slug,
            },
        )

Again, we define changefreq and priority (still optional but recommended).

The following methods however have to be changed so they resolve URLs dynamically rather than fixed as in the static example.

The items() method changes: Instead of returning a fixed list of url names, we return PostModel objects (here filtered by active=True)

Here, we add a lastmod() method. It's argument (obj) references each model object. The it returns the objects publishd attribute. If your PostModel had an attribute last_updated, you would use that.

location() resolves the URL again. With dynamic URLs however, Django's reverse() function takes keyword arguments (kwargs). These are mapped to your dynamic url args (here: '/', '/', and '/') to what is stored in your model object. That's why for dynamic sitemaps location() also takes obj as an argument.

The items() method returns the objects that belong in the sitemap. Django passes each object to methods like location(), lastmod(), changefreq(), and priority().

Alternative: use get_absolute_url()

Instead of defining location() in the sitemap class, you can define get_absolute_url() on the model:

apps/blog/models.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.urls import reverse


class PostModel(models.Model):
    # fields...

    def get_absolute_url(self):
        return reverse(
            "blog:post",
            kwargs={
                "category": self.category.slug,
                "subcategory": self.sub_category.slug,
                "slug": self.slug,
            },
        )

Then your sitemap changes to:

python

1
2
3
4
5
6
7
8
9
class PostSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.7

    def items(self):
        return PostModel.objects.filter(active=True)

    def lastmod(self, obj):
        return obj.published

This shortens down the sitemap class by defining how the absolute url should be built inside each Model class individualy.

If no location() method is provided, Django calls get_absolute_url() on each object returned by items().

There is no real advantage over either method. It comes down to preference: do you want the sitemap to resolve the URL or do you want the model to resolve the URL

Make sure to only include paths in your sitemap that you want to be accessed by the public and search engines. Don't include links to i.e. closed login pages

A Reusable Base Sitemap for Active Models

If several models share the same pattern, create a base sitemap class:

sitemaps.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.contrib.sitemaps import Sitemap


class _BaseActiveSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.7

    model = None

    def items(self):
        return self.model.objects.filter(active=True)

    def lastmod(self, obj):
        return getattr(obj, "updated", None) or getattr(obj, "published", None)

Then subclass it:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from django.urls import reverse

from apps.blog.models import CategoryModel, PostModel
from apps.field_notes.models import FieldNoteModel
from apps.projects.models import ProjectModel


class PostSitemap(_BaseActiveSitemap):
    model = PostModel

    def location(self, obj):
        return reverse(
            "blog:post",
            kwargs={
                "category": obj.category.slug,
                "subcategory": obj.sub_category.slug,
                "slug": obj.slug,
            },
        )


class CategorySitemap(_BaseActiveSitemap):
    model = CategoryModel

    def location(self, obj):
        return reverse(
            "blog:category",
            kwargs={"slug": obj.slug},
        )


class FieldNoteSitemap(_BaseActiveSitemap):
    model = FieldNoteModel

    def location(self, obj):
        return reverse(
            "field_notes:detail",
            kwargs={"slug": obj.slug},
        )


class ProjectSitemap(_BaseActiveSitemap):
    model = ProjectModel

    def location(self, obj):
        return reverse(
            "projects:detail",
            kwargs={"slug": obj.slug},
        )

This gives you one shared rule for filtering active content and setting lastmod, while still letting each model define its own URL structure.

Register the Sitemaps in urls.py

Once your sitemap classes exist, register them in your root URLconf. This makes them accessible from the browser.

main/urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from django.contrib.sitemaps.views import sitemap
from django.urls import path

from .sitemaps import (
    StaticViewSitemap,
    PostSitemap,
    CategorySitemap,
    FieldNoteSitemap,
    ProjectSitemap,
)


sitemaps = {
    "static": StaticViewSitemap,
    "posts": PostSitemap,
    "categories": CategorySitemap,
    "field_notes": FieldNoteSitemap,
    "projects": ProjectSitemap,
}


urlpatterns = [
    # your normal URLs...

    path(
        "sitemap.xml",
        sitemap,
        {"sitemaps": sitemaps},
        name="django.contrib.sitemaps.views.sitemap",
    ),
]

You import all sitemap classes (either from one central sitemaps.py file or from all app specific files) and map them to a name (you can choose one freely) inside the sitemaps dictionary to a short section name, such as "posts" or "static".

The the path() function takes these arguments:

  • "sitemap.xml"
    • The URL for your sitemap
  • sitemap
    • The imported sitemap function (line 1)
  • {"sitemaps": sitemaps}
    • The mapped sitemap classes
  • name="django.contrib.sitemaps.views.sitemap"
    • Reference to the sitemap package, so django knows how to handle / render the sitemap classes

After adding your sitemaps, visit:

/sitemap.xml

Django will generate an XML sitemap dynamically.

You will see something similar to this:

xml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
This XML file does not appear to have any style information associated with it. The document tree is shown below.

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
    <url>
        <loc>https://www.bytestaq.com/</loc>
        <changefreq>monthly</changefreq>
        <priority>1.0</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/categories/</loc>
        <changefreq>monthly</changefreq>
        <priority>0.6</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/field-notes/</loc>
        <changefreq>weekly</changefreq>
        <priority>0.8</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/newsletter/</loc>
        <changefreq>monthly</changefreq>
        <priority>0.7</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/projects/</loc>
        <changefreq>monthly</changefreq>
        <priority>0.8</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/resources/</loc>
        <changefreq>monthly</changefreq>
        <priority>0.8</priority>
    </url>
    <url>
        <loc>https://www.bytestaq.com/resources/cheet-sheets/</loc>
        <changefreq>weekly</changefreq>
        <priority>0.7</priority>
    </url>
</urlset>

That's your sitemaps all set up and ready to be submitted to Google Search Console!

Common Mistakes

get_absolute_url() in the Wrong Class

This is wrong:

python

1
2
3
4
5
class CategorySitemap(BaseActiveSitemap):
    model = CategoryModel

    def get_absolute_url(self):
        return reverse("blog:category", kwargs={"slug": self.slug})

Why? Because Django does not call get_absolute_url() on the sitemap class. It calls it on the model instance.

So either put it on the model:

python

1
2
3
4
5
class CategoryModel(models.Model):
    slug = models.SlugField(unique=True)

    def get_absolute_url(self):
        return reverse("blog:category", kwargs={"slug": self.slug})

Or define location() on the sitemap:

python

1
2
3
4
5
class CategorySitemap(BaseActiveSitemap):
    model = CategoryModel

    def location(self, obj):
        return reverse("blog:category", kwargs={"slug": obj.slug})

URL Keyword Mismatches

If your URL pattern is:

python

1
2
3
4
5
path(
    "articles/<slug:category>/<slug:subcategory>/<slug:slug>/",
    views.post,
    name="post",
)

Then your reverse() call must use the same names as specified in your URL:

python

1
2
3
4
5
kwargs={
    "category": obj.category.slug,
    "subcategory": obj.sub_category.slug,
    "slug": obj.slug,
}

Not:

python

1
2
3
4
5
kwargs={
    "category": obj.category.slug,
    "sub_category": obj.sub_category.slug,
    "slug": obj.slug,
}

The Python model field may be called sub_category, but the URL parameter is called subcategory. Django’s reverse() matches the URL pattern keyword, not the model field name.

Optional: Sitemap Index for Larger Sites

For larger sites, you can generate a sitemap index. This creates a main sitemap.xml that points to separate sitemap files like:

/sitemap-posts.xml
/sitemap-categories.xml
/sitemap-static.xml

Example:

urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from django.contrib.sitemaps import views as sitemap_views
from django.urls import path

from .sitemaps import sitemaps


urlpatterns = [
    path(
        "sitemap.xml",
        sitemap_views.index,
        {"sitemaps": sitemaps},
        name="django.contrib.sitemaps.views.index",
    ),
    path(
        "sitemap-<section>.xml",
        sitemap_views.sitemap,
        {"sitemaps": sitemaps},
        name="django.contrib.sitemaps.views.sitemap",
    ),
]

Django’s sitemap framework supports sitemap indexes that reference one sitemap file per section. This is especially useful when you have many URLs; the sitemap protocol limit is 50,000 URLs per sitemap, and Django’s default sitemap limit is aligned with that limit.

Complete Example:

sitemaps.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from django.contrib.sitemaps import Sitemap
from django.urls import reverse

from apps.blog.models import PostModel, CategoryModel
from apps.field_notes.models import FieldNoteModel
from apps.projects.models import ProjectModel


# --- Static
class StaticViewSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.8

    def items(self):
        return [
            "index",
            "about",
            "contact",
        ]

    def location(self, item):
        return reverse(item)



# --- Dynamic
class _BaseActiveSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.7

    model = None

    def items(self):
        return self.model.objects.filter(active=True)

    def lastmod(self, obj):
        return getattr(obj, "updated", None) or getattr(obj, "published", None)


class PostSitemap(_BaseActiveSitemap):
    model = PostModel

    def location(self, obj):
        return reverse(
            "blog:post",
            kwargs={
                "category": obj.category.slug,
                "subcategory": obj.sub_category.slug,
                "slug": obj.slug,
            },
        )


sitemaps = {
    "static": StaticViewSitemap,
    "posts": PostSitemap,
    "categories": CategorySitemap,
}

urls.py

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from django.contrib.sitemaps.views import sitemap
from django.urls import path, include

from .sitemaps import sitemaps


urlpatterns = [
    path("", include("apps.blog.urls")),
    path("", include("apps.field_notes.urls")),
    path("", include("apps.projects.urls")),

    path(
        "sitemap.xml",
        sitemap,
        {"sitemaps": sitemaps},
        name="django.contrib.sitemaps.views.sitemap",
    ),
]

In this final example we define the sitemaps dict inside the sitemap.py file. That way we don't have to import each sitemap class into main/urls.py but only the sitemaps dict.

Final Checklist:

Before calling the sitemap finished, make sure you tick off this list:

[] "django.contrib.sitemaps" is in INSTALLED_APPS [] /sitemap.xml is registered in the root urls.py [] Static pages use reverse() inside location() [] Dynamic model objects either define get_absolute_url() or the sitemap defines location() [] URL kwargs match the URL pattern names exactly [] QuerySets filter out inactive, draft, private, or unpublished objects [] Only public pages are listed in the sitemap [] lastmod returns a datetime/date where possible [] Large sites use a sitemap index

That gives search engines one clean sitemap endpoint while keeping your sitemap logic explicit, testable, and close to your URL design.

Join the Newsletter

Practical insights on Django, backend systems, deployment, architecture, and real-world development — delivered without noise.

Get updates when new guides, learning paths, cheat sheets, and field notes are published.

No spam. Unsubscribe anytime.



There is no third-party involved so don't worry - we won't share your details with anyone.