Wagtail vs Django CMS: Technical Comparison & Editor Experience
Both are mature Django CMSs, both are open-source, and both have real production use. But they make opposite bets on what a CMS should be: Wagtail bets on structured content defined in Python; django-CMS bets on flexible in-context page editing in the browser. This post works through both from the inside — models, editor UX, plugin systems, routing, search, and the honest tradeoffs developers face when picking one.
1. TL;DR Summary
If you want a quick decision table before diving into the detail:
┌─────────────────────────────┬─────────────────────────┬────────────────────────────┐
│ Dimension │ Wagtail │ django-CMS │
├─────────────────────────────┼─────────────────────────┼────────────────────────────┤
│ Content modelling │ Python models (strict) │ Plugins + placeholders │
│ Editor UI │ Separate admin │ Inline frontend editing │
│ Learning curve (dev) │ Moderate — new concepts │ Familiar Django admin feel │
│ Learning curve (editor) │ Low — clean, guided │ Moderate — toolbar overlay │
│ Multi-site │ Via Sites framework │ First-class built-in │
│ i18n / translation │ Via wagtail-localize │ Built-in (djangocms-trans) │
│ Headless / API │ Built-in Wagtail API │ Via REST framework │
│ Search │ First-class (ES/DB) │ Via django-cms-search │
│ Image management │ Excellent (Willow) │ Plugin-based │
│ Workflow / versioning │ Built-in (Wagtail 4+) │ Via versioning plugin │
│ Community / ecosystem │ Large, active │ Mature, smaller │
│ Best for │ Content-heavy sites │ Marketing / agency sites │
└─────────────────────────────┴─────────────────────────┴────────────────────────────┘
2. Architecture Philosophy
Understanding why these two CMSs feel so different starts with understanding what problem each was designed to solve.
Wagtail was built by Torchbox in 2014 for content-rich editorial sites — newspapers, government services (GOV.UK), universities. Its core idea is that a developer defines the content structure in Python models, editors fill in that structure through a clean admin UI, and the template renders whatever the developer designed. Content is typed, structured, and version-controlled. Editors cannot break the layout because the layout lives in code.
django-CMS was built by Divio in 2009 for agency-style web projects where clients want to drag content into pages without developer involvement. Its core idea is placeholders — named slots in templates that editors can fill with plugins from a toolbar overlay directly on the live page. The template defines where content can go; editors decide what goes there.
Neither approach is wrong. They solve different problems for different audiences. The mistake is picking one when your project belongs with the other.
3. Page Modelling: StreamField vs Placeholders
Wagtail: Python Models + StreamField
In Wagtail, every page type is a Django model subclassing wagtail.models.Page. A
blog post is a BlogPage model. An event is an EventPage model. The
developer controls every field.
# Wagtail page model
from django.db import models
from wagtail.models import Page
from wagtail.fields import RichTextField, StreamField
from wagtail.blocks import CharBlock, RichTextBlock
from wagtail.images.blocks import ImageChooserBlock
from wagtail.admin.panels import FieldPanel
class BlogPage(Page):
intro = RichTextField(blank=True)
body = StreamField([
('heading', CharBlock(form_classname='full')),
('richtext', RichTextBlock()),
('image', ImageChooserBlock()),
]) # use_json_field=True was required on Wagtail 3/4; default (and only mode) in Wagtail 5+
date = models.DateField()
author = models.ForeignKey('auth.User', null=True, on_delete=models.SET_NULL)
content_panels = Page.content_panels + [
FieldPanel('intro'),
FieldPanel('body'),
FieldPanel('date'),
FieldPanel('author'),
]
StreamField is where Wagtail's content modelling shines. It stores a list of typed
blocks as JSON. Editors can add any mix of heading, richtext, image, quote, or custom blocks in
any order — but only the block types the developer defined. The schema is enforced; you can
migrate it; you can query it. StreamField data is portable across templates and API consumers.
django-CMS: Placeholders + Plugins
In django-CMS, a page is created through the CMS admin. The template defines named placeholder slots, and editors fill those slots with plugins:
{# django-CMS template #}
{% load cms_tags %}
<div class="hero">
{% placeholder "hero" %}
</div>
<div class="body">
{% placeholder "content" %}
</div>
<aside>
{% placeholder "sidebar" %}
</aside>
A plugin is a Django model + a CMS plugin class that defines its admin form. The built-in text plugin uses django-CKEditor. You can write custom plugins for anything:
# django-CMS custom plugin
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.models.pluginmodel import CMSPlugin
from django.db import models
class HeroPlugin(CMSPlugin):
heading = models.CharField(max_length=200)
subline = models.CharField(max_length=400, blank=True)
cta_text = models.CharField(max_length=60, blank=True)
cta_url = models.URLField(blank=True)
@plugin_pool.register_plugin
class HeroPluginPublisher(CMSPluginBase):
model = HeroPlugin
name = 'Hero Banner'
render_template = 'plugins/hero.html'
cache = True
def render(self, context, instance, placeholder):
context['instance'] = instance
return context
The plugin is stored independently of the page model in a CMSPlugin table, linked
back to the placeholder slot. This means the content structure is more dynamic — an editor can
add a hero banner to any page that has a matching placeholder, regardless of page type.
Key difference
Wagtail's StreamField is schema-first: the developer defines the data model and editors work within it. django-CMS's placeholder system is template-first: the template defines where content can go and editors choose what to put there. Wagtail's model is easier to query, migrate, and expose through an API. django-CMS's model is more flexible for editors who need to compose pages freely.
4. Editor Experience
Wagtail Admin
Wagtail ships a purpose-built admin UI at /cms/ (configurable). It is entirely
separate from Django's admin. The editor experience is clean and opinionated:
- Pages are navigated through a tree explorer — editors see the site hierarchy clearly
- The editing form is generated from the model's
content_panelsdefinition - StreamField blocks are added and reordered with a block picker — editors only see block types the developer made available
- Draft → In Review → Approved → Published workflow is built in (Wagtail 4+)
- Page history and revision diff are built in — you can restore any previous version
- Live preview panel alongside the edit form (configurable)
The Wagtail editor experience is consistently praised for being approachable for non-technical users. Because editors are constrained to the structure the developer defined, they cannot accidentally break layouts or introduce inconsistent content. The tradeoff is less flexibility — an editor cannot decide to add a sidebar to a page type that doesn't have one.
django-CMS Frontend Editing
django-CMS's headline feature is its frontend editing toolbar. When a logged-in editor visits any page on the site, a toolbar overlay appears at the top of the browser. From this toolbar, the editor can:
- Switch between Live and Draft mode with a toggle
- Double-click any placeholder to open an inline edit popover
- Add, reorder, and remove plugins directly on the page — changes render immediately
- Publish, unpublish, and schedule pages from the toolbar
- Navigate to any CMS page without leaving the frontend
This WYSIWYG-style experience is compelling for editors who are used to page builders. Seeing content in its final context as you edit eliminates the "guess what it will look like" problem that backend-only editing creates.
The tradeoff is complexity: the toolbar overlay relies on injecting JavaScript and CSS into every page for authenticated users. On pages with their own complex CSS or heavy JavaScript, the toolbar occasionally conflicts. Plugin modals open in iframes, which can feel clunky on plugin-heavy pages.
Side-by-side verdict
For editorial teams producing articles, press releases, and structured content, Wagtail's admin is significantly better — faster to learn, less error-prone, and the workflow system is production-grade. For marketing teams who need to compose landing pages freely and want instant visual feedback, django-CMS's frontend editing is genuinely useful and hard to match with Wagtail out of the box.
5. Plugin & Extension Systems
Wagtail: StreamField Blocks + Snippets + Hooks
Extending Wagtail happens in three places:
StreamField blocks are the primary content extension point. You define a
StructBlock, ListBlock, StreamBlock, or subclass
Block directly for custom rendering logic. Blocks are composable — a
CardGridBlock can contain a ListBlock of CardBlocks:
from wagtail.blocks import StructBlock, CharBlock, RichTextBlock, ListBlock
class CardBlock(StructBlock):
heading = CharBlock()
body = RichTextBlock(features=['bold', 'italic', 'link'])
class Meta:
template = 'blocks/card.html'
icon = 'doc-empty'
class CardGridBlock(StructBlock):
cards = ListBlock(CardBlock())
class Meta:
template = 'blocks/card_grid.html'
icon = 'grip'
Snippets are non-page models that editors can manage through Wagtail's admin. Use them for reusable content like authors, testimonials, or navigation items.
Hooks let you inject behaviour at defined points in the admin lifecycle — add menu items, register admin views, add edit handlers, intercept page publishing:
from django.http import HttpResponse
from wagtail import hooks
from wagtail.admin.menu import MenuItem
@hooks.register('register_admin_menu_item')
def register_reports_menu_item():
return MenuItem('Analytics', '/analytics/', icon_name='chart-bar', order=500)
@hooks.register('before_publish_page')
def require_seo_title(request, page):
# Returning any HttpResponse from before_publish_page blocks publication.
# Raising an exception does NOT block it — the return value is what matters.
if not page.seo_title:
return HttpResponse('SEO title is required before publishing.', status=412)
django-CMS: Plugin Pool
All content extensions in django-CMS go through the plugin system. Third-party plugins are
installed as Django apps that register themselves with plugin_pool. The ecosystem
includes:
djangocms-text— rich text with CKEditor 5 (replaces the olderdjangocms-text-ckeditorwhich used CKEditor 4)djangocms-picture— image plugin with responsive srcsetdjangocms-link— internal and external link managerdjangocms-file— file attachment plugindjangocms-video— YouTube / Vimeo embed plugindjangocms-bootstrap4— Bootstrap layout plugins (grid, cards, etc.)
The plugin pool is powerful but its quality is uneven. Core plugins (text, picture, link) are well-maintained. Many community plugins target older django-CMS versions and require patching. Always check last commit date and Django version compatibility before pulling in a third-party plugin.
One genuinely useful django-CMS feature that Wagtail lacks out of the box is
plugin nesting — plugins can contain child plugins via
allow_children = True. This lets editors compose column layouts, tabs, or accordion
components entirely from the frontend toolbar without touching code.
6. Routing, Multi-site & i18n
Wagtail routing
Wagtail uses a single URL catch-all that routes requests through the page tree. Add
wagtail_urls to the end of your URLconf and Wagtail handles the rest:
# urls.py
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
urlpatterns = [
path('django-admin/', admin.site.urls), # Django's own admin
path('cms/', include(wagtailadmin_urls)), # Wagtail admin UI
path('documents/', include(wagtaildocs_urls)), # Document serving
# Your own app views above wagtail_urls...
path('', include(wagtail_urls)), # Page routing — must be last (catch-all)
]
Multi-site in Wagtail is handled via Django's Sites framework. Each
Site maps a hostname to a root page in the page tree. It works but requires
deploying multiple Wagtail instances or careful hostname routing — there is no built-in
multi-site dashboard grouping sites together. The wagtail-localize package adds
robust translation workflows with PO file import/export, machine translation integration, and
translation memory.
django-CMS routing
django-CMS has its own apphook system. You attach a Django app (with its own URLconf) to any
CMS page. That page's URL then serves that app's views. This is how you mix CMS-managed pages
with application views — a blog app hooked into /blog/, a shop hooked into
/shop/:
# Attach your Django app to a CMS page via apphook
from cms.app_base import CMSApp
from cms.apphook_pool import apphook_pool
@apphook_pool.register
class BlogApp(CMSApp):
app_name = 'blog'
name = 'Blog Application'
def get_urls(self):
return ['blog.urls']
Multi-site is first-class in django-CMS: you create multiple sites in the admin, assign pages to sites, and the toolbar switches context as you navigate between them. For agencies managing multiple client sites on one installation, this is a genuine advantage.
django-CMS ships with built-in i18n: pages exist per-language, and the toolbar lets editors
switch between language versions. The djangocms-translations package integrates
with professional translation services.
7. Search Integration
Wagtail search
Wagtail has a first-class search framework. The default backend uses the database; you can switch to Elasticsearch with a settings change and no code changes. Search fields are declared on models:
from wagtail.search import index
class BlogPage(Page):
search_fields = Page.search_fields + [
index.SearchField('intro', boost=4),
index.SearchField('body', boost=1),
index.FilterField('date'),
index.AutocompleteField('title'),
]
You search with .search() on any queryset — it transparently dispatches to the
configured backend. Wagtail also tracks search queries and hit rates in the database, surfacing
them in the admin for content teams. See the full Elasticsearch setup in
How to Set Up Wagtail with Elasticsearch.
django-CMS search
django-CMS does not include a search framework. The most common approach is
django-cms-search (for Haystack) or wiring Elasticsearch directly. Because content
lives in plugin tables rather than model fields, indexing is more complex — you have to gather
text from plugin instances across multiple tables and denormalize it into a search document.
This is one of the areas where Wagtail's structured data model pays off: a single
SearchField declaration on the model is enough to index everything. In django-CMS,
you typically write a custom indexing strategy per page type.
8. Image & Media Handling
Wagtail images
Wagtail ships a full image management system built on the Willow library. Images
are uploaded once and referenced anywhere. The template tag handles resizing, cropping, and
WebP conversion at render time, with aggressive caching:
{# Wagtail image template tag #}
{% load wagtailimages_tags %}
{# Generate a 800×600 fill crop, served as WebP with JPEG fallback #}
{% image page.hero_image fill-800x600 as hero %}
<img src="{{ hero.url }}" width="{{ hero.width }}" height="{{ hero.height }}"
alt="{{ page.hero_image.title }}" loading="lazy">
{# Responsive image with srcset #}
{% image page.hero_image width-400 as img_400 %}
{% image page.hero_image width-800 as img_800 %}
<img src="{{ img_400.url }}"
srcset="{{ img_400.url }} 400w, {{ img_800.url }} 800w"
sizes="(max-width:640px) 100vw, 800px"
alt="{{ page.hero_image.title }}">
Editors use a focal point picker on upload so that automatic crops preserve the subject. Wagtail 4.x added WebP output by default. The image library integrates with Wagtail's permission system — you can restrict collections of images to specific editor groups.
django-CMS images
django-CMS delegates images entirely to plugins. The official
djangocms-picture plugin handles uploads and provides a responsive image editor.
Many projects integrate easy-thumbnails or Pillow-based solutions
directly. The ecosystem is workable but less cohesive than Wagtail's built-in approach.
9. Headless & API Modes
Wagtail API
Wagtail ships a built-in read-only REST API (V2) that exposes pages, images, and documents. Enable it in three lines:
# settings.py
INSTALLED_APPS += ['wagtail.api.v2']
# api.py
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
api_router = WagtailAPIRouter('wagtailapi')
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
# urls.py
path('api/v2/', api_router.urls),
The API returns structured JSON with StreamField content serialized as a typed block array. React or Next.js frontends can render StreamField blocks by type without needing to parse HTML. For a write API, you add DRF and model serializers on top — Wagtail does not ship a write API.
The wagtail-headless-preview package adds preview endpoints so a decoupled
frontend can render draft content from the Wagtail admin before publishing.
django-CMS headless
django-CMS's plugin model makes headless harder. Plugin content is stored across multiple
tables, and the toolbar overlay is fundamentally a server-rendered HTML feature. The
djangocms-rest package serialises page content to JSON but it reflects the plugin
tree structure rather than clean typed content — each JSON node maps to a plugin instance.
For headless projects, Wagtail is the cleaner choice. django-CMS is designed around its frontend editing experience, and stripping that away removes its main differentiator.
10. Performance & Caching
Wagtail caching
Wagtail has no built-in page caching — it relies on your Django caching layer or a reverse
proxy (nginx, Varnish, CloudFront). You typically add Django's
cache_page decorator or CacheMiddleware to page views, and bust the
cache on page publish via a Wagtail hook:
from wagtail import hooks
from django.core.cache import cache
@hooks.register('after_publish_page')
def bust_page_cache(request, page):
# Bust the per-page cache key
cache.delete(f'page_{page.pk}')
# delete_pattern is only available with django-redis; skip on other backends
if hasattr(cache, 'delete_pattern'):
cache.delete_pattern('views.decorators.cache.cache_page.*')
For high-traffic sites, the pattern is: Wagtail → nginx cache → CDN. Database queries per page
request depend entirely on your model design — be careful with select_related and
prefetch_related on deep page trees.
django-CMS caching
django-CMS has a plugin-level cache attribute. Setting cache = True on a plugin
tells django-CMS to cache its rendered output:
@plugin_pool.register_plugin
class StaticContentPlugin(CMSPluginBase):
model = StaticContent
render_template = 'plugins/static.html'
cache = True # Cache this plugin's rendered output
django-CMS's caching is more granular — you can cache individual plugins rather than the entire
page. But any plugin with cache = False forces the entire placeholder to skip cache,
which can silently degrade performance when you install third-party plugins that disable caching
by default.
11. Developer Experience
Setup comparison
# Wagtail quickstart
pip install wagtail
wagtail start mysite
cd mysite
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
# django-CMS quickstart
pip install "django-cms>=4.0" djangocms-text
django-admin startproject mysite
cd mysite
# Add cms, menus, treebeard, sekizai, djangocms_text to INSTALLED_APPS
# Configure CMS_TEMPLATES, LANGUAGES, SITE_ID in settings
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Wagtail's project scaffold (wagtail start) gets you to a running CMS in under
five minutes. django-CMS setup requires more manual configuration — adding apps, configuring
template processors, and setting up middleware. The Divio Installer automates this, but the
underlying complexity is real.
Testing
Wagtail ships test utilities including WagtailPageTests which gives you
convenience methods for testing page creation, routing, and rendering:
from datetime import date
from wagtail.models import Page
from wagtail.test.utils import WagtailPageTests
from blog.models import BlogPage, BlogIndexPage
class BlogPageTests(WagtailPageTests):
def test_can_create_under_index(self):
self.assertCanCreateAt(BlogIndexPage, BlogPage)
def test_cannot_create_at_root(self):
self.assertCanNotCreateAt(Page, BlogPage)
def test_page_renders(self):
root = Page.objects.filter(depth=1).first()
index = root.add_child(instance=BlogIndexPage(
title='Blog', slug='blog'
))
page = index.add_child(instance=BlogPage(
title='Test', slug='test', date=date.today()
))
response = self.client.get(page.url)
self.assertEqual(response.status_code, 200)
django-CMS provides CMSTestCase with helpers for creating pages and placeholders
in tests. Plugin testing requires creating plugin instances and attaching them to placeholders,
which adds test setup overhead compared to Wagtail's model-based approach.
Migrations
Wagtail content lives in model fields, so changes go through standard Django migrations. Adding
a field to BlogPage generates a migration exactly like any other model change.
StreamField block additions are schema-transparent — adding a new block type does not require a
migration because the JSON schema is implicit.
django-CMS plugin content is stored in plugin-specific Django models that subclass
CMSPlugin — each plugin class has its own database table with proper Django fields.
Plugin model changes migrate normally. However, renaming a plugin class breaks existing plugin
instances because the class path is stored as a string in the database; you need a data
migration to update those records. django-CMS v4 moved content versioning into the separate
djangocms-versioning package, which uses a content snapshot approach rather than
Wagtail's built-in revision system.
12. When to Pick Each One
Choose Wagtail when:
- Your content has structure — articles, products, events, courses — not freeform marketing pages
- You need a headless or API-first architecture
- You want built-in Elasticsearch search with minimal configuration
- Editorial workflow (draft → review → publish) and version history are important
- Your editors are content producers, not page composers
- You need tight image management with focal points and responsive output
- You want a clean, modern admin that non-technical editors will actually enjoy
- You are building for a large editorial team across multiple content types
Choose django-CMS when:
- Your clients are marketing teams who need to compose and rearrange page layouts without developer involvement
- You are an agency building many client sites from a single installation
- Inline WYSIWYG editing and instant visual feedback are a hard requirement from stakeholders
- You need first-class multi-site management from one Django installation
- Pages are marketing landing pages, not structured content objects
- Your team is already comfortable with the plugin model and has existing plugin code to reuse
What production experience teaches you
After running both in production, the pattern I see repeatedly: projects that start with django-CMS for its flexibility often grow into a place where they need Wagtail's structure. Marketing pages evolve into a content database. Editors stop composing pages and start filling templates. The plugin tree becomes hard to query for feeds, sitemaps, and APIs.
Projects that start with Wagtail occasionally hit the ceiling where an editor needs to compose
a genuinely freeform landing page. The fix is almost always a well-designed set of StreamField
blocks — a TwoColumnBlock, a HeroBlock, a CardGridBlock
— that gives editors the flexibility they need within a structure the developer controls.
If you are uncertain, Wagtail is the lower-risk default for most teams. It scales from simple to complex better than django-CMS scales from flexible to structured.