A WordPress to Wagtail migration is not a plugin you install or a button you press. It is a small data-engineering project: you are taking content that was stored as loosely-structured HTML and re-homing it inside a typed, version-controlled schema. Done badly, it loses your rankings and frustrates your editors. Done well, it is invisible to your audience and a relief to your team — same URLs, faster pages, cleaner data, and a CMS that costs far less to keep secure.

This guide walks through the process exactly as we run it, including the code. It is written so a technical lead can scope the work and a marketing director can sanity-check the risk. If you want the strategic case for the switch rather than the mechanics, read the companion piece first: Wagtail vs WordPress: why UK enterprises are making the switch.

01Why migrate at all

Before any migration, be honest about the reason — because it shapes the scope. In our experience the trigger is almost always one of four things:

  • Security & maintenance fatigue. Patchstack consistently attributes the large majority of new WordPress-ecosystem CVEs to third-party plugins. Once an estate depends on twenty-plus plugins, patching becomes a standing line in your engineering capacity.
  • Layout drift. Gutenberg, ACF, Elementor and friends let editors put anything anywhere. Eighteen months in, no two landing pages match and the brand team is policing layouts the CMS cannot enforce.
  • Content trapped as HTML. You want to feed a mobile app, a partner portal or an AI search index — but the content lives as an HTML blob in post_content and has to be re-parsed every time.
  • You are becoming a Python shop. Data, ML and AI work is pulling the team toward Python, and maintaining a PHP island no longer makes sense.

Wagtail answers all four: a small, curated dependency footprint; a schema that editors cannot break; content stored as structured JSON available over REST and GraphQL; and a Django foundation that sits one import away from your data and AI tooling.

Scope it by the “why”

If your only pain is hosting cost, a migration is overkill — fix the hosting. Migrate when the pain is structural: security surface, content portability, or editorial governance. Those are problems WordPress cannot fix without becoming something it isn't.

02Step 1 · The content audit

The single biggest predictor of a smooth migration is the audit you do before writing any code. The number of pages barely matters; the number of distinct content shapes is what drives cost. Ten thousand near-identical blog posts are easy. Forty bespoke landing pages built with three different page builders are hard.

Produce a spreadsheet that inventories, for every URL on the live site:

  • Content type — post, page, custom post type (CPT), category/tag archive, author archive.
  • Template / builder — classic editor, Gutenberg, ACF flexible content, Elementor, a bespoke theme template.
  • Traffic & value — pull 12 months of sessions from GA4 and impressions/clicks from Search Console. This tells you which URLs must survive and which can be retired.
  • Disposition — migrate, redirect-and-drop, or consolidate.

It is normal for 20–40% of a long-lived WordPress estate to be dead weight — old campaign pages, duplicate tag archives, abandoned drafts. Retiring them (with redirects) is the cheapest improvement in the whole project. The audit is where you decide that, deliberately, rather than carrying the cruft across.

03Step 2 · Map WordPress to Wagtail

Wagtail’s model is a tree of Page objects. WordPress’s model is a flat table of posts with taxonomies bolted on. Mapping one to the other is the core design decision of the migration. The translation is usually:

the conceptual mapping wp → wagtail
WORDPRESS                         WAGTAIL
--------------------------------  --------------------------------
Page (hierarchical)            →  Page subclass (e.g. StandardPage)
Post                           →  BlogPage under a BlogIndexPage
Custom Post Type (e.g. Event)  →  EventPage under an EventIndexPage
Category / Tag                 →  Wagtail taxonomy snippet + ClusterTaggableManager
ACF / meta fields              →  explicit model fields OR StreamField blocks
post_content (HTML blob)       →  StreamField (typed blocks)
Media Library item             →  Wagtail Image / Document
Menu                           →  Wagtail menu snippet or Site settings
Yoast / RankMath SEO meta      →  seo_title, search_description, og fields

The judgement call is the last column of the audit: which WordPress meta fields become first-class model fields (because they are structured and queried — an event date, a price, a product SKU) versus which become StreamField blocks (because they are free-form body content). Get this right and editors get a clean, guided form. Get it wrong and you have recreated the HTML-blob problem inside Wagtail.

Define the target page models first, in Python, and review them with the editorial team before importing a single row:

blog/models.py · the target schema python
from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail.admin.panels import FieldPanel
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock


class BlogPage(Page):
    date = models.DateField("Post date")
    intro = models.CharField(max_length=255, blank=True)
    body = StreamField([
        ("heading",   blocks.CharBlock(form_classname="title")),
        ("paragraph", blocks.RichTextBlock(features=["h2", "h3", "bold", "italic", "link", "ol", "ul"])),
        ("image",     ImageChooserBlock()),
        ("quote",     blocks.BlockQuoteBlock()),
        ("embed",     EmbedBlock()),
    ], use_json_field=True)

    content_panels = Page.content_panels + [
        FieldPanel("date"),
        FieldPanel("intro"),
        FieldPanel("body"),
    ]
    parent_page_types = ["blog.BlogIndexPage"]

04Step 3 · Export & parse the content

