Back to home

Directly filtering Wagtail parent/child pages

Table of Contents

What we're solving

In Wagtail, pages don't have a typical, direct relationship like you'd expect in Django. Instead of using foreign keys to link them, each page has a `path` and a `depth` attribute, which Wagtail uses to determine the page's relative positions.

Because of this, you can't do a typical Django filtering query like:

# Doesn't work on standard Wagtail Page models
BlogPag.objects.filter(parent_page=some_page)

Instead, you have to use a queryset method provided by Wagtail:

BlogPage.objects.child_of(some_page)

While that might not seem like a big deal at first glance, it can introduce frustrating limitations when you require complex querying between page models. For example, let's say you wanted to retrieve all index pages for your blog posts with a certain tag. With a typical Django foreign key relationship, this is as simple as:

# Doesn't work on standard Wagtail Page models
BlogIndexPage.object.filter(child_pages__tags__slug="python")

Before we begin

I wouldn't recommend implementing these relationships for all pages across your whole site unless you know what you're doing. I also wouldn't recommend doing this unless you have need for complex querying between parent/child pages. If you're just looking to use this as a more familiar alternative to Wagtail's build-in queryset methods, then you're going out of your way to work against the framework instead of with it.

Adding a foreign key relationship between Wagtail pages

We're going to add a foreign key relationship between pages and then use hooks to keep the relationships up-to-date. We'll also need to write a custom migration to populate the new field for existing pages. But firstly, creating the relationship:

# blog/models.py
# Where 'BlogPage' is the child page and 'BlogIndexPage' is the parent.
class BlogPage(Page):
    parent_page = models.ForeignKey(
        "Blog.BlogIndexPage",
        on_delete=models.SET_NULL,
        related_name="child_pages",
        editable=False,
        null=True,
        # db_index=True, # Optional, but if you're going to query based on this field a lot, it'll help performance
    )

You can call the attributes whatever you like, but to keep things generalised for this example, I've gone with parent_page and child_pages.

You'll notice that on_delete is SET_NULL. This is required because using CASCADE or PROTECT causes issues with Wagtail's page systems. SET_NULL is fine to use because if the parent page is deleted, Wagtail will cascade this to the child pages anyway.

Once you've done this, you'll need to make migrations.

python run ./manage.py makemigrations

If you already have existing pages in your database, the make migrations command will ask you for a default. Instead of providing a default, I'd recommend either writing the migration yourself or making the field nullable first, creating migrations, then removing the null support. This will allow the migrations to be generated, and then you can move onto the next step, where we populate existing records.

Creating a migration to populate existing records

The following migration is an example of what you'll need to populate the new relationships for existing pages. You'll see that we first create the field, then we run a function to populate existing records:

import django.db.models.deletion
from django.db import migrations, models


def populate_field(apps, schema_editor):
    BlogIndexPage = apps.get_model("blog", "BlogIndexPage")
    BlogPage = apps.get_model("blog", "BlogPage")

    for blog in BlogPage.objects.all():
        index = BlogIndexPage.objects.parent_of(blog).first()

        if index is None:
            raise Exception(
                f"Could not find parent BlogIndexPage for BlogPage id={blog.id}"
            )

        blog.parent_page = index
        blog.save(update_fields=["parent_page"])


class Migration(migrations.Migration):

    dependencies = [
        ("blog", "xxxx_previous_migrations"),
    ]

    operations = [
        migrations.AddField(
            model_name="blogpage",
            name="parent_page",
            field=models.ForeignKey(
                editable=False,
                null=True,
                on_delete=django.db.models.deletion.SET_NULL,
                related_name="child_pages",
                to="blog.blogindexpage",
            ),
        ),
    ]

If it's a brand new table in the database without any records, you can simplify this to just be the AddField step.

Keeping the new relationship up-to-date

