WordPress Autoloaded Options: Audit and Trim Safely

WordPress Autoloaded Options: Audit and Trim Safely

WordPress autoloaded options are the rows in wp_options that get pulled into memory on every single request, front end and admin alike, whether the page needs them or not. When Site Health calls them out, the fix is almost never “delete them” — it is to leave the data alone and turn autoload off for the handful of rows that are carrying the weight.

The number that matters is the total serialized size of everything marked for autoload. WordPress 6.6 added a Site Health test that goes critical past 800,000 bytes with the label “Autoloaded options could affect performance”. That threshold is a prompt to look, not a cliff edge: a 900 KB total made of one abandoned plugin’s cache row is a five-minute fix, while 700 KB spread evenly across forty active plugins may be nothing you can do much about.

What WordPress autoloaded options actually are

Every row in wp_options has an autoload column. On boot, wp_load_alloptions() runs a single query that selects option_name and option_value for every row whose autoload value is in the set returned by wp_autoload_values_to_autoload(), then caches the whole lot under the cache key alloptions in the options group. After that, get_option() for any of those names is an array lookup rather than a query.

So one query is not the cost. The cost is everything else: transferring the payload from MySQL, unserializing it into PHP memory, and — if you have a persistent object cache — fetching and unserializing that same blob from the cache on every request. A 2 MB alloptions array is 2 MB of work your site does before it renders a single character, repeated for every uncached hit.

WordPress 6.6 reworked what the column can hold, and because there is no upgrade routine, an older site will have a mix of old and new values sitting side by side:

ValueMeaningAutoloaded?
onExplicitly set to autoload by whoever wrote the optionYes
offExplicitly set not to autoloadNo
autoNo explicit value given — WordPress decidesYes, under current defaults
auto-onWordPress decided, dynamically, that it should autoloadYes
auto-offWordPress decided it should not — typically because the value is largeNo
yes / noLegacy values from before 6.6, deprecated in 6.7 but still honouredyes only
Values and behaviour per the Options API change published on make.wordpress.org. There is no migration of old rows, so yes and no will persist on established sites indefinitely.

The auto-off value is where the dynamic part lives. Since 6.6, an option added or updated without an explicit true for autoload is not autoloaded once its value passes roughly 150,000 bytes, which the wp_max_autoloaded_option_size filter controls. That is a guard rail for new data only. It does nothing about the row a plugin wrote in 2021 with an explicit yes, which is usually the row you are hunting.

Measure before you change anything

Three ways to get the number, in descending order of how much I trust them.

WP-CLI, if you have shell access

# Total bytes of everything currently autoloaded
wp option list --autoload=on --format=total_bytes

# The twenty heaviest autoloaded rows, largest last
wp option list --autoload=on --fields=option_name,size_bytes \
  | sort -n -k 2 | tail -20

Note the sort pipe. wp option list --orderby accepts only option_id, option_name and option_value — there is no --orderby=size_bytes, and piping through sort -n is the approach WP-CLI’s own documentation uses. The --autoload=on filter matches autoloaded rows regardless of whether they say on, yes or auto, so you do not need to enumerate the values yourself.

SQL, if all you have is phpMyAdmin

-- Total autoloaded payload in kilobytes
SELECT ROUND( SUM( LENGTH( option_value ) ) / 1024, 1 ) AS autoload_kb
FROM wp_options
WHERE autoload IN ( 'yes', 'on', 'auto', 'auto-on' );

-- The worst offenders
SELECT option_name,
       ROUND( LENGTH( option_value ) / 1024, 1 ) AS size_kb,
       autoload
FROM wp_options
WHERE autoload IN ( 'yes', 'on', 'auto', 'auto-on' )
ORDER BY LENGTH( option_value ) DESC
LIMIT 25;

Swap wp_ for your own table prefix. Include all four values in that IN list — a query that checks only autoload = 'yes' will under-report on any site that has written options since 6.6, which is every site.

Site Health, if you have neither

Go to Tools → Site Health → Status. Under 800,000 bytes you get a passing result reading “Autoloaded options are acceptable”; over it, the test returns a critical status, the “Autoloaded options could affect performance” label, and the row count and total size. If you work on client sites, reading the rest of the Site Health panel properly is worth the ten minutes — this is one test among many that people scroll past.

Moving the threshold is legitimate, occasionally

The 800,000-byte limit is filterable through site_status_autoloaded_options_size_limit, with companion filters site_status_autoloaded_options_limit_description and site_status_autoloaded_options_action_to_perform for the wording. Raising it is defensible on a large multisite where you have already audited the total and know it is legitimately 1.2 MB of real settings. Raising it so a client stops asking about a red box is how you end up at 4 MB two years later.

What is usually taking up the space

Run the top-25 query and you will almost certainly recognise something from this list. The sizes are not the point — the pattern in the option name tells you what wrote it and therefore what is safe to do.