There are three ways to get content out of WordPress. Pick based on scale and how much custom-field data you have:

  • WXR export (Tools → Export) — an XML file of posts, pages and core fields. Perfect for blogs and simple sites; does not reliably include all ACF data.
  • WP REST API/wp-json/wp/v2/ with the ACF-to-REST plugin gives you clean JSON including custom fields. Our default for anything ACF-heavy.
  • Direct database read — for very large or very bespoke estates, query wp_posts / wp_postmeta directly. Most control, most effort.

Whichever source you use, the import is a Django management command, not a one-off script — so it is idempotent, re-runnable, and version-controlled. You will run it dozens of times against a staging database before go-live. Here is the shape of a WXR importer:

blog/management/commands/import_wp.py python
import xml.etree.ElementTree as ET
from django.core.management.base import BaseCommand
from django.utils.dateparse import parse_datetime
from blog.models import BlogIndexPage, BlogPage
from .wp_html import html_to_streamfield   # see Step 4

NS = {"wp": "http://wordpress.org/export/1.2/",
      "content": "http://purl.org/rss/1.0/modules/content/"}


class Command(BaseCommand):
    help = "Import a WordPress WXR export into Wagtail BlogPages"

    def add_arguments(self, parser):
        parser.add_argument("xml_path")

    def handle(self, *args, **opts):
        index = BlogIndexPage.objects.first()
        root = ET.parse(opts["xml_path"]).getroot()

        for item in root.iter("item"):
            post_type = item.findtext("wp:post_type", namespaces=NS)
            status    = item.findtext("wp:status", namespaces=NS)
            if post_type != "post" or status != "publish":
                continue

            slug = item.findtext("wp:post_name", namespaces=NS)
            # idempotent: skip if already imported
            if BlogPage.objects.filter(slug=slug).exists():
                continue

            html = item.findtext("content:encoded", namespaces=NS) or ""
            page = BlogPage(
                title=item.findtext("title"),
                slug=slug,
                date=parse_datetime(item.findtext("wp:post_date", namespaces=NS)),
                body=html_to_streamfield(html),
            )
            index.add_child(instance=page)
            page.save_revision().publish()
            self.stdout.write(self.style.SUCCESS(f"✓ {slug}"))

05Step 4 · Into StreamField

This is the heart of the migration and where most cheap migrations cut corners. The lazy approach dumps the entire post_content HTML into a single RichTextBlock. It “works”, but you have carried the HTML-blob problem straight into Wagtail and gained nothing structurally.

The right approach parses the HTML with BeautifulSoup and walks the DOM, emitting a typed StreamField block per meaningful element — images become ImageChooserBlock references, blockquotes become quote blocks, embeds become embed blocks, and runs of prose become rich-text blocks. The result is real structured content:

blog/wp_html.py · HTML → typed blocks python
from bs4 import BeautifulSoup
from wagtail.images.models import Image
from .media import import_remote_image   # downloads + dedupes


def html_to_streamfield(html: str) -> list:
    soup = BeautifulSoup(html, "html.parser")
    blocks, buffer = [], []

    def flush():
        if buffer:
            blocks.append(("paragraph", "".join(buffer)))
            buffer.clear()

    for el in soup.find_all(recursive=False):
        if el.name == "img":
            flush()
            image = import_remote_image(el["src"], alt=el.get("alt", ""))
            blocks.append(("image", image.id))
        elif el.name == "blockquote":
            flush()
            blocks.append(("quote", el.get_text(strip=True)))
        elif el.name in {"figure", "iframe"} and el.find("iframe"):
            flush()
            blocks.append(("embed", el.find("iframe")["src"]))
        else:
            buffer.append(str(el))   # keep as rich text

    flush()
    return blocks

Two things make or break this stage. First, internal links must be rewritten — a link to /2019/04/old-post/ in the HTML should resolve to a database-backed Wagtail page reference, not a dead string, so the link survives future slug changes. Second, images must be de-duplicated: WordPress stores multiple sized renditions of the same upload, and you want one Wagtail Image per original, letting Wagtail generate its own renditions. A hash-on-download check handles both.

The 80/20 of migration quality

You will never get a fully automated parse to 100% on a bespoke estate. Aim for the pipeline to handle 90–95% perfectly, then hand the editorial team a short list of the pages it flagged as ambiguous for a manual pass. That blend is far cheaper than either pure-manual re-keying or chasing the last 5% in code.

06Step 5 · Media, URLs & redirects

Media. The import_remote_image helper pulls each referenced asset from the live wp-content/uploads/ directory (or the WXR attachment items), hashes it to avoid duplicates, and creates a Wagtail Image or Document. Once in Wagtail, you get automatic responsive renditions, WebP/AVIF output and focal-point cropping for free — usually a Core Web Vitals win on its own.

URLs. Decide your URL policy early. The safest, most SEO-protective choice is to preserve every existing URL exactly. Wagtail’s tree gives clean paths by default; where the old WordPress permalink structure (e.g. /2019/04/slug/) differs from Wagtail’s, you bridge the gap with redirects rather than re-keying URLs into the tree.

Redirects. Wagtail ships a redirects module. Generate a 301 from every legacy URL to its new home as part of the import — never by hand:

