Wagtail Python StreamField

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.