The symptoms usually arrive together. The post list in wp-admin takes a few seconds to load, the nightly database backup has doubled in size without the site publishing much, and a table size check shows wp_postmeta several times larger than wp_posts. Nothing is broken, but something is holding far more data than the site’s actual content explains.
The fix is not a plugin’s “optimize” button. It is finding out which rows in that table belong to nothing, deleting only those, and leaving everything a live plugin still reads. Here is how to do that with a way back at every step.
Why wp_postmeta grows faster than any other table
Three kinds of rows account for most of the growth, and they need different handling:
Orphaned rows. Metadata whose post_id points at a post that no longer exists. These serve no purpose and are safe to remove once you have a backup.
Leftovers from plugins you uninstalled. Removing a plugin rarely removes the custom fields it wrote to every post. The rows stay, tied to posts that still exist, so a simple orphan query never finds them.
Rows a live plugin still needs. Page builder layout data, SEO fields, product attributes. These can be very large and still be completely legitimate. Elementor’s _elementor_data key, for example, holds an entire page layout as one long value.
The third group is why blind cleanup is risky. A large table is not the same as a wasteful table, and the only way to tell them apart is to look at what is in it.
How the table is built, and why size matters
WordPress core defines wp_postmeta with four columns: meta_id (the primary key), post_id, meta_key (a varchar(255)) and meta_value (a longtext). Core adds two indexes on top of the primary key, one on post_id and one on meta_key, which on a modern utf8mb4 install covers only the first 191 characters of the key.
There is no index on meta_value. Looking up all the metadata for one post is fast, because that goes through post_id. Any query that filters on the value itself, such as a plugin searching for every product with a certain attribute, has no index to use and has to read rows. The more rows the table holds, the more work those queries do, and every leftover row makes that work slightly worse.
Find out what is actually in the table
Start with the table sizes. With WP-CLI:
wp db size --tables --size_format=mb
If wp_postmeta is not among the largest tables, this guide will not change much, and the bigger problem is elsewhere (the autoloaded options problem is the usual next suspect). If it is, list the keys taking the most space:
SELECT meta_key, COUNT(*) AS row_count,
ROUND(SUM(LENGTH(meta_value)) / 1024 / 1024, 1) AS size_mb
FROM wp_postmeta
GROUP BY meta_key
ORDER BY size_mb DESC
LIMIT 20;
Run it through wp db query or phpMyAdmin. Change wp_ to your own table prefix if you changed it during install. The output is a ranked list of the keys responsible for the bulk of the table, and almost every cleanup decision comes from reading it.
Then count the orphans:
SELECT COUNT(*)
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
A number in the hundreds or thousands here means the orphan cleanup below will make a visible difference. Zero means the bloat is somewhere else.
Which rows are safe to delete
Read each key from the ranked list and sort it into one of these:
Safe after a backup: orphaned rows, and keys that clearly belong to a plugin you have removed. Search the key name before deleting anything you do not recognize, because a key that looks like debris is sometimes the active plugin’s own data.
Leave alone: keys starting with an underscore that belong to plugins you still run. WordPress treats underscore-prefixed keys as protected and hides them from the Custom Fields box in the editor, which is why plugins use them for internal data. Also leave page builder layout data, however large it looks. Deleting it does not slim the site down, it deletes the pages.
Do not clean by hand: order data on a WooCommerce store that has not moved to High-Performance Order Storage. That gets its own section below.
Delete orphaned rows, with a way back
A database backup comes first, taken with your host or a backup plugin, not just a file copy. On top of that, make a quick copy of the table itself so an undo takes seconds:
CREATE TABLE wp_postmeta_backup AS SELECT * FROM wp_postmeta;
Confirm the copy holds the same number of rows as the original before continuing. Then preview exactly what will go, using the same join as the count above but selecting the rows:
SELECT pm.*
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL
LIMIT 50;
If those rows look like debris, run the delete:
DELETE pm
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
On a table with millions of rows this can take a while, so run it when traffic is low. Afterward, check the site: open a few posts, a page built with your page builder, and the checkout if you run a store. If anything looks wrong, this puts back every row that is missing from the live table:
INSERT INTO wp_postmeta
SELECT b.*
FROM wp_postmeta_backup b
LEFT JOIN wp_postmeta m ON m.meta_id = b.meta_id
WHERE m.meta_id IS NULL;
Once you are sure everything works, drop the copy with DROP TABLE wp_postmeta_backup; so it does not sit there doubling the space you just recovered.
Remove one uninstalled plugin’s leftovers
For a key that clearly belongs to a plugin you removed, delete by key rather than by post:
wp db query "DELETE FROM wp_postmeta WHERE meta_key = 'the_exact_key_name'"
Do one key at a time, check the site between each, and keep the backup table until the last one is done. A key with a wildcard pattern (LIKE 'pluginprefix_%') is faster but deletes more than you may have previewed, so run the same condition as a SELECT COUNT(*) first and read the count.
Reclaim the space
Deleting rows does not shrink the table file by itself. After the cleanup, run:
wp db optimize
That runs MySQL’s table optimize across the database and gives back the space the deleted rows were holding, which is what makes the backup size and the table size check actually drop.
WooCommerce stores: check High-Performance Order Storage first
Before HPOS, WooCommerce kept orders in wp_posts and wp_postmeta, which is a large part of why store databases balloon. WooCommerce’s own documentation says HPOS moves order data into four dedicated tables (wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data and wp_wc_orders_meta), and that it became the default for new installations in WooCommerce 8.2, released in October 2023. Existing stores have to switch it on themselves.
On a store that predates that release, open WooCommerce, Settings, Advanced, Features and check whether it is enabled. If it is not, a big wp_postmeta is largely order data, and the right fix is the HPOS migration, not a hand-written delete. Deleting order rows from wp_postmeta is deleting orders.
When a big table is not the problem
Some sites have a genuinely large wp_postmeta table because they have tens of thousands of products or posts, each with real fields, and nothing in it is waste. If the ranked key list shows only keys you recognize, and the orphan count is zero or tiny, stop. Cleaning further deletes live data for no gain.
In that case the real speed gain is usually elsewhere: a slow query from one plugin, an oversized autoloaded options table, or missing caching. The size of one table is a clue, not a diagnosis, and the ranked key list is what tells you which of those you are looking at.

Etienne Basson works with website systems, SEO-driven site architecture, and technical implementation. He writes practical guides on building, structuring, and optimizing websites for long-term growth.