issuing 301s during import python
from wagtail.contrib.redirects.models import Redirect

def create_redirect(old_path: str, page):
    Redirect.objects.get_or_create(
        old_path=Redirect.normalise_path(old_path),
        defaults={"redirect_page": page, "is_permanent": True},
    )

# e.g. create_redirect("/2019/04/old-slug/", page)

Export the old site’s full URL list (Screaming Frog, or the XML sitemap) before you switch DNS, and assert that every one of them returns a 200 or a 301 on the new site. That assertion — ideally a test in CI — is your insurance against the single most common way migrations lose rankings.

07Step 6 · Protecting your SEO

“Will we lose our SEO?” is the question every stakeholder asks, and the honest answer is: only if the migration is done carelessly. SEO loss in a migration is an execution risk, not a platform risk. The checklist that prevents it:

  • 301 every old URL to its exact new equivalent — covered above, and the biggest single factor.
  • Carry the metadata across. Map Yoast/RankMath _yoast_wpseo_title and _yoast_wpseo_metadesc into Wagtail’s seo_title and search_description fields during import. Don’t let editors re-write 3,000 meta descriptions by hand.
  • Preserve structured data. Re-emit Article, Breadcrumb and FAQ JSON-LD from the Wagtail templates. Wagtail makes this cleaner because the data is already typed.
  • Regenerate the XML sitemap (wagtail.contrib.sitemaps) and resubmit it in Search Console on launch day.
  • Keep canonical tags pointing at the canonical URL, and watch for accidental trailing-slash or www inconsistencies.
  • Benchmark Core Web Vitals before and after. Wagtail sites typically launch faster — and Google rewards that — but you want the data to prove it.

Our full Wagtail SEO methodology — metadata models, sitemaps, structured data and Core Web Vitals — is documented in the ultimate guide to SEO in Wagtail. Run a migration to that standard and the typical outcome is flat-to-improving impressions in the first quarter and a measurable uplift within six months.

08What it costs

Cost is driven by content variety, not page count. A 10,000-post blog with one template is cheaper to migrate than a 200-page site built with three page builders and forty ACF layouts. Here is a representative range for UK projects in 2026 — your mileage will vary:

representative WordPress → Wagtail migration cost £ gbp
PROJECT SHAPE                                   | TYPICAL RANGE   | TIMELINE
------------------------------------------------+-----------------+-----------
Brochure site, <50 pages, one template          |  £8k – 20k      |  3–5 wks
Content site, 1k–5k posts, 2–4 content types    |  £25k – 50k     |  8–12 wks
Complex estate, CPTs + ACF + multi-site         |  £50k – 90k+    |  12–20 wks
Add: bespoke design / rebrand                   |  +£10k – 30k    |  +3–6 wks

WHAT DRIVES THE NUMBER:
  • number of distinct content shapes  (biggest factor)
  • how clean the source HTML is        (page-builder soup = costlier)
  • automated parse vs manual re-key %
  • design: like-for-like vs rebrand
  • redirects + SEO assurance rigour

The two ways to control the number are both decided in the audit: retire dead content (don’t pay to migrate pages no one visits) and consolidate content shapes (collapse five near-identical ACF layouts into one good StreamField block set). On most engagements the audit pays for itself several times over by shrinking the build scope.

Crucially, the migration cost is a one-off, and it buys an ongoing reduction in cost of ownership: no premium-plugin subscriptions, far less CVE-patching effort, and a longer interval between expensive redesign cycles. We model the five-year picture in the enterprise comparison; for most mid-sized estates the migration is recovered within 18–30 months.

09FAQ

How long does a WordPress to Wagtail migration take?

A typical mid-sized site (1,000–5,000 pages, a few content types) is an 8–14 week engagement including discovery, modelling, the import pipeline, redirects and QA. Brochure sites can be done in 3–5 weeks; complex multi-site estates run longer.

Can I keep my WordPress theme/design?

Yes — a like-for-like rebuild of your current front-end in Django templates is the cheapest path and keeps the migration invisible to users. Many teams take the opportunity to refresh the design at the same time, which adds cost but avoids a second project later.

What happens to WooCommerce?

This is the one case where we sometimes advise against a full migration. If WooCommerce is the core of your business and works well, a hybrid (Wagtail for content, keep the commerce stack, or move to a dedicated commerce platform) is often wiser than reimplementing checkout. We’ll tell you honestly in discovery.

Will my editors need retraining?

A little, and they’ll thank you for it. Wagtail’s admin is widely rated higher than enterprise WordPress + Gutenberg in editor studies. The StreamField model feels different on day one and obviously better by week two. See Wagtail for editors.

Can we do it in phases?

Yes. A common pattern is to migrate the blog/news section first (high volume, low risk, one template), prove the pipeline and the redirects, then migrate the marketing pages. Wagtail and WordPress can run side by side behind a reverse proxy during the transition.

If you’re weighing a migration right now, the cheapest next step is a fixed-price audit of your estate — it tells you the real content-shape count, the redirect map, and a scoped number before you commit to a build. That’s exactly what our Discovery Sprint is for.