What you seeWhere it came fromWhat to do
_transient_* rows with no matching _transient_timeout_*set_transient() called with no expiry. Non-expiring transients are autoloaded by design; transients with an expiry are notLeave the data, disable autoload. Deleting works too, but the plugin will simply rebuild it next request
Options prefixed for a plugin you removed months agoUninstall routines are optional, and most plugins do not write oneDelete, after you have confirmed the plugin folder is genuinely gone
theme_mods_* for themes you no longer runOne row per theme ever activated, kept so switching back restores your settingsDisable autoload. Keep the rows if you might switch back
rewrite_rulesCore, and it grows with every custom post type and taxonomy you registerLeave it alone entirely. It is autoloaded because almost every request needs it
cronCore’s scheduled-event array. Bloats when jobs pile up faster than they runDo not touch autoload. Fix the queue instead — the size is a symptom
Licence checks, update payloads, remote API responses stored as optionsCommercial plugins caching a vendor response and marking it autoloadDisable autoload. It is needed on an update screen, not on a product page
Multi-megabyte log or history arraysA plugin using wp_options as a table it should have createdDisable autoload, then ask whether you want the plugin
Transient autoload behaviour is per core’s set_transient() source; the rest is from auditing production sites.

One row deserves a special mention because it panics people: core_updater.lock. It is tiny, it is temporary, and it has nothing to do with this. If you found it while poking around and your updates are stuck, that is the “another update is currently in progress” problem, not an autoload problem.

How to disable autoload without corrupting the value

Since WordPress 6.4 there has been a function for exactly this, and it changes the autoload column without reading, re-encoding or rewriting option_value:

<?php
// One option. Returns true if the autoload value changed.
wp_set_option_autoload( 'some_plugin_cached_feed', false );

// Several at once. Returns an array keyed by option name.
wp_set_option_autoload_values( array(
	'some_plugin_cached_feed'   => false,
	'some_plugin_license_check' => false,
	'theme_mods_twentytwenty'   => false,
) );

Pass booleans. The string values 'yes' and 'no' are still accepted but were deprecated in 6.7, so there is no reason to write new code with them.

From the shell, run that through wp eval rather than reaching for wp option update:

wp eval 'var_dump( wp_set_option_autoload( "some_plugin_cached_feed", false ) );'

Why not wp option update --autoload=off

wp option update does accept --autoload=on|off|yes|no, but it is a command for writing a value. Omit the value and it reads from STDIN, so the command appears to hang. Supply one with $(wp option get ...) and you have round-tripped a serialized array through plaintext, which is a real way to corrupt a settings row. wp_set_option_autoload() touches only the column you want changed.

If you truly only have database access, the UPDATE is one line — but the object cache will keep serving the old alloptions array until it is cleared, so the change will look like it did nothing:

UPDATE wp_options SET autoload = 'off'
WHERE option_name = 'some_plugin_cached_feed';

-- then, from the shell:
-- wp cache flush

Take a database export first. Not a backup you took — one you have restored somewhere and looked at. And if a careless edit here does take the site down with a database error, the database connection error walkthrough and enabling debugging to read the log will get you further than guessing.

Disable autoload or delete the row?

These are different operations with different risk profiles, and conflating them is how people break sites while “optimising” them. Disabling autoload is reversible, costs you one extra query on the requests that genuinely need the option, and cannot lose data. Deleting is permanent and can lose settings a plugin has no way to rebuild.

Is the plugin still installed? If yes, never delete. Disable autoload and move on. A plugin that calls get_option() and gets false back will either rebuild the row or behave as though it was never configured.
Is it needed on front-end requests, or only in wp-admin? Licence state, update payloads, importer progress and admin notices are admin-only by nature. Those are the clearest candidates for off — you pay one query on the few screens that read them.
Is it core’s? rewrite_rules, cron, active_plugins, stylesheet, template and the _site_transient_update_* family are autoloaded because core reads them early. Leave them. If cron is huge, the queue is the problem.
Can you name what wrote it? If you cannot, do not delete it. Disable autoload, note the date, and look again in a month. An option you cannot identify that is not autoloaded is costing you nothing.
Does disabling it actually help? Changing autoload on a 400-byte row to tidy up a list achieves nothing measurable. Work top-down from the heaviest rows and stop when the remainder are noise.

Do not disable autoload on everything

There is a trap at the bottom of this. wp_load_alloptions() has a fallback: if the autoload query returns no rows at all, it loads every option in the table instead. Strip autoload from the lot and you do not get a lean boot, you get the entire wp_options table in memory on every request — the exact opposite of the goal.

Clear expired transients while you are in there

Expired transients are not autoloaded, so they do not show up in your autoload total. They still bloat the table, slow down the queries that scan it, and get copied into every backup you take. Two commands:

# Safe: removes only transients whose timeout has passed
wp transient delete --expired

# On multisite, site transients are stored separately
wp transient delete --expired --network

wp transient delete --all also exists and I would not run it on a busy production site mid-morning. Every plugin that was relying on a cached value now rebuilds it on the next request that needs it, which can mean a burst of remote HTTP calls and database work arriving all at once. Run it when traffic is low, or stick to --expired.

