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.