Ashish Pitrodafield notes
All fixes

Wagtail 27 September 2026

Why Adding a Heading Block to Your CMS Takes Three Days · I Let the Admin Define CMS Blocks. Here's What Broke.

Define StreamField blocks in the Wagtail admin, without writing Python: the definitions live in database rows rather than code. And the five bugs that came with it, every one of which passed the test I would naturally have run.

Someone asks for a testimonial block. A quote, a name, a photo.

That is maybe ten minutes of actual work. In most CMS setups it is three days of calendar, because it has to pass through a developer, a pull request, a code review and a deploy to get there. Nobody in that chain is doing anything wrong. The work is genuinely small, and the process around it genuinely isn't.

I got tired of that gap, so I spent a while building something about it: a visual block builder for Wagtail. You define the shape of a content block in the admin by dragging fields around, and it becomes a real, selectable block in the ordinary page editor.

One thing it deliberately is not: a page builder. Nobody is dragging content around a page. It changes who is allowed to define a block's schema. Filling that block in is still the editor's job, in the normal editor, with the normal review and publishing workflow. Those two roles staying separate is the whole point — the moment you blur them you have built a website-shaped Word document, and everyone involved eventually regrets it.

The building part was interesting, but not for the reason I expected.

The four bugs

Block definitions live in database rows. At request time those rows are turned into real Wagtail block instances. No generated Python files, no code written to disk, no monkey-patching. That sounds straightforward, and the first working version took an afternoon.

Then four things went wrong. What they have in common is more useful than any of them individually: every one of them passed the test I would naturally have run.

1. The block list went stale. The framework caches a field's block list on the field instance. Entirely correct when your blocks are declared in code, because they cannot change while the process is running. Mine could. And a field instance lives for the whole worker process, not one request.

On my machine it worked perfectly — the dev server restarts every time you touch a file. In production it would have produced a block that appears on some requests and not others, depending which worker you hit. That is a bug you ship, because there is no manual test that catches it.

2. Every schema edit wanted a migration. The field's deconstruct() writes the whole block list into migration state. Right for declared blocks. For mine it meant a content editor adding a field in the admin would make Django demand a new migration. The fix was to skip that implementation entirely — safe, because the underlying column is just JSON. Changing which blocks are available genuinely does not need a schema change.

3. Blocks renamed each other. When a parent block adopts a child, the framework calls set_name() on it, which mutates the instance in place. So the obvious optimisation — build a block once, reuse the instance — means the second place you use it renames the first. Your Heading block is suddenly called right_column. The fix is boring: build a fresh instance per slot and cache one level up.

4. Loops crashed the editor. In ordinary code you cannot make a circular block reference, because you cannot reference a class before defining it. The language stops you. Database rows have no such rule. Hero contains Card, Card contains Hero.

What you get is not a validation error. It is a stack overflow hundreds of frames deep inside the library that serialises blocks for the JavaScript editor. The page editor simply dies, and the traceback points at framework code rather than yours. So there is now a graph walk on save that refuses the change and names the path, plus a depth cap — and the interface greys out any block that would do it, using the same function, so the UI can never offer something the server is about to reject.

The one I found on camera

While screenshotting the front end for the video, I found a fifth.

A "group" field — a cluster of sub-fields defined inline — has no definition record of its own, so nothing ever assigned it a template. It fell through to the framework's default rendering, which prints a definition list of raw machine names onto the page. Exactly the output the entire feature exists to prevent.

It had been there for weeks. Every test passed, because only the nested output was wrong and every test I had written checked a top-level block. It took pointing a camera at the result to see it.

There is a regression test now. I checked that it fails without the fix, which is the only way to know a regression test is worth anything.

What it does not do

Renaming a block orphans content stored under the old name, so machine names lock after creation. Resaving old content under a changed schema is lossy for fields that no longer exist — reading is always safe. Usage scanning walks every page, which is fine at this scale and would want an index table on a large site. And templates written in the admin are template code, so that permission belongs to people you trust.

I would rather write that list than discover someone found it themselves.

There is also an export button that turns any block into equivalent framework Python, including everything it builds on, in dependency order. Paste it in, delete the module, carry on. It exists so that adopting this is never a one-way door — and honestly, I think a tool that makes leaving easy is the only kind worth adopting.

The actual takeaway

I set out to remove a deployment from a ten-minute task. What I got was a much better reminder of something I already knew and clearly did not believe hard enough:

The dangerous bugs are not the ones that fail loudly. They are the ones that pass.

All five of these looked fine. Four of them looked fine on my machine specifically, for reasons that had nothing to do with whether the code was correct — a dev server that restarts, a test that only checks the top level, a cache that happens to be cold. The environment was doing the work my tests should have been doing, and quietly taking the credit.

If you are building anything where the schema is data rather than code, I would budget real time for that category alone.

I walk through all of it — the architecture, the admin, the rendering, and the bug I found mid-recording — in about fifteen minutes here:

https://www.youtube.com/watch?v=CLPIo7BFbPg

Code is on GitHub: https://github.com/apequaltowork/wagtail-blocks

If you have solved this differently, I would genuinely like to hear how. And if a tool like this is something you would use, tell me what would have to be true for it to be worth it — I am weighing up whether to package it properly.

Stuck on something like this?

Tell me what you have and what it is doing instead. I will tell you honestly whether I am the right person for it.

Write to me or read the other fixes