Why Your oEmbed Cache Pollutes wp_postmeta and How to Prune It Safely

Every time a WordPress editor pastes a YouTube URL, a Vimeo link, or a tweet into the block editor, WordPress runs an oEmbed discovery request. The response gets cached. Not in a dedicated table. Not in a transient bucket that expires cleanly. It lands in wp_postmeta as rows keyed _oembed_* and _oembed_time_*. For a small-to-mid publishing team running a multi-editor newsroom, a magazine site, or a content-heavy membership property, this is not a theoretical annoyance. It is a slow accumulation of orphaned metadata that bloats the postmeta table, degrades meta_query performance, complicates database migrations, and makes backups larger for no editorial benefit. The fix is not a plugin that promises to “clean everything.” The fix is understanding exactly which rows are safe to delete, which ones are still referenced, and how to prevent the pollution from returning without breaking the editor experience.

This article is for the WordPress systems engineer who has already seen wp_postmeta grow to hundreds of thousands of rows on a site with only a few thousand posts. It is for the developer who has opened phpMyAdmin, seen _oembed_time_5f8d2c... repeated thousands of times, and wondered whether deleting them will break the front end. It is for the team lead who needs a repeatable pruning routine, not a one-off SQL snippet copied from a forum. I will cover the exact storage mechanism, the failure modes of naive deletion, a safe pruning procedure, and a prevention strategy that respects WordPress core behavior.

WordPress database schema on a developer screen showing wp_postmeta table rows

What oEmbed Cache Rows Actually Look Like in wp_postmeta

When a post contains an embeddable URL, WordPress stores two types of postmeta entries. The first is the cached oEmbed HTML response, stored under a key like _oembed_5f8d2c9b1a3e4f6a7b8c9d0e1f2a3b4c. The second is a timestamp, stored under _oembed_time_5f8d2c9b1a3e4f6a7b8c9d0e1f2a3b4c. The hash suffix is an MD5 of the source URL. The same URL embedded in ten different posts produces ten separate postmeta rows, because postmeta is scoped to a post_id. A single post with three different embeds produces six rows: three HTML caches and three timestamps.

This design is not a bug in the traditional sense. It is a consequence of WordPress storing oEmbed cache data in the same generic key-value table used for custom fields, SEO metadata, and plugin settings. The wp_postmeta table has no concept of “cache” versus “content.” It has no TTL enforcement at the database level. The _oembed_time_* key exists so WordPress can invalidate the cache after 24 hours, but the invalidation only happens when the post is loaded and the timestamp is checked. If a post is never loaded again, the cache row remains indefinitely.

Why the Rows Accumulate Faster Than You Expect

Several behaviors accelerate the accumulation. First, every revision of a post can carry its own oEmbed cache rows. If an editor saves a draft ten times, each save can trigger a new oEmbed fetch and a new set of postmeta rows. Second, when a post is deleted through the WordPress admin, the postmeta rows are usually deleted by the core deletion routine, but not always. If a post is removed directly from the database, or if a plugin deletes posts without calling wp_delete_post(), the postmeta rows remain orphaned. Third, when an editor changes the embed URL in a post, the old cache rows are not immediately removed. They stay until the post is saved again and the cleanup routine runs, which does not always happen.

For a publishing team that embeds tweets, YouTube videos, and Spotify playlists in daily articles, the math is simple. A site publishing 20 articles per week, each with two embeds, creates at least 80 new postmeta rows per week just from oEmbed. Over a year, that is over 4,000 rows. Add revisions, drafts, and editor previews, and the number can easily triple. On a site with 5,000 posts, the wp_postmeta table can contain 50,000 to 100,000 oEmbed rows that serve no current purpose.

The Real Cost of oEmbed Cache Pollution

The cost is not just disk space. The wp_postmeta table is one of the most queried tables in WordPress. Every call to get_post_meta() runs a query against it. Plugins that use meta_query for related posts, filtering, or search join against it. A bloated postmeta table slows down those queries, especially on sites that do not have object caching. The meta_key column is indexed by default, but the index itself grows with the table. A larger index means more memory pressure on the database server.

There is a second cost that is less obvious: migration and backup time. A database dump with 100,000 unnecessary rows takes longer to export and import. Staging site clones become slower. Search-replace operations on the database, such as those performed by WP-CLI or migration plugins, have to process every row. For a small-to-mid team that pushes content from staging to production, this is measurable friction.

A third cost is editorial confusion. When a developer or site owner looks at the postmeta table and sees thousands of rows with cryptic keys, it becomes harder to distinguish real custom fields from cache data. This makes debugging harder. If a plugin conflict arises, the first instinct is often to blame the postmeta table, and the oEmbed rows obscure the actual problem.

Database performance monitoring dashboard showing slow query metrics

