The report always reads the same way. The SEO title was changed — by an import, a script, a bulk tool, a database edit — and the field in Yoast's meta box shows the new value, and the page still serves the old one. Nothing errored. Clearing the page cache did not help.
The cause is usually not a cache in the ordinary sense. It is that Yoast SEO keeps a second copy of your metadata, renders from that copy, and the copy was never told the first one changed.
What an indexable is
Since Yoast SEO 14.0, each public post, term and author archive, plus the homepage, gets a row in a table Yoast owns, wp_yoast_indexable (with your own table prefix). Yoast's functional specification calls it "an abstraction layer for interacting with post metadata relating to SEO", kept in "a dedicated database table", and says that when Yoast meets a URL it does not know, it creates a new record.
The SEO title and meta description you type into the Yoast box are still saved where they always were, in post meta under _yoast_wpseo_title and _yoast_wpseo_metadesc. Yoast kept them there deliberately — its own 14.0 post-mortem notes a site-wide re-index was possible "because we left all the data in wp_postmeta". The indexable is built from that post meta.
But the front end reads the indexable. In Yoast's post presentation class, the meta description is $this->model->description — the indexable row's column — falling back to the post type's template when it is empty. Post meta is not consulted on the way to the page. So the two copies can disagree, and when they do, the page shows the indexable's.
How Yoast keeps the two in sync
Yoast is not careless about this. In 28.7 it watches two things:
- Saving a post. The post watcher hooks
wp_insert_postand rebuilds the indexable from current data. This is why editing a post in the block editor always works. - Its own meta keys changing. The post meta watcher hooks
added_post_meta,updated_post_metaanddeleted_post_meta, keeps a list of every post whose changed key starts with_yoast_wpseo_, and rebuilds those indexables in a PHP shutdown function — once the request has finished its work.
The second watcher is the one most explanations of this problem leave out, and it changes the advice. A plugin that calls update_post_meta() on a Yoast key, in a request where Yoast is loaded, normally does reach the page — just not until that request ends.
When the indexable goes stale
Every way to end up with a stale row is a way of changing the data that neither watcher sees:
- The write bypasses WordPress's meta functions. A direct
UPDATE wp_postmeta, a SQL import, a database search-and-replace, a row edited in phpMyAdmin, an import tool that inserts meta rows itself for speed. Noupdated_post_metaaction fires, so Yoast never hears about it. - Yoast is not loaded when the write happens. Yoast was deactivated during a migration and reactivated afterwards, a WP-CLI command ran with
--skip-plugins, or a standalone script bootstrapped WordPress without it. The meta changes; the watcher that would have queued it was not there. - The write happens on a switched multisite blog. The post watcher's
build_indexable()returns immediately whenms_is_switched()is true, so a network-level tool that callsswitch_to_blog()and writes there leaves that site's indexable as it was. - Something reads the page before the request ends. The rebuild runs at shutdown. A tool that writes and then fetches the page within the same request sees the old value, every time — which is exactly how a "write, then check the page" test fails on a correct write.
None of these produces an error. The meta value is right, the meta box shows it, and the row Yoast renders from still holds the previous one.
Why it works on staging
This is the detail that makes the problem so hard to pin down. Yoast saves post indexables only on a production site: should_index_indexables() returns true only when WordPress's environment type is production, unless a developer filters it. On a site set to staging or development, nothing is persisted, every request builds its presentation from current data, and there is no stale row to find.
So the import that leaves production showing old titles works perfectly when you rehearse it on staging. If a problem appears only in production, check wp_get_environment_type() on both before assuming anything else differs.
How to prove it is the indexable
Three reads of the same post, and the disagreement between them is the diagnosis. With WP-CLI, for post 123:
# 1. What is stored
wp post meta get 123 _yoast_wpseo_metadesc
# 2. What Yoast will render (adjust the wp_ prefix to yours)
wp db query "SELECT description FROM wp_yoast_indexable WHERE object_id = 123 AND object_type = 'post'"
# 3. What the page actually serves
curl -s https://example.com/?p=123 | grep -io '<meta name="description"[^>]*>'
If 1 is new and 2 and 3 are old, the indexable is stale. If 1 and 2 are new and 3 is old, it is a page cache. If all three are new and the search result is old, it is Google — which rewrites snippets on its own schedule anyway. Each answer points somewhere different, which is why it is worth the minute.
How to fix a stale indexable
- One post: open it in the editor and update it. That fires
wp_insert_post, and the post watcher rebuilds the row. - Many posts, or not sure which:
wp yoast index --reindex. Yoast's documentation for the command describes--reindexas removing "all existing indexables" and then reindexing them. On a large site it takes a while, and it is the thorough option.
One thing that does not fix it: plain wp yoast index, or the "SEO data optimization" button in Yoast's tools. Both run the same indexation action, which selects posts with no indexable at the current builder version. A stale row is current-version and present, so it is skipped and the tool reports that everything is done. That is the right behaviour for the job those tools exist for — filling in a site that has never been indexed — and the wrong tool for this one.
Going forward, the durable fix is the boring one: write through update_post_meta(), with Yoast active, and tell Yoast directly when you cannot be sure its watcher will run.
How SnippetGrove writes to Yoast
SnippetGrove writes SEO titles and meta descriptions in bulk into whichever SEO plugin a site already runs, so Yoast's second copy is the first thing its Yoast adapter has to get right. All of it lives in a shared library, seo-hosts, that every write path goes through — generated runs, hand edits on the Missing metadata screen, and undo.
- It writes where Yoast's own meta box writes.
_yoast_wpseo_titleand_yoast_wpseo_metadesc, throughupdate_post_meta(), so Yoast's watcher sees every change as well. - Then it refreshes the indexable itself. After each batch — every 100 applied items in a run, or each save of hand edits — it asks Yoast's own indexable repository for the post's row and deletes it through Yoast's model, so Yoast rebuilds it from current post meta on the next request. If that route is unavailable it asks Yoast's builder to rebuild the row in place instead, and either way it clears WordPress's post cache.
- It never touches Yoast's table with SQL. Yoast owns that schema and changes it between major versions; the adapter goes through Yoast's own classes or leaves the row alone.
- It never re-saves the post to trigger a rebuild.
wp_update_post()would work, and it would also bumppost_modifiedon every post it touched — re-dating an entire catalogue after a metadata run, and misleading every feed and reader that trusts "last updated".
Why do both, when Yoast's watcher would usually catch the write? Because the adapter's refresh does not depend on Yoast's watcher being registered in that request, or on the request reaching its end before anything reads the page. It is idempotent with the watcher — deleting a row Yoast is about to rebuild costs one rebuild — and it closes every gap above that a well-behaved writer can close.
The proof is a check rather than an assertion. Under Settings → Check the SEO plugin connection, SnippetGrove writes a marked probe value to one published post, reads it back from Yoast's field, refreshes the indexable, fetches the page over HTTP, and passes only if the served <head> carries exactly one title and exactly one meta description with the probe in them. It then restores the original values and confirms post_modified did not move. When the check fails on Yoast, it also shows the indexable's description beside the post meta value, so a mismatch is visible as data rather than as a hunch. The documentation describes each step.
That round trip was run against Yoast SEO 28.1 on a live WooCommerce store on 2026-07-29, and against Yoast SEO 28.5 on WordPress 6.9.4 and 7.1.1 on 2026-09-19 — apply, undo and a manual-edit conflict, with the rendered page read before and after each and post_modified unchanged throughout. The coverage audit and the connection check are free and need no licence. Generation itself is hosted and enabled for activated licences; public licensing enrollment is not yet open.
If the stale value on your page is not Yoast's, the next most likely cause is a second SEO plugin rendering its own tags. That has a guide of its own: running two SEO plugins at once.