Back to home

Writing Wagtail StreamField content migrations

Table of Contents

The issue with migrating StreamField data

Wagtail's StreamFields are excellent when they're in use; however, they store their data in JSON, which means that migrating existing data can be complicated if you don't know the best approaches.

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.

When Wagtail tries to parse this old data, it'll use whatever it can (assuming there are matching field names), but once you save your changes, you'll lose anything that wasn't matched to the updated fields.

How to migrate StreamField data easily

Example scenario

Let's say you've got a simple page which has a body StreamField containing a few block choices:

from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.images.blocks import ImageBlock
from wagtail.models import Page


class ExamplePage(Page):
    body = StreamField(
        [
            ("heading", blocks.CharBlock(form_classname="title")),
            ("paragraph", blocks.RichTextBlock()),
            ("image", ImageBlock()),
        ]
    )

    content_panels = Page.content_panels + [
        FieldPanel("body"),
    ]

Using this page model, I've created a page and then added some content to the body using each block once. Going into the database and checking the value of the field directly, I can see that it's stored as follows (I've indented the JSON for readability):

[
	{
		"id": "2f7cf10d-1a18-4b80-b4d9-33e996b9c97f",
		"type": "heading",
		"value": "This is a heading."
	},
	{
		"id": "79964f36-61cb-41e2-bcf6-752049703ef8",
		"type": "paragraph",
		"value": "<p data-block-key=\"de7rr\">This is some paragraph text!</p>"
	},
	{
		"id": "904f81f9-8c63-4984-9c51-9911f441a223",
		"type": "image",
		"value": {
			"image": 166,
			"alt_text": "",
			"decorative": true
		}
	}
]

Now, let's say that I wanted to change the image field to be a ListBlock of several images. In doing so, I've effectively removed the old field and also changed the internal structure of the value being stored. Here's the new page model:

from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.images.blocks import ImageBlock
from wagtail.models import Page


class ExamplePage(Page):
    body = StreamField(
        [
            ("heading", blocks.CharBlock(form_classname="title")),
            ("paragraph", blocks.RichTextBlock()),
            (
                "images",
                blocks.ListBlock(ImageBlock()),
            ),
        ]
    )

    content_panels = Page.content_panels + [
        FieldPanel("body"),
    ]

When I load the editor for this page, I can no longer see the image block I had added. The other blocks show as they did before, but only the image is gone.

Thankfully, the data in the database is still the same, meaning nothing is lost (yet)! Only if the page was now saved would the original data be lost, as it'll be overwritten.

Migrating to our new format

To start us off, we need to manually make an empty migration file in which we can work. This can be done using:

python ./manage.py makemigrations [app_name] --empty

For my environment in which I'm running this example, I get a new migration file with the following content. Yours should be almost identical, with the exception of the dependency and the date/time at the top:

# Generated by Django 6.0.2 on 2026-02-18 14:41

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ("pages", "0041_alter_examplepage_body"),
    ]

    operations = []

In this migration file, we're now going to write a simple operation to parse the StreamField data and make updates where needed.

A common approach for people to take is to manually iterate through all of the JSON and use a series of complicated, nested if statements. In our case, however, we're going to leverage the approach Wagtail use for their own content migrations.

# Generated by Django 6.0.2 on 2026-02-18 14:41

import uuid

from django.db import migrations
from wagtail.blocks.migrations.migrate_operation import MigrateStreamData
from wagtail.blocks.migrations.operations import BaseBlockOperation


class UpdateBodyImageBlockOperation(BaseBlockOperation):

    def apply(self, block_value):
        # The body field should be a list of blocks
        if block_value is None or not isinstance(block_value, list):
            return block_value

        # Loop over the blocks
        for block in block_value:
            # If the current block is not an image block, skip it
            if block.get("type") != "image":
                continue

            # If the value of the block isn't a dict, which is the old format, skip it
            value = block.get("value", {})
            if not isinstance(value, dict):
                continue

            # Store the values to be preserved
            image = value.get("image", None)
            alt_text = value.get("alt_text", "")
            decorative = value.get("decorative", False)

            # Update the block to the new format
            # Using uuid to generate unique IDs for the blocks
            block["id"] = str(uuid.uuid4())
            block["type"] = "images"
            block["value"] = [
                {
                    "id": str(uuid.uuid4()),
                    "type": "item",
                    "value": {
                        "image": image,
                        "alt_text": alt_text,
                        "decorative": decorative,
                    },
                }
            ]

            if image is None:
                continue

        # Return the updated block values
        return block_value

    @property
    def operation_name_fragment(self):
        return "UpdateBodyImageBlockOperation"


class Migration(migrations.Migration):

    dependencies = [
        ("pages", "0040_examplepage"),
    ]

    operations = [
        MigrateStreamData(
            app_name="pages",
            model_name="ExamplePage",
            field_name="body",
            operations_and_block_paths=[
                (
                    UpdateBodyImageBlockOperation(),
                    "",  # Can pass it block paths, like "row.items.columns.items.value"
                ),
            ],
        )
    ]

I've added comments to explain the approach as clearly as possible, but I'd recommend studying this simple example.

The most interesting part, in my opinion, is line 74's string argument. This is where you can pass a block's full StreamField path to work on that block directly without having to loop through lots of container blocks or whatever else you might have.

For example, if I passed "image" to this argument, the apply method would be passed the image block's value as the block_value argument. In this example, because I wanted to also change the block's type and name, I couldn't do this.

In my other projects, I've had examples of using block paths like "basic_content.items.value.image", which saved a ton of time and effort by letting me access block values directly.

Finally, after creating this migration, you'll then need to make a new automatic migration one final time. This is so that Wagtail can do what it needs to to change the original image field into our new images field. It's important to note that we had to convert our data first so that Wagtail was aware that the image data existed.

The JSON field in the database for the original page who's content was migrated is now:

[
	{
		"id": "2f7cf10d-1a18-4b80-b4d9-33e996b9c97f",
		"type": "heading",
		"value": "This is a heading."
	},
	{
		"id": "79964f36-61cb-41e2-bcf6-752049703ef8",
		"type": "paragraph",
		"value": "<p data-block-key=\"de7rr\">This is some paragraph text!</p>"
	},
	{
		"id": "65296a0a-7d6e-42ea-b066-02a2b3b5b244",
		"type": "images",
		"value": [
			{
				"id": "92c6b930-d861-49d5-90a0-c35217760bc7",
				"type": "item",
				"value": {
					"id": "4863f63d-706d-47c4-91b6-832ac7b3f068",
					"image": 166,
					"alt_text": "placeholder-image",
					"decorative": false
				}
			}
		]
	}
]

This matches the newly required structure perfectly. If you're ever unsure how your new data needs to be formatted, you should always make and run the required migrations for your new format and create some data manually in the admin interface. You can then extract the JSON field from your database and compare its structure to the old data. Using that comparison, it should be clear what changes are needed in your code.

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
  • directly-filtering-wagtail-pages
    Directly filtering Wagtail parent/child pages

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

    24 November 2025
  • 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