#14620·wagtail

Rich text page links read the site root paths cache, and query Locale, once per link instead of once per page

Author: jpmckinneyCreated Sep 16, 2026Updated Sep 16, 2026
Labelsstatus:Needs Reviewstatus:Needs Infocomponent:Rich text🚀 Performance

Issue summary

PageLinkHandler.expand_db_attributes_many() batches the database access for the pages it links to, via get_many(), but then resolves each URL individually with page.localized.url. Both halves of that expression repeat work that the pass could share:

  • Page.get_url() ends in Page._get_site_root_paths(), which memoizes on the cache_object it is given, and on the page instance otherwise. Rich text rewriting has no request in scope, and get_many() builds fresh page instances for every rich text value, so each linked page is a separate cache.get("wagtail_site_root_paths") — a network round trip on Redis or Memcached.
  • .localized calls Locale.get_active(), and LocaleManager.get_for_language() is an uncached .get(), so every link is also a SELECT ... FROM wagtailcore_locale WHERE language_code = %s when WAGTAIL_I18N_ENABLED is true.

On a fresh Wagtail 8.0 project (steps below), rendering a page whose body holds three links costs:

links cache reads of wagtail_site_root_paths wagtailcore_locale queries
none (baseline) 0 1
3 links to 3 pages, in 3 rich text values 3 4
3 links to 3 pages, in 1 rich text value 3 4
3 links to the same page, in 1 rich text value 1 4

The cache reads scale with the distinct pages linked from each rich text value, and the Locale queries with the links themselves. A single lookup per render would serve all of them.

This surfaced as a Sentry N+1 alert on a production page, whose repeating group was one cache read, the two queries from PageQuerySet.specific() in get_many(), and one Locale query, repeated for each rich text value in the body. The per-request cost is small, but it scales with how many links an editor writes into a page's body, and a project cannot reach it: {% pageurl %} fixes URL resolution in a project's own templates, but not inside rich text rewriting.

Steps to reproduce

  1. Start a new project with wagtail start myproject, and set WAGTAIL_I18N_ENABLED = True.
  2. Give HomePage three RichTextFields, body_one, body_two and body_three, and render all three with the richtext filter in home_page.html.
  3. Run the following, which creates a page to link to, puts one link in each rich text field, then counts the cache reads and the queries of a render:
python
import collections

from django.core.cache import cache
from django.db import connection
from django.test import Client
from django.test.utils import CaptureQueriesContext, setup_test_environment
from home.models import HomePage

setup_test_environment()

home = HomePage.objects.get(slug="home")
if not HomePage.objects.filter(slug="target").exists():
    home.add_child(instance=HomePage(title="Target", slug="target"))
target = HomePage.objects.get(slug="target")

home.body_one = home.body_two = home.body_three = f'<p><a id="{target.id}" linktype="page">Target</a></p>'
home.save()

cache.clear()
reads = collections.Counter()
original = cache.get
cache.get = lambda key, *args, **kwargs: (reads.update([key]), original(key, *args, **kwargs))[1]
with CaptureQueriesContext(connection) as queries:
    Client().get("/")
cache.get = original

print(reads["wagtail_site_root_paths"])  # 3, rather than 1
print(sum("wagtailcore_locale" in query["sql"] for query in queries.captured_queries))  # 4, rather than 1

Additional information

Two ideas:

  • Let expand_db_attributes_many() resolve the site root paths once and share them with the pages it is about to resolve. _get_site_root_paths() already takes a cache_object, but get_url_parts() and get_url() only accept request, which doubles as the memo carrier and as the argument to Site.find_for_request(). Threading cache_object through those two methods would make the sharing expressible without a request; priming _wagtail_cached_site_root_paths on the pages is the same thing done from outside.
  • Memoize Locale.get_active(), which is a query on every call. That would help well beyond this code path, and locales change rarely enough to be cached against the active language.

The two get_many() queries per rich text value look inherent to rewriting each value separately, so this issue is about the per-link work.

Can be reproduced

Yes, on a fresh Wagtail project

Technical details

  • Python version: 3.11.9
  • Django version: 5.2.17
  • Wagtail version: 8.0 (first observed on 7.4.2, with Django 5.2.16)
  • Browser version: N/A, server-side

Working on this

If you would like to contribute to this issue, follow these steps:

  • Confirm that the issue is reproducible, either on a fresh Wagtail project or the bakerydemo.
  • Once confirmed, view our contributing guidelines, add a comment to the issue once you're ready to start.