What Happens If You Delete the Wrong Rows

Before pruning, you need to understand the failure modes. Deleting _oembed_* rows is generally safe for the front end. WordPress will simply re-fetch the oEmbed data the next time the post is rendered. The embed will still appear. The only downside is a temporary performance hit while WordPress makes the external request. Deleting _oembed_time_* rows is also safe in the same way. The timestamp is only used to decide whether the cached HTML is stale. If the timestamp is missing, WordPress treats the cache as stale and re-fetches.

The danger is not in deleting oEmbed rows. The danger is in deleting rows that are not oEmbed rows. A careless SQL query like DELETE FROM wp_postmeta WHERE meta_key LIKE '%oembed%' will also delete rows from plugins that use the word “oembed” in their own keys. Some plugins store custom oEmbed-related data under keys like _myplugin_oembed_data. Deleting those rows can break plugin functionality. The safe approach is to target only the exact core keys: _oembed_% and _oembed_time_%.

Another failure mode is deleting postmeta rows for posts that are still in the trash. WordPress does not always clean postmeta when a post is trashed. If you prune oEmbed rows for trashed posts, you are not breaking anything, but you are also not solving the underlying problem. The real issue is that the postmeta table has no garbage collection for orphaned rows. A safe pruning routine must account for posts that no longer exist.

How to Prune oEmbed Cache Rows Safely

The pruning procedure has three stages: identify, delete, and verify. Do not skip the verification stage. A production database is not the place to learn that your LIKE pattern was too broad.

Stage 1: Identify the Rows

Run a count query first. This tells you the scope of the problem and gives you a baseline for comparison after pruning.

SELECT COUNT(*) FROM wp_postmeta WHERE meta_key LIKE '\_oembed\_%' OR meta_key LIKE '\_oembed\_time\_%';

The backslash escaping is necessary because _ is a wildcard in SQL LIKE patterns. Without the backslash, _oembed_% would match any key that has any character followed by “oembed”. The escaped version matches only keys that literally start with _oembed_.

Next, check how many of those rows belong to posts that still exist. This is important because orphaned rows are the highest-priority deletion target.

SELECT COUNT(*) FROM wp_postmeta pm LEFT JOIN wp_posts p ON pm.post_id = p.ID WHERE (pm.meta_key LIKE '\_oembed\_%' OR pm.meta_key LIKE '\_oembed\_time\_%') AND p.ID IS NULL;

If the orphaned count is high, you can delete those rows immediately without any risk to live content. The posts they belonged to are gone, so the cache data is useless.

Stage 2: Delete the Rows

For orphaned rows, use a targeted delete with a subquery that checks for missing posts.

DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON pm.post_id = p.ID WHERE (pm.meta_key LIKE '\_oembed\_%' OR pm.meta_key LIKE '\_oembed\_time\_%') AND p.ID IS NULL;

For rows that belong to existing posts, the decision requires more care. If you delete all oEmbed cache rows for live posts, the next page load will trigger a burst of external oEmbed requests. On a site with hundreds of posts, this can cause a noticeable slowdown and may hit rate limits on platforms like Twitter or YouTube. A safer approach is to delete only the timestamp rows first. This forces WordPress to re-fetch the oEmbed data on the next load, but it does so gradually as posts are actually visited. The HTML cache rows can be deleted in a second pass after the timestamps have been cleared.

DELETE FROM wp_postmeta WHERE meta_key LIKE '\_oembed\_time\_%';

After the timestamps are gone, WordPress will treat every cached oEmbed HTML as stale. The next time a post is loaded, WordPress will re-fetch the embed and store a new timestamp. This spreads the external requests over time instead of creating a thundering herd.

If you want to delete the HTML cache rows as well, do it after the timestamps have been cleared and the site has had time to re-fetch the most frequently visited posts. A simple approach is to wait 24 to 48 hours, then delete the remaining _oembed_% rows that still have no corresponding timestamp.

DELETE FROM wp_postmeta WHERE meta_key LIKE '\_oembed\_%' AND meta_key NOT LIKE '\_oembed\_time\_%';

This query deletes only the HTML cache rows, not the timestamp rows. By this point, the timestamp rows for frequently visited posts have been recreated, so those posts will not be affected. The HTML cache rows for rarely visited posts are deleted, and they will be re-fetched only if someone visits those posts.

Stage 3: Verify the Pruning

After each delete, run the count query again and compare the numbers. The orphaned count should be zero. The total oEmbed row count should be significantly lower. Then load a few posts on the front end that contain embeds. Check that the embeds still render. Check the postmeta table for those posts to confirm that new _oembed_* and _oembed_time_* rows were created. If the embeds render and the new rows appear, the pruning was successful.