A persistent object cache changes this picture: with Redis or Memcached in place, transients are stored in the cache rather than in wp_options, and there is nothing in the table to clean. If you are not sure which you have, Site Health tells you under Tools → Site Health → Info → Server.

When autoload bloat is not your real problem

Being honest about this saves you an afternoon. Shaving 300 KB off alloptions is a real improvement to time-to-first-byte on uncached requests, measurable in tens of milliseconds on a decent host. It is not going to rescue a site that is slow for a different reason.

A slow admin is usually several things at once. Autoload bloat is one of the four causes I check, alongside the Heartbeat API hammering admin-ajax.php, a jammed WP-Cron queue, and a single plugin running expensive queries on every admin screen. Diagnosing a slow WordPress admin dashboard works through all four in order, which is the right sequence if your complaint is “wp-admin is unusable” rather than “Site Health has a red box”.

It will not move your Core Web Vitals much. Autoload cost is server-side work that lands in TTFB. If your problem is interaction latency, the bottleneck is JavaScript on the main thread, and diagnosing a failing INP score is where to spend the time instead. If page caching is serving most of your traffic, the front end barely pays the autoload cost at all — but every logged-in request and every uncacheable page still does.

There is one way this gets genuinely bad. Memcached’s default maximum item size is 1 MB, set by -I / --max-item-size. An alloptions array larger than that cannot be stored, so the set silently fails, every request misses the cache, and your “optimised” site queries and unserializes the whole thing from MySQL every time — while the dashboard cheerfully reports a persistent object cache in use. Crossing 1 MB of autoloaded options on a Memcached host is not a slow degradation, it is a step change. Redis does not have the same low ceiling, but the unserialization cost is still real.

If one plugin owns most of the total, the plugin is the decision. I have seen a single slider plugin account for 1.4 MB of autoloaded options. You can disable autoload on its rows every quarter, or you can replace it. Work out which plugin is responsible first — the step-by-step plugin conflict process is the same deactivate-and-measure discipline applied to a different symptom.

A repeatable 15-minute audit

  1. Export the database, and confirm the export is restorable.
  2. Record the starting number: wp option list --autoload=on --format=total_bytes.
  3. List the top 20 rows and write down which plugin owns each one. Anything you cannot attribute, leave alone for now.
  4. Delete only rows belonging to plugins that are no longer installed.
  5. For everything else over about 50 KB that is clearly admin-only, call wp_set_option_autoload_values() once with the whole batch.
  6. wp transient delete --expired, then wp cache flush.
  7. Re-run the total, then click through the front end, the editor, and the settings screen of every plugin you touched. Autoload changes break things silently, not loudly.
  8. Note the date and the new figure somewhere you will find it. This grows back.

Step 7 is the one people skip and the only one that catches a mistake. A plugin whose licence row you set to off may read that option in a context where an extra query is fine, or it may have been written on the assumption that the value is always already in memory. You find out by looking, not by reasoning about it.

Frequently asked questions

What is a good size for autoloaded options in WordPress?

Core’s Site Health test passes below 800,000 bytes and reports a critical status above it, so that is the number to aim under. In practice a lean site sits between 200 KB and 500 KB, a site with thirty active plugins will be higher, and anything approaching 1 MB deserves an audit — particularly on a Memcached host, where 1 MB is the default item size ceiling.

Is it safe to set autoload to off for an option?

Usually, and it is reversible either way — the value is untouched, so get_option() still returns it, just with one extra query. The risk is not data loss but behaviour: code written on the assumption that an option is always in memory can perform worse, or trip over an unexpected query during an early hook. Change the heavy rows, test the affected screens, and keep a record of what you changed.

Why did my autoloaded options total go back up?

Because a plugin wrote the option again. update_option() on an existing row does not reset autoload, but a plugin that deletes and re-adds its row — which many do on update or on settings save — gets core’s default autoload behaviour back. If a specific option keeps returning, that is a bug report for the plugin author, not something you can fix permanently from the database.

Do I need a plugin to clean up autoloaded options?

No. Everything here is core functions, two WP-CLI commands and a SELECT statement. A database optimisation plugin can show you the same list in a nicer table, which is genuinely useful if you have no shell access, but installing a plugin that runs on every request to inspect a per-request cost is a trade worth thinking about. Whatever you use, make the judgement call about each row yourself — no tool knows which of your options are admin-only.

Does a persistent object cache make autoloaded options irrelevant?

It moves the cost rather than removing it. The MySQL query goes away, but the alloptions blob still has to be fetched from Redis or Memcached and unserialized into PHP memory on every request. And on Memcached, an array over the 1 MB default item limit cannot be cached at all, which turns a cache into a per-request database read with extra steps.

Why does my autoload column say yes instead of on?

Because the row predates WordPress 6.6 and core deliberately shipped no upgrade routine for the column. yes and no are still honoured as on and off, so a mixed table is normal and not a sign of corruption. It only matters when you write SQL by hand — check for all four autoloading values, not just yes.

Sources

Leave a Reply to This Post