Before WordPress renders anything, it runs one query: SELECT option_name, option_value FROM wp_options WHERE autoload = 'yes' (newer versions also match on and auto-on), and holds the whole result in memory for the rest of the request. That’s by design, and it’s fast when the result is small. The trouble is that nothing ever makes it small again. Every plugin you’ve installed, and every one you’ve removed, had a chance to leave rows in that table, and most of them did. Here’s how I investigate it, in the order I’d do it on a client’s site.
Step 01 Measure the total
One query, run in phpMyAdmin, Adminer, or your host’s database tool. Change wp_ if your table prefix differs.
SELECT COUNT(*) AS rows_autoloaded,
ROUND(SUM(LENGTH(option_value)) / 1024) AS kb_autoloaded
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');
With WP-CLI it’s one line:
wp option list --autoload=on --format=count
wp db query "SELECT ROUND(SUM(LENGTH(option_value))/1024) AS kb FROM $(wp db prefix)options WHERE autoload IN ('yes','on','auto-on','auto')"
What’s normal Under 400 KB is healthy. WordPress’s own Site Health check (Tools → Site Health) warns at 800 KB. Over 2 MB and you have found real time on every page view; I’ve measured sites in the tens of megabytes, which means every request began by pulling a novel out of the database before doing any work.
You're fine. Skip to step 07 and set the monthly reminder; the rest of this post is for the other sites.
Step 02 finds out what it is.
Step 02 List the biggest rows
SELECT option_name,
ROUND(LENGTH(option_value) / 1024, 1) AS kb,
autoload
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY LENGTH(option_value) DESC
LIMIT 40;
Or with WP-CLI, sorted by size:
wp option list --autoload=on --fields=option_name,size_bytes --orderby=size_bytes --order=desc | head -40
Read the list top to bottom and put each row into one of five buckets. This is the whole investigation; everything after is cleanup.
Legitimate core rows. rewrite_rules, cron, wp_user_roles, active_plugins, sidebars_widgets, the theme mods. Expected, usually small. If cron is over 100 KB, a plugin is scheduling events that never get cleared (step 05). If rewrite_rules is huge, a plugin registers thousands of rewrite rules and you’ll feel it on every page.
Transients. Rows beginning _transient_ or _site_transient_. Transients are caches with an expiry, and WordPress only deletes an expired one when something asks for it. The ones nobody asks for again stay forever. Step 03.
Leftovers from removed plugins. Names you recognise from plugins you uninstalled in 2021. Uninstall routines are optional, and many plugins don’t have one. Step 04.
Data that shouldn’t be an option at all. Per-visitor sessions, form submissions, logs, analytics counters, a “views” number updated on every page load. Some plugins use wp_options as a scratch pad because it’s convenient, and set autoload on top. The plugin is still installed and still writing. Step 05.
Big, legitimate, but shouldn’t autoload. A page builder’s global CSS, a theme’s options blob, a font-cache array: real data, used on some pages, but 300 KB loaded on every request including admin-ajax and cron. Step 06.
Plain English The table is a hallway everybody’s allowed to leave boxes in, and WordPress carries every box into every room. The job is to find out whose boxes they are and whether they’re still needed.
Step 03 Clear the expired transients
Every transient has a twin, _transient_timeout_<name>, holding its expiry as a Unix timestamp. Expired pairs can go. With WP-CLI:
wp transient delete --expired
wp transient delete --expired --network
Or in SQL, deleting each expired timeout row together with its value row:
DELETE a, b
FROM wp_options a
JOIN wp_options b
ON b.option_name = CONCAT('_transient_', SUBSTRING(a.option_name, 20))
WHERE a.option_name LIKE '\_transient\_timeout\_%'
AND a.option_value < UNIX_TIMESTAMP();
Run step 01 again. On a site with a broken cleanup routine this alone can drop megabytes. If the transients come back within days, a plugin is creating them faster than they expire, and you’ll want its name from step 05.
Good. Finish with step 07 so it stays that way.
The weight is in named rows, not caches. Step 04.
Step 04 Deal with the leftovers
For a row you’re sure belongs to a plugin that no longer exists, you have two safe moves. The gentle one is to stop autoloading it and leave the data in place:
UPDATE wp_options SET autoload = 'no'
WHERE option_name LIKE 'old_plugin_prefix_%';
The clean one is to delete it. Take a database backup first, then:
wp option delete old_plugin_prefix_settings
How sure is “sure”? The prefix should match a plugin you can name, that plugin should not be in wp-content/plugins, and the row should not be referenced anywhere else (wp option get <name> shows you the content; if it’s obviously a settings array for something you removed, it’s safe). When in doubt, flip autoload off and wait a month. Nothing that still needs the row will break, because get_option() fetches non-autoloaded rows on demand; it will just cost one extra query the first time it’s asked for.
Step 05 Find who is still writing
Cleaning is pointless if a plugin refills the table by Friday. Two ways to catch the writer. First, the name: an option like wpforms_entries_cache_1234 or elementor_css_print_method tells you the plugin directly, and its settings usually have a switch (store sessions in the database, cache in options, keep logs). Second, watch the table for a day:
SELECT option_name, ROUND(LENGTH(option_value)/1024) AS kb
FROM wp_options
WHERE option_id > (SELECT MAX(option_id) - 500 FROM wp_options)
ORDER BY option_id DESC;
The newest 500 rows show you what’s being created right now and how fast. If one prefix owns most of them, you’ve found it. For cron bloat, wp cron event list shows duplicate scheduled events; a plugin that schedules a job on every page load without checking whether one exists will do that.
The fix is at the source: a setting, an update (this class of bug gets fixed), a replacement, or a support ticket to the author with the query above attached. Only after the source is fixed does cleaning stick.
Step 06 Stop autoloading what’s big and legitimate
A 300 KB options blob from a page builder is real data; it just doesn’t need to ride along on every request. Since WordPress 6.6, options can carry autoload = 'auto' and core decides: anything over 150 KB is left out of the autoload set automatically. Older rows still say yes, so flip the large ones yourself:
UPDATE wp_options SET autoload = 'no'
WHERE autoload IN ('yes', 'on')
AND LENGTH(option_value) > 150000
AND option_name NOT IN ('rewrite_rules', 'cron', 'wp_user_roles');
Then load the front end, wp-admin, and a page that uses the blob (a builder-edited page) and watch Query Monitor for a new query fetching that option. One extra query on the pages that need it is a good trade for zero bytes on the pages that don’t. If a plugin resets the row to yes on its next save, that’s a one-line report to its author.
Step 07 Keep it that way
- Move cron off page loads (
DISABLE_WP_CRON+ a server cron every five minutes) so expired transients and scheduled cleanups actually run. - Add a persistent object cache (Redis or Memcached). The autoload query still happens, but the result is served from memory instead of MySQL, which turns a 40 ms query into a fraction of a millisecond.
- Run the step 01 query monthly. It’s the number that would have caught most of the slow sites I’ve opened, years before anyone noticed. It’s also one of the lines on the monthly health report I send to care-plan clients, for exactly that reason.
- Check Site Health after every plugin install. The autoload warning there is blunt, but it’s free.
If the list in step 02 is a page long and nothing on it has a name you recognise, that’s the point where measuring gets faster than guessing, and it’s the first hour of the performance work I do.
Still slow? Then fixed.
We measure your real traffic, tell you which of these causes you actually have, and quote a fixed price to fix them.