You changed the YouTube URL in the post. You saved. The editor showed the new URL. The front end still renders the old video’s iframe. The post content is correct; the rendered HTML is not. This is not a caching plugin problem, not a theme problem, and not a YouTube problem. It is a key-mismatch problem in wp_postmeta, and the invalidation routine that would fix it is documented as unused by core.
Symptom
The editor updates the URL. The post saves cleanly. The front end serves the previous embed’s HTML. If you inspect the rendered page, the iframe’s video ID is the old one. If you inspect the post content in the database, the URL is the new one. Both facts are true simultaneously because the rendered embed does not come from the post content — it comes from a post meta row keyed by a hash of the URL.
Before touching post meta, rule out page-level caching and CDN HTML caching. A stale rendered page and a stale _oembed_ row produce the same visual symptom and have completely different fixes. The retrieved sources for this article do not cover host or CDN cache layers, so treat that check as a prerequisite, not a conclusion. If a hard refresh with cache bypass still shows the old iframe, continue.
Trace
The relevant code path is WP_Embed::shortcode(), which is registered as the shortcode callback and also runs via autoembed() on the_content. The method derives a cache key from the URL and the shortcode attributes:
$key_suffix = md5( $url . serialize( $attr ) );
$cachekey = '_oembed_' . $key_suffix;
$cachekey_time = '_oembed_time_' . $key_suffix;
That is from the WP_Embed::shortcode() reference. When a post ID is available, the method reads the cached HTML and cache time from that post’s meta using those keys:
if ( $post_id ) {
$cache = get_post_meta( $post_id, $cachekey, true );
$cache_time = get_post_meta( $post_id, $cachekey_time, true );
The TTL defaults to one day and is filterable:
$ttl = apply_filters( 'oembed_ttl', DAY_IN_SECONDS, $url, $attr, $post_id );
The cached branch is entered when either $this->usecache is true or the cache is recent. If the cached value is the sentinel {{unknown}}, the method returns the URL as a link rather than an embed. If the cached value is non-empty HTML, it is passed through the embed_oembed_html filter and returned. The filter reference describes its first parameter as “the cached HTML result, stored in post meta.”
So the rendered iframe on the front end is not read from post_content. It is read from wp_postmeta under a key derived from the URL. Change the URL, and the derived key changes. The old key’s row is not overwritten; it is orphaned. The new key has no row yet, so the next render on the front end will fetch fresh HTML from the provider and write a new row — unless something prevents that fetch path from running, or unless the front end is serving a page cached before the save.
Root cause
The mental model “the cache is stale” is slightly wrong and leads to the wrong fix. The cache is not stale. It is keyed to a URL that no longer appears in the post. The old row is dead weight; the new URL has no row. The correct model is a key mismatch, not a stale value.
WordPress ships a method that would clean this up. WP_Embed::delete_oembed_caches() iterates the post’s meta keys and deletes every one beginning with _oembed_:
foreach ( $post_metas as $post_meta_key ) {
if ( str_starts_with( $post_meta_key, '_oembed_' ) ) {
delete_post_meta( $post_id, $post_meta_key );
}
}
The reference page for that method states plainly: “Deletes all oEmbed caches. Unused by core as of 4.0.0.” The WP_Embed class listing repeats the same note. The method exists; core does not call it.
A 2012 wp-trac mailing-list message records a proposal to move oEmbed cache deletion to pre_post_update so cached metadata would be more likely to be used. That is a proposal recorded on a mailing list, not evidence that shipped core ever hooked delete_oembed_caches() to save_post. The documented fact is the one on the reference page: as of 4.0.0, core does not run it.
The practical consequence for a self-maintained install is that oEmbed meta is data the site owns and must occasionally groom, the same way it treats transients or orphaned post meta. WordPress will not reconcile it on save.
The TTL nuance that explains “sometimes it fixes itself”
Publishers often report that the embed eventually corrects itself. That is consistent with the code. The cached branch is gated on $this->usecache || $cached_recently. On a normal front-end render, usecache is true, so the cached HTML is served regardless of age. The TTL matters on paths that set usecache = false — notably WP_Embed::cache_oembed(), which the reference shows setting usecache to false while re-running the shortcode and autoembed over the post content, then restoring it to true.
That is why behavior differs between the front end and the admin re-cache path. The front end will happily serve a cached row indefinitely as long as the key matches. The admin path can force a fresh fetch. If the publisher’s symptom resolves after an admin save but not after a front-end reload, the difference is the usecache flag, not the TTL.
The {{unknown}} sentinel
Failures are cached too. When wp_oembed_get() returns false and there is no existing cache, WP_Embed::shortcode() writes the literal string {{unknown}} under the cache key. On subsequent renders, the cached branch sees that sentinel and returns the URL as a link via maybe_make_link(). A publisher who sees a bare link where an embed used to be may be looking at a cached failure from a transient provider outage, not a provider outage happening right now. The troubleshooting will be mis-timed unless the stored value is inspected.
Fix
The fix is a deliberate, narrowly scoped purge of the affected post’s _oembed_ meta, followed by a render that repopulates the new key. Do not run a blanket DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%' on a live publishing site. That forces every embedded URL across the site to be re-fetched on next render, which is a self-inflicted provider-rate and page-latency event. Scope the purge to the affected post and, where practical, the affected key.
Step 1: Identify the post and list its oEmbed keys
Using WP-CLI:
wp post meta list 1234 --keys='_oembed_*' --format=table
Or in PHP, using get_post_custom_keys() as the core method does:
$keys = get_post_custom_keys( 1234 );
foreach ( $keys as $key ) {
if ( str_starts_with( $key, '_oembed_' ) ) {
echo $key . "\n";
}
}
You will see pairs: _oembed_<md5> and _oembed_time_<md5>. If the post has been edited several times, there may be several pairs, one per historical URL-and-attributes combination.
Step 2: Confirm which key corresponds to the current URL
Compute the hash the same way core does. The URL must match exactly what WP_Embed::shortcode() receives after the & to & replacement, and the attributes must match the serialized form. For a bare URL with default attributes, a quick check in a WP-CLI eval is:
wp eval '
$url = "https://www.youtube.com/watch?v=NEWVIDEOID";
$attr = wp_parse_args( array(), wp_embed_defaults( $url ) );
echo "_oembed_" . md5( $url . serialize( $attr ) ) . "\n";
'
If the computed key is not present in the post’s meta, the new URL has no cache row yet and the next render will fetch fresh. If the computed key is present and its value is the old iframe HTML, the row is wrong for the current URL and should be removed. If the computed key is present and its value is {{unknown}}, the row is a cached failure and should be removed.
Step 3: Delete only the affected keys
wp post meta delete 1234 _oembed_<md5>
wp post meta delete 1234 _oembed_time_<md5>
Or, to remove every oEmbed row for that one post — the equivalent of running delete_oembed_caches() manually — use a scoped WP-CLI eval:
wp eval '
$post_id = 1234;
foreach ( get_post_custom_keys( $post_id ) as $key ) {
if ( str_starts_with( $key, "_oembed_" ) ) {
delete_post_meta( $post_id, $key );
}
}
'
That is the same loop the core method uses. It is safe because it is scoped to one post ID. It is not a substitute for a site-wide purge, and it should not be.
Step 4: Let the next render repopulate
Load the post on the front end with any page cache bypassed. WP_Embed::shortcode() will find no cached row, call wp_oembed_get(), and write the new HTML and timestamp under the new key. If the provider returns false, the sentinel {{unknown}} will be written instead, and the front end will show a link. That is a provider-side failure, not a meta problem.
Why a save_post hook is the wrong reflex
The obvious-looking fix is to hook save_post and call delete_oembed_caches() on every save. That fires on unrelated edits — title changes, category changes, status transitions — and purges oEmbed rows that did not need purging. The save_post reference also warns that calling a function such as wp_update_post() inside a save_post callback creates an infinite loop unless the callback is unhooked first. A purge callback that itself triggers a save is a plausible way to compound the problem.
For a small publishing team, a targeted admin action or a WP-CLI command is a better fit than an automatic hook. The purge is rare, the scope is one post, and the operator can see what was deleted.
Verification
After the purge and a fresh render, confirm three things:
-
The rendered iframe’s video ID matches the URL in the post content. Inspect the iframe
srcin DevTools. If it does not match, the front end is serving a page cached before the save, or a different code path is producing the embed. -
The stored meta value matches the rendered HTML. Query the row:
SELECT meta_key, LEFT(meta_value, 120) AS value_preview FROM wp_postmeta WHERE post_id = 1234 AND meta_key LIKE '_oembed_%' ORDER BY meta_key;You should see one
_oembed_<md5>row containing the new iframe HTML and one_oembed_time_<md5>row containing a recent Unix timestamp. If the value is{{unknown}}, the provider fetch failed and the front end will show a link. -
The timestamp is recent. A timestamp older than the edit means the row was not repopulated — either the render did not reach
WP_Embed::shortcode(), or the page was served from cache.
If all three check out and the front end still shows the old video, the problem is above the oEmbed layer: page cache, object cache, CDN, or a theme/plugin filter on embed_oembed_html that is substituting HTML. The filter reference shows the filter receives the cached HTML, the URL, the attributes, and the post ID, so a filter that ignores the URL and returns a fixed string will produce exactly this symptom.
Diagnostic checklist
- Confirm the rendered iframe’s video ID against the URL in
post_content. - Confirm the stored
_oembed_value for the current URL’s hash. - Confirm the stored
_oembed_time_value is recent. - Confirm whether a page cache or CDN is serving HTML rendered before the save.
- Confirm whether an
embed_oembed_htmlfilter is substituting HTML. - Confirm whether the stored value is
{{unknown}}, which indicates a cached provider failure rather than a stale success.
FAQ
Why does the old embed come back after I delete the meta row?
If the front end is served from a page cache that was populated before the deletion, the cached HTML still contains the old iframe. The meta row is gone, but the rendered page is not. Bypass the page cache and re-render.
Why does the embed show as a plain link instead of a video?
The stored value is likely {{unknown}}, the sentinel WP_Embed::shortcode() writes when wp_oembed_get() returns false and there is no existing cache. Delete the key and let the next render retry the provider. If the provider is still failing, the sentinel will be written again.
Does the TTL eventually fix this on its own?
Not on a normal front-end render. The cached branch is entered when $this->usecache is true, which it is by default, regardless of the TTL. The TTL matters on paths that set usecache = false, such as WP_Embed::cache_oembed(). A front-end reload will keep serving the cached row as long as the key matches.
Can I just run DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%'?
You can, but on a live publishing site it forces every embedded URL across the site to be re-fetched on next render. That is a provider-rate and page-latency event you are inflicting on yourself. Scope the purge to the affected post.
Why doesn’t WordPress clean this up on save?
WP_Embed::delete_oembed_caches() exists and does the cleanup, but the reference page states it is “Unused by core as of 4.0.0.” Core does not call it on save. The oEmbed meta is data the site owns and must groom.
Is this specific to YouTube?
No. The mechanism is provider-agnostic. Any URL that routes through WP_Embed::shortcode() and caches under _oembed_<md5> is subject to the same key-mismatch behavior when the URL changes.