From here, we just need to add a new method to the BlogPage to update the parent_page field. Then, using some Wagtail hooks, we can update this field whenever a BlogPage or a BlogIndexPage is modified.

It's important to note that I'm using a task to update the page parent. Doing this ensures that Wagtail's other page tree index jobs run first before we try to update the page parent. The overall process then is that the BlogPage model has a method for updating its parent, we have the task that can be enqueued to run that method for any given BlogPage or BlogIndexPage (to update all children of that index), and we use Wagtail's hooks to enqueue the tasks whenever page modifications are made.

# blog/models.py

class BlogPage(Page):
    # .... Rest of the model
    
    def update_parent_page_field(self, save: bool = True):
        """Updates the parent_page field to match the parent page within Wagtail's page tree."""
        parent = self.get_parent().specific

        if not parent:
            raise ValidationError("Parent page not found.")

        self.parent_page = parent

        if save:
            self.save(update_fields=["parent_page"])
# blog/services.py

from wagtail.models import Page
from .models import BlogIndexPage, BlogPage

@task()
def update_page_relationships(page_id: int) -> None:
    """Task for updating the page parent relationships when a page is modified.
    This is handled as a background task as it allows Wagtail's publish/move operations to complete first & faster.
    """
    if type(page_id) is not int:
        logger.error(f"Invalid page ID type: {type(page_id)}. Expected int.")
        return

    page = Page.objects.filter(id=page_id).first()

    if not page:
        logger.error(f"Page with ID {page_id} not found.")
        return

    page = page.specific

    if isinstance(page, BlogIndexPage):
        index = page

        child_pages = BlogPage.objects.child_of(index).all()
        related_pages = BlogPage.objects.filter(parent_page=index).all()
        all_child_pages = (child_pages | related_pages).distinct()

        for related_page in related_pages.iterator(100):
            try:
                page.update_page_parent_field()
                logger.debug(
                    f"Updated parent page field for BlogPage {related_page.id} related to BlogPageIndex {index.id}"
                )
            except Exception as e:
                logger.error(
                    f"Error updating parent_page field for BlogPage {related_page.id}: {e}"
                )

    elif isinstance(page, BlogPage):
        page.update_page_parent_field()
        logger.debug(
            f"Updated parent page field for BlogPage {page.id} related to BlogIndexPage {page.parent_page.id}"
        )
# blog/wagtail_hooks.py

from wagtail import hooks
from .services import update_page_relationships

@hooks.register("after_delete_page")
@hooks.register("after_publish_page")
@hooks.register("after_move_page")
def update_parent_pages(request, page, *args, **kwargs) -> None:
    """Updates BlogIndexPages and BlogPages when they're modified."""
    page = page.specific

    if isinstance(page, (BlogIndexPage, BlogPage)):
        update_page_relationships.enqueue(page.id)


@hooks.register("after_bulk_action")
def bulk_update_parent_pages(
    request, action_type, pages, *args, **kwargs
) -> None:
    """Updates BlogIndexPage and BlogPages after a bulk action."""
    if action_type != "move":
        return

    for page in pages:
        page = page.specific

        if isinstance(page, (BlogIndexPage, BlogPage)):
            update_page_relationships.enqueue(page.id)

Related blogs

  • django-testing-patterns
    Useful Django unit test patterns

    This is a collection of some common unit test patterns I find myself re-implementing across various projects. To save myself (and maybe some others) time in the future, I've decided to collect them all in one place.

    20 March 2026
  • writing-wagtail-streamfield-content-migrations
    Writing Wagtail StreamField content migrations

    When you drastically change one of your StreamFields and generate a migration for your new structure, the migration won't actually update what's already in your database. It'll describe the new, intended structure behind the fields, but the JSON data itself will remain exactly as it is.

    18 February 2026
  • django-wagtail-test-factories
    Django and Wagtail test factories

    A factory is a system that lets you easily mock data for your tests and creates as many records as you need for your test cases.

    5 November 2025