If you have a staging environment, run the entire procedure there first. This is not optional for a production database. A staging clone gives you a safe place to test the exact SQL queries against a copy of the real data. If a query is wrong, you lose nothing. If the query is right, you can run it on production with confidence.

Preventing oEmbed Cache Pollution from Returning

Pruning is a one-time fix. Prevention is a systems design decision. The core problem is that WordPress stores oEmbed cache data in wp_postmeta with no automatic cleanup for orphaned rows. You cannot change core behavior without breaking the editor experience, but you can add a scheduled cleanup routine that runs on a defined interval.

The simplest prevention is a WP-CLI command that runs the orphaned-row deletion on a weekly or monthly schedule. If your team already uses WP-CLI for deployments and maintenance, this fits naturally into the existing workflow.

wp db query "DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON pm.post_id = p.ID WHERE (pm.meta_key LIKE '\\_oembed\\_%' OR pm.meta_key LIKE '\\_oembed\\_time\\_%') AND p.ID IS NULL;"

Schedule this command through cron on the server, not through WordPress cron. WordPress cron only runs when the site is visited, which is unreliable for a low-traffic staging site or a site with irregular traffic patterns. A server-level cron job runs on a fixed schedule regardless of traffic.

A second prevention layer is to limit the number of revisions stored for each post. Revisions are a major source of oEmbed cache accumulation. If your team does not need 50 revisions per post, reduce the limit in wp-config.php.

define('WP_POST_REVISIONS', 5);

This does not stop oEmbed cache rows from being created, but it reduces the number of places they can hide. Fewer revisions means fewer orphaned postmeta rows when a post is updated.

A third prevention layer is to monitor the postmeta table size over time. If you have a monitoring system like New Relic, Datadog, or a simple nightly database size check, add a metric for the number of _oembed_% rows. When the count crosses a threshold, run the pruning routine. This turns a reactive cleanup into a predictable maintenance task.

Server cron job configuration for scheduled WordPress database maintenance

What This Means for Your Editorial Workflow

The oEmbed cache is not a content problem. It is a systems problem. Editors should not have to think about database tables when they paste a YouTube link. The systems engineer should. The goal is to make the editorial workflow feel instant while keeping the database lean enough to back up, migrate, and query without friction.

For a small-to-mid publishing team, the practical takeaway is this: schedule a monthly orphaned postmeta cleanup, limit revisions, and document the pruning procedure in your team’s runbook. When a new developer joins the team, they should not have to rediscover why wp_postmeta has 80,000 rows with _oembed_ keys. The runbook should explain it in one paragraph and point to the exact SQL queries.

If you are dealing with a related symptom—such as a new WordPress site returning “Nothing Found” on archive pages—the root cause is often a different kind of metadata mismatch. I have written about that separately in What to Fix First When a New WordPress Site Says Nothing Found. The two problems share a common thread: WordPress stores more state in wp_postmeta than most teams realize, and that state can drift out of sync with the actual content.

FAQ

Is it safe to delete all _oembed_* rows from wp_postmeta?

Yes, with one caveat. Deleting the rows is safe because WordPress will re-fetch the oEmbed data on the next page load. The caveat is that a mass deletion can cause a burst of external requests if many posts are loaded at once. To avoid this, delete the _oembed_time_* rows first, let the site re-fetch the most visited posts over 24 to 48 hours, then delete the remaining _oembed_* HTML cache rows.

Why does wp_postmeta grow so large on sites that rarely change content?

Because oEmbed cache rows are created for every embed in every revision and draft, and they are not automatically removed when a post is deleted outside the standard WordPress deletion flow. A site with a stable post count can still accumulate thousands of orphaned oEmbed rows from editor previews, saved drafts, and posts that were removed directly from the database.

Can I prevent oEmbed cache rows from being stored in wp_postmeta at all?

Not without replacing core oEmbed functionality. You can filter the oEmbed cache duration, but the storage location is hardcoded in WordPress core. The practical prevention is a scheduled cleanup routine that removes orphaned rows and a revision limit that reduces the number of places cache rows can accumulate.

How often should a publishing team prune oEmbed cache rows?

Monthly is sufficient for most small-to-mid teams. If your team publishes multiple posts per day with several embeds per post, a weekly cleanup may be warranted. The key is to monitor the row count and prune when the orphaned count exceeds a few thousand rows, not to prune on a fixed schedule regardless of need.

Next Step: Build a Postmeta Health Check into Your Maintenance Routine

This article is part of a larger pattern: WordPress systems engineering for publishing teams that cannot afford to treat the database as a black box. The next logical step is to build a postmeta health check that reports not just oEmbed rows, but also orphaned custom fields, duplicate meta keys, and plugin leftovers. That health check becomes a recurring column on this site, with a runbook your team can copy. If you have a specific postmeta failure mode you want dissected, send it in. The best columns come from real production logs, not hypotheticals.