Wagtail StreamField: A Deep Dive with Custom Blocks
StreamField is Wagtail's flagship feature — a JSON-backed ordered list of typed content blocks that gives editors the flexibility of a page builder while keeping content structured enough to query, migrate, and expose through an API. This post covers every block type, custom block development, nested composites, choosers, templates, querying, and the migration story.
1. How StreamField Works
A StreamField is stored as a JSON array in a single database column. Each element has a
type key (the block name) and a value key (the block data):
[
{ "type": "heading", "value": { "text": "Why we built this", "size": "h2" }, "id": "abc1" },
{ "type": "paragraph", "value": "<p>It started with a simple problem...</p>", "id": "abc2" },
{ "type": "image", "value": 42, "id": "abc3" },
{ "type": "callout", "value": { "body": "Key insight here", "style": "info" }, "id": "abc4" }
]
The id per block enables stable cross-references, link targets, and preview anchors.
Wagtail 5+ uses use_json_field=True by default (the legacy StreamField
used a custom database type; use_json_field=True uses a plain JSONField).
2. Built-in Block Types
from wagtail.blocks import (
CharBlock, # Single-line text
TextBlock, # Multi-line plain text
RichTextBlock, # HTML rich text editor
BooleanBlock, # Checkbox
IntegerBlock, # Integer number
FloatBlock, # Float number
DecimalBlock, # Decimal number
RegexBlock, # Text validated against a regex
URLBlock, # URL field
EmailBlock, # Email field
DateBlock, # Date picker
TimeBlock, # Time picker
DateTimeBlock, # Combined datetime
ChoiceBlock, # Single choice dropdown
MultipleChoiceBlock, # Multi-select
PageChooserBlock, # Wagtail page foreign key
DocumentChooserBlock, # Wagtail document FK
ImageChooserBlock, # Wagtail image FK
SnippetChooserBlock, # Wagtail snippet FK
EmbedBlock, # oEmbed (YouTube, Vimeo, Twitter)
StaticBlock, # No value — renders a fixed template
StructBlock, # Dict of sub-blocks
ListBlock, # Ordered list of one block type
StreamBlock, # Ordered list of mixed block types
RawHTMLBlock, # Unescaped HTML (use with caution)
)
3. StructBlock — Composite Fields
StructBlock groups multiple sub-blocks into a named composite. Use it for
semantically related fields that always appear together:
from wagtail.blocks import StructBlock, CharBlock, RichTextBlock, URLBlock, ChoiceBlock
class CalloutBlock(StructBlock):
style = ChoiceBlock(choices=[
('info', 'Info'),
('warning', 'Warning'),
('success', 'Success'),
('danger', 'Danger'),
], default='info')
title = CharBlock(required=False)
body = RichTextBlock(features=['bold', 'italic', 'link'])
class Meta:
icon = 'warning'
label = 'Callout Box'
template = 'blocks/callout_block.html'
class CTABlock(StructBlock):
heading = CharBlock()
body = RichTextBlock(features=['bold', 'italic'])
btn_text = CharBlock(label='Button text')
btn_url = URLBlock(label='Button URL')
class Meta:
icon = 'link'
template = 'blocks/cta_block.html'
In the template, access sub-blocks via value.style, value.title, etc.
The Meta.template path is relative to any configured template directory.
4. ListBlock — Repeating Items
ListBlock stores an ordered list of a single block type. Good for feature lists,
step-by-step instructions, or team member grids:
from wagtail.blocks import ListBlock, StructBlock, CharBlock
from wagtail.images.blocks import ImageChooserBlock
class TeamMemberBlock(StructBlock):
name = CharBlock()
role = CharBlock()
photo = ImageChooserBlock(required=False)
bio = CharBlock(required=False, max_length=200)
class Meta:
icon = 'user'
class TeamGridBlock(StructBlock):
heading = CharBlock(required=False)
members = ListBlock(TeamMemberBlock())
class Meta:
icon = 'group'
template = 'blocks/team_grid.html'
In the template, iterate with {% for member in value.members %}.
5. StreamBlock — Nested Streams
You can nest a StreamBlock inside a StructBlock to create
two-column layouts or tab panels where each column/tab has its own stream:
from wagtail.blocks import StreamBlock, StructBlock, RichTextBlock, CharBlock
from wagtail.images.blocks import ImageChooserBlock
class ColumnContentBlock(StreamBlock):
paragraph = RichTextBlock()
image = ImageChooserBlock()
class Meta:
required = False
class TwoColumnBlock(StructBlock):
left = ColumnContentBlock()
right = ColumnContentBlock()
class Meta:
icon = 'placeholder'
label = 'Two Columns'
template = 'blocks/two_column.html'
{# blocks/two_column.html #}
<div class="two-col">
<div class="col-left">{% include_block value.left %}</div>
<div class="col-right">{% include_block value.right %}</div>
</div>
6. ChooserBlocks — Relational Content
Chooser blocks create foreign-key relationships to Wagtail objects. They render a picker widget in the admin:
from wagtail.blocks import StructBlock, CharBlock, PageChooserBlock
from wagtail.snippets.blocks import SnippetChooserBlock
from wagtail.images.blocks import ImageChooserBlock
class RelatedPostBlock(StructBlock):
post = PageChooserBlock(page_type='blog.BlogPage')
label = CharBlock(required=False, help_text='Override the post title for the link')
class Meta:
template = 'blocks/related_post.html'
class TestimonialBlock(StructBlock):
# Testimonial is a Snippet model
testimonial = SnippetChooserBlock('testimonials.Testimonial')
class Meta:
template = 'blocks/testimonial.html'
In the template, value.post gives you the full BlogPage instance —
you can call any model property or method on it. {% pageurl value.post %} gives the
correct URL accounting for the site root.
7. Custom Block Class
For complete control over rendering logic, subclass Block directly and override
render():
from wagtail.blocks import Block
from django.utils.html import format_html
import qrcode
import io
import base64
class QRCodeBlock(Block):
"""Renders a QR code image inline from a URL value."""
def render(self, value, context=None):
img = qrcode.make(value)
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode()
return format_html(
'<figure class="qr-code"><img src="data:image/png;base64,{}" alt="QR code for {}"></figure>',
b64, value
)
class Meta:
icon = 'link'
label = 'QR Code'
8. Block Templates
Each block's template receives a value context variable (the block's data) and the
parent page's context. Always use {% load wagtailimages_tags %} for image blocks:
{# blocks/callout_block.html #}
<div class="callout callout--{{ value.style }}">
{% if value.title %}
<strong class="callout__title">{{ value.title }}</strong>
{% endif %}
<div class="callout__body">{{ value.body }}</div>
</div>
For blocks that should render nothing (a divider, a spacer), use StaticBlock
with its own template — no value needed:
from wagtail.blocks import StaticBlock
class DividerBlock(StaticBlock):
class Meta:
icon = 'horizontalrule'
label = 'Divider'
template = 'blocks/divider.html'
# admin_text shows in the block picker description
admin_text = 'Inserts a horizontal rule'
9. Querying StreamField Data
You can filter pages based on block content using PostgreSQL JSON operators via
__contains:
# Find all BlogPages whose body contains a callout block
from blog.models import BlogPage
pages_with_callout = BlogPage.objects.filter(
body__contains=[{'type': 'callout'}]
)
# Find pages with a specific callout style
pages_with_warning = BlogPage.objects.filter(
body__contains=[{'type': 'callout', 'value': {'style': 'warning'}}]
)
For extracting text from StreamField (for search indexing or summaries), use
StreamField.get_searchable_content():
page = BlogPage.objects.first()
# Returns a list of text strings from all text-bearing blocks
text_chunks = page.body.stream_block.get_searchable_content(page.body)
summary = ' '.join(text_chunks)[:500]
10. StreamField Migrations
Adding a new block type to an existing StreamField does not require a data migration — JSON data without the new block type is still valid. The migration only modifies the Python schema, not the stored data:
# After adding a new block type to BlogStreamBlock
python manage.py makemigrations blog
python manage.py migrate
Renaming a block type does require a data migration to update existing JSON records:
# migrations/0012_rename_callout_to_alert.py
from django.db import migrations
from wagtail.blocks.migrations.migrate_operation import MigrateStreamData
# wagtail.blocks.migrations provides helpers for renaming types
from wagtail.blocks.migrations.operations import RenameStreamChildrenOperation
class Migration(migrations.Migration):
dependencies = [('blog', '0011_previous')]
operations = [
MigrateStreamData(
app_name='blog',
model_name='BlogPage',
field_name='body',
operations_and_block_paths=[
(RenameStreamChildrenOperation(old_name='callout', new_name='alert'), ''),
],
),
]
The wagtail.blocks.migrations module (added in Wagtail 4.1) provides a set of
data migration operations: rename, remove, alter block value, and move blocks. Always run
these through standard Django migrations to keep them reversible and version-controlled.