Skip to content
System nominalUTC --:--:--Articles 041

The wp_options Autoload Flag, and the Rows That Tax Every Request

Autoloaded options are read on every uncached request, not only on admin screens. Here is the query that finds the expensive rows and what to do with each kind.

Martin Kiel · @mkiel

WordPress internals

Topic
PERF
Section
Performance
Published
18 SEP 2026
Reading time
7 MIN
Words
1796W
Technical cover graphic for The wp_options Autoload Flag, and the Rows That Tax Every Request
Generated schematic — PERF series, REV 2.6.

A store owner reports that pages got slower "at some point in the last few months". Nothing appears in the slow query log. The database CPU graph is flat. The host has already been asked to increase PHP memory, twice. What actually happened is that one plugin started writing a serialised array of every product attribute into a single option, and that option is marked autoload = yes.

The query itself takes under two milliseconds. It returns roughly 900 KB. And it runs on every request that does not already have a warm alloptions cache entry, which on a shared host without a persistent object cache means every request, forever.

This is one of the few WordPress performance problems that is genuinely cheap to find and genuinely annoying to fix, because the fix usually lives in somebody else's plugin.

What autoload actually controls

The wp_options table has an autoload column. When WordPress boots, it calls wp_load_alloptions(), which runs a single query selecting every option whose autoload flag is on. The whole result set is then stored in the object cache under the key alloptions in the options group.

// wp-includes/option.php, condensed
$alloptions = wp_cache_get( 'alloptions', 'options' );

if ( ! is_array( $alloptions ) ) {
    $alloptions = $wpdb->get_results(
        "SELECT option_name, option_value FROM $wpdb->options WHERE autoload = 'on'"
    );
    wp_cache_add( 'alloptions', $alloptions, 'options' );
}

Three consequences follow, and they explain most of the confusion around this flag.

It is one cache entry, not one entry per option. Miss the alloptions key and you pay for the entire set at once. A 900 KB row is not a 900 KB problem for the request that needs it; it is a 900 KB problem for every request, because the blob is read and unserialised as a unit.

It is front-end work. Autoloaded options are loaded during wp-settings.php, long before WordPress knows whether the request is a page view, an admin screen or a wp-json call. There is no route on which you can avoid it.

Writing to an autoloaded option invalidates the whole entry. Any update_option() on an autoloaded row forces the next request to rebuild alloptions from scratch. A plugin that updates a counter on every page view and autoloads that counter has quietly guaranteed that no request ever reuses the cache.

Finding the offenders with one query

You do not need a profiler for the first pass. You need the twenty largest autoloaded rows. WordPress 6.6 reworked the column from a two-value flag into a richer set that distinguishes "explicitly on" from "decided automatically", so the predicate depends on your version.

-- WordPress 6.6 and newer
SELECT option_name,
       LENGTH(option_value) AS bytes,
       autoload
FROM wp_options
WHERE autoload IN ( 'on', 'auto-on', 'auto' )
ORDER BY bytes DESC
LIMIT 20;

-- WordPress 6.5 and older
SELECT option_name,
       LENGTH(option_value) AS bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY bytes DESC
LIMIT 20;

On a healthy install the tail of that list is a few kilobytes of rewrite rules. On a sick one the top entry is hundreds of kilobytes and it belongs to a plugin whose settings page you have opened exactly once.

Two supporting numbers make the report easier to act on. The total size of the autoloaded set tells you how much is being read per request; the row count tells you whether the problem is one whale or four hundred minnows.

SELECT COUNT(*) AS rows_autoloaded,
       SUM(LENGTH(option_value)) AS bytes_autoloaded
FROM wp_options
WHERE autoload IN ( 'on', 'auto-on', 'auto' );
Total autoloadedTypical causeWhere to look
Under 100 KBNormal. Rewrite rules, cron array, a handful of settings.Nowhere.
100–500 KBOne plugin storing its full settings object, including defaults it never reads.Rows over 50 KB.
500 KB – 2 MBA cache written as an option: menu structures, term lookups, an export of product data.Names ending in _cache, _data, _index.
Over 2 MBSomething is writing a table into a single row. It will eventually fail on write, not on read.Everything, urgently.

Four classes of accidental autoload, and how each one ends

Sorting the offenders by name is usually enough to decide what to do, because almost everything that should not be autoloaded falls into one of four groups.

Plugin caches written through the options API. The symptom is a pair of rows, one holding a payload and one holding a timestamp, often with names like myplugin_product_cache. Turning autoload off is safe here: the plugin reads the row by name, so it does not care whether the value arrives in the alloptions blob. This is the easiest win and it is usually the largest one.

Transients that were never given an expiry. WordPress stores non-expiring transients as ordinary options, which means they accumulate permanently and never appear in the expired-row cleanup. They are written with autoload disabled, so they do not inflate alloptions, but they do grow the table indefinitely and they will show up in any size-ordered audit. If a transient has no expiry and no code path that reads it, delete it.

Stale rewrite rules. The rewrite_rules row is legitimately large and legitimately autoloaded. The problem is the stale variant: a plugin that flushes rewrites on every admin page load writes the row constantly and invalidates alloptions each time. If rewrite_rules appears at the top of your size list and the site is not a multisite with thousands of pages, look for an unconditional flush_rewrite_rules() call. That function belongs in an activation hook and nowhere else.

Cost warning

flush_rewrite_rules() on every request is worse than the bloat it is usually blamed for. It rewrites a row that every subsequent request reads, so it taxes traffic twice. Audit for it before you audit for size.

Duplicated settings arrays. A plugin with a settings screen may store each field as its own option, or the whole form as a single serialised array. The second pattern is not wrong, but it means a plugin with forty fields and a hundred defaults is holding all hundred and forty values in the autoload blob whether the request reads any of them. If the plugin only needs two of those values on the front end, the whole array has no business being autoloaded.

Changing the flag without breaking the plugin

The removal is a one-liner. The reason it is not the end of the article is that plugins frequently re-assert the flag the next time their settings screen is saved.

$value = get_option( 'myplugin_product_cache', array() );

// Third argument is autoload. Passing false sets it off explicitly.
update_option( 'myplugin_product_cache', $value, false );

Verify the change landed, and watch whether it survives a trip through the plugin's own admin screen:

wp option get myplugin_product_cache --format=json | wc -c
wp db query "SELECT autoload FROM wp_options WHERE option_name='myplugin_product_cache';"

If the flag comes back, you have three options, in increasing order of durability. The first is to leave it off and accept that a settings save will flip it back, which is fine for a row that is written once a quarter and wrong for a row that is written hourly. The second is to patch the plugin where it writes, replacing a bare update_option( $key, $value ) with an explicit false. WordPress 6.7 added wp_set_option_autoload(), which does the same thing in one call when you are on a recent version.

The third option survives plugin updates: intercept the write from a small must-use plugin of your own and correct the column directly.

// wp-content/mu-plugins/autoload-guard.php
add_action( 'updated_option', function ( $option ) {
    global $wpdb;

    if ( 'myplugin_product_cache' !== $option ) {
        return;
    }

    $wpdb->update(
        $wpdb->options,
        array( 'autoload' => 'off' ),
        array( 'option_name' => $option )
    );

    // The blob every request reads is now stale.
    wp_cache_delete( 'alloptions', 'options' );
}, 10, 1 );

That snippet reaches past the options API, and it should be treated as the hack it is. The honest sequence is: measure, remove the flag, confirm the plugin still works, then open an issue upstream so the fix lives where it belongs. A must-use guard is a stopgap for a plugin you cannot wait on.

Where this advice stops working

Turning autoload off is not free. It converts one query per request into one query per request per option, because each subsequent get_option() on a non-autoloaded row is now a separate lookup. On a page that reads one such row, you have traded a single wide read for a single narrow one and come out ahead. On a page that reads thirty of them, you have made things worse.

The rule that follows: an option belongs in the autoload set if the request path reads it on essentially every request and it is small. Everything else does not, no matter how convenient the current arrangement is.

Two cases deserve a different answer entirely. If the data is genuinely needed on every request and it is large, the options table is the wrong home for it and a custom table with a narrow query is the right one. And if you have a persistent object cache, none of this matters until the cache is cold, which is exactly when it matters most: a 900 KB autoload blob turns a cache flush into a stampede, because every concurrent request tries to rebuild the same entry at once.

Multisite adds a multiplier that is easy to miss. Options are stored per site, so the autoloaded set is per site as well, and wp_load_alloptions() runs against whichever site is current. A network of two hundred sites running a plugin with a 200 KB autoloaded option is holding forty megabytes of settings that almost no request reads. The audit query has to be run inside each site's context, which is what makes the WP-CLI loop in the multisite article worth writing down rather than improvising on the day.

One more asymmetry is worth internalising before you start deleting flags. Removing autoload is reversible in seconds and costs nothing. Adding it back is equally cheap. There is no reason to deliberate for a week: demote the row, measure the effect with the object cache flushed, and put it back if the trade was wrong.

What to do on Monday

  1. Run the size query and paste the top twenty rows into a ticket. Names first, sizes second.
  2. Classify each row into one of the four groups above. Do not skip this; the classification determines whether removal is safe.
  3. Remove autoload from the plugin caches and the stale rewrite rules. Expect the largest gains here.
  4. Delete orphaned non-expiring transients rather than demoting them.
  5. Re-measure total autoloaded bytes and uncached TTFB with the object cache flushed, before and after.
  6. File upstream issues for anything you had to patch, with the byte count attached. A number in a bug report gets fixed; an adjective does not.

Martin Kiel · @mkiel

WordPress internals

Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.

Continue

Related in Performance