The Database Changed. The System Did Not.
I reviewed a change recently that looked correct at the database layer. A Wagtail page's SEO fields were updated with
QuerySet.update(). The row changed. The code was fast. The test could prove the new value existed.Production could still serve stale SEO forever.
The missing behavior
QuerySet.update() writes directly to the database. It does not call Model.save(), run model hooks, or emit Wagtail's publish lifecycle signals.That distinction matters when the rest of the system reacts to those signals. Cache revalidation, search indexing, analytics, and downstream synchronization often hang off the publish path. If you bypass that path, you have changed data without telling the system that anything happened.
Page.objects.filter(pk=page.pk).update(seo_title=title)
The row changes. page_published does not fire.
The bug was not in the SQL. The bug was in treating a database mutation as equivalent to a domain event.
Why tests miss it
A narrow test reloads the page and asserts the new title. It passes. A useful regression test also asserts the behavior users depend on: the publish signal fires, the cache is invalidated, or the new metadata is visible through the public response.
This is a recurring review smell. When a change replaces a framework operation with a lower-level write, ask what behavior lived around the old operation. Performance shortcuts are rarely just performance shortcuts.
The rule I carry forward
Use the normal publish path when the action is semantically a publish. If a bulk update is genuinely required, trigger every necessary side effect explicitly and pin that contract with a test.
The database is only one part of the system. Correct rows do not guarantee correct behavior.
TL;DR
QuerySet.update() bypasses model and Wagtail lifecycle hooks. Test the public side effect, not only the stored value.