Why wp_options Grows Unbounded When update_option() Runs in a Loop — and How to Find the Autoloaded Rows Slowing Your Admin

Slow admin pages rarely announce themselves as an options problem. They arrive as a two-second delay on wp-admin/edit.php, a dashboard that hangs before the widgets paint, and a wp_options table that has quietly become the largest thing in the database. The usual suspect is a plugin calling update_option() inside a loop. That phrase describes two different failures, and they need different diagnostics.

Symptom

You have a self-maintained install. Admin pages are slow, the front end is tolerable, and wp_options is bloated. Someone on the team says “the plugin calls update_option() in a loop.” That is not yet a diagnosis. It is a description of a code shape that can produce two distinct outcomes:

  • Mode A — row-count growth. The loop iterates over distinct option names. Each new name becomes a new row, because update_option() creates the option if it does not exist. The table grows in width, not depth.
  • Mode B — payload growth. The loop iterates over one option name, writing an accumulating value. Row count stays flat. The stored value grows, and if that option is autoloaded, every request pays for it.

Only mode B, combined with autoload, directly inflates the payload that wp_load_alloptions() pulls on every request. Mode A can still matter — thousands of rows are not free — but row count alone is a misleading metric. A table with thousands of non-autoloaded rows can be harmless to page load, while a single large autoloaded row can dominate request cost. Triage should measure autoloaded bytes first, then row count.

Trace

Start with what core actually does. The update_option() reference states plainly: “If the option does not exist, it will be created.” That is the mechanism behind mode A. A loop over unique keys — say, one option per post, per user, or per cron event — will insert a row for every key it has not seen before. The function is not misbehaving; it is doing what its documentation says.

Mode B is subtler. The same option name is written repeatedly. The row count does not move. The option_value column grows. If the option was created with autoload enabled, or if the autoload heuristic decided to enable it, that growing value is part of the alloptions payload.

Here is the core path that matters. wp_load_alloptions() is documented as loading and caching “all autoloaded options, if available or all options.” Its source queries the options table filtering on the set of autoload values returned by wp_autoload_values_to_autoload():

$alloptions_db = $wpdb->get_results( "SELECT option_name, option_value FROM $wpdb->options WHERE autoload IN ( '" . implode( "', '", esc_sql( wp_autoload_values_to_autoload() ) ) . "' )" );

Note the fallback in the same function: if that query returns nothing, core selects all options. A diagnostic that assumes wp_load_alloptions() output is strictly “autoloaded only” can misattribute non-autoloaded rows. Keep that in mind before you blame a row you found through that function.

Why does this matter per request? The WordPress optimization guide is direct: “your site’s options data needs to be available for each page view. Without a persistent object cache, your web server must read those options from the database to handle every page view.” It goes on to say those extra trips “slow down your web server’s response times such as ‘Time to first byte’ (TTFB) and can quickly overwhelm your database server during traffic spikes.” The autoload payload is not a background concern; it is on the critical path of every admin page load.

The update_option() reference also carries the warning that matters for prevention: “Autoloading too many options can lead to performance problems, especially if the options are not frequently used.” And it gives the rule: options accessed across several places in the frontend should autoload; options accessed only on a few specific URLs should not. The add_option() reference repeats the same warning and the same recommendation to pass false for options read on few specific URLs.

Root cause

The root cause is not “a loop.” It is a loop whose key strategy and value strategy were never checked against the autoload contract.

Mode A root cause: the loop uses variable option names. Each iteration writes a new key. update_option() creates it. The table accumulates rows that may never be read again, and if the autoload heuristic or an explicit true marked them autoloaded, they join the alloptions payload permanently.

Mode B root cause: the loop uses a constant option name and an accumulating value — appending to an array, concatenating a log, storing a growing serialized structure. The row count is stable, which is why this mode hides from table-size checks. The payload grows without bound, and if the option is autoloaded, the cost is paid on every request.

There is a third trap in remediation. The update_option() reference states: “For existing options, $autoload can only be updated using update_option() if $value is also changed.” A cleanup script that calls update_option( $name, $same_value, false ) will silently fail to flip autoload off, because the value did not change. The autoload column stays as it was. You will believe you fixed it and the admin will still be slow.

Fix

Work in this order. Measure autoloaded bytes first, rank the individual options second, verify against the database third, and only then look at row count.

Step 1 — total autoloaded bytes

Since WordPress 6.6.0, WP_Site_Health::get_autoloaded_options_size() calculates the total amount of autoloaded data and returns it in bytes. Its source sums the string length of each autoloaded value, serializing arrays and objects first:

$total_length += strlen( (string) $option_value );

Run it from WP-CLI:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

This gives you a number to compare against after remediation. It does not tell you which option is responsible. That is the next step.

Step 2 — rank autoloaded options by value length

wp_load_alloptions() is the right enumeration primitive because it mirrors what core loads. Sort the result by serialized length and print the top offenders:

wp eval '
$all = wp_load_alloptions();
$rows = array();
foreach ( $all as $name => $value ) {
    $rows[ $name ] = strlen( (string) maybe_serialize( $value ) );
}
arsort( $rows );
$i = 0;
foreach ( $rows as $name => $len ) {
    printf( "%10d  %s\n", $len, $name );
    if ( ++$i >= 25 ) { break; }
}
'

If a single option dominates the list, you are looking at mode B. If the list is long and flat, you are looking at mode A with autoload enabled across many small keys.

Step 3 — verify against the database

The alloptions cache can be stale, and the fallback path in wp_load_alloptions() can return non-autoloaded rows. Confirm with direct SQL filtered on the autoload values core treats as autoloaded. The $wpdb class documentation notes that $wpdb can read from any table in the WordPress database, not just core tables, so this is a supported diagnostic path:

SELECT option_name,
       LENGTH(option_value) AS bytes,
       autoload
FROM wp_options
WHERE autoload IN ( 'yes', 'on', 'auto-on', 'auto' )
ORDER BY bytes DESC
LIMIT 25;

Adjust the autoload value list to match what wp_autoload_values_to_autoload() returns on your version. The point is to query the table directly rather than trusting a cached array. If you want to run it through WordPress, wpdb::query() returns the number of rows affected or selected for non-DDL queries and false on error, so you can check the result before iterating.

Step 4 — row count, last

Only after the autoload picture is clear should you look at row count:

SELECT COUNT(*) AS total_rows,
       SUM(autoload IN ( 'yes', 'on', 'auto-on', 'auto' )) AS autoloaded_rows
FROM wp_options;

A large total with a small autoloaded count is a different problem from a small total with a large autoloaded count. Treat them separately.

Step 5 — flip autoload correctly

Because update_option() only changes the autoload flag when the value also changes, a remediation that passes the same value will not work. Two safe options:

  • Pass a genuinely changed value along with false, then verify the autoload column afterward.
  • Use wp_set_option_autoload_values() where it fits your version and workflow, and verify the column afterward either way.

Do not assume the flag flipped. Query it:

SELECT option_name, autoload FROM wp_options WHERE option_name = 'your_option_name';

If the row still shows an autoload value in the autoloaded set, the change did not take.

Step 6 — fix the code, not just the data

Cleaning the table without fixing the loop means the table refills. The code review question is simple: in any loop that writes options, is the key constant or variable?

  • Variable key: you are in mode A. Ask whether each key needs to be a separate option at all. Often a single option holding an array, or a custom table, is the correct shape.
  • Constant key with growing value: you are in mode B. Ask whether the value needs to grow without bound. Logs, histories, and append-only structures belong in a custom table, not in an autoloaded option.
  • Autoload argument: make it explicit. The documentation recommends true for options read across several places and false for options read only on specific URLs. Leaving it to the heuristic is how options you never intended to autoload end up in the alloptions payload.

Verification

Re-run the measurement from step 1 and compare. Then confirm the specific rows changed:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

And confirm the autoload column for the options you touched:

SELECT option_name, autoload, LENGTH(option_value) AS bytes
FROM wp_options
WHERE option_name IN ( 'option_a', 'option_b' );

If the byte total dropped and the autoload column shows a non-autoloaded value, the fix took. If the byte total is unchanged, the most likely cause is the update_option() autoload caveat: the value did not change, so the flag did not change either.

For a related failure mode on fresh installs — where the symptom is “Nothing Found” rather than slow admin — see What to Fix First When a New WordPress Site Says Nothing Found. It is a different trace, but the same discipline applies: read the core path before installing around it.

FAQ

Does update_option() in a loop always bloat wp_options?

No. The documentation does not say the loop itself is a bug. The outcome depends on key strategy and value growth. A loop over a constant key with a bounded value is fine. A loop over variable keys grows row count. A loop over a constant key with an unbounded value grows payload. Only the payload case, combined with autoload, directly inflates the alloptions load.

Why did my cleanup script not turn autoload off?

Because update_option() only updates the autoload flag for an existing option when the value also changes. If you passed the same value with false, the function returned without touching the autoload column. Verify with a direct SELECT on the autoload column.

Is wp_load_alloptions() output always autoloaded options only?

Not guaranteed. The function is documented as loading autoloaded options “if available or all options,” and its source falls back to selecting all options when the autoload-filtered query returns nothing. Treat its output as a diagnostic aid, not as an authoritative autoload manifest, and confirm against the database.

What is the right order to triage this?

Total autoloaded bytes first, per-option length ranking second, direct SQL against the options table filtered by autoload values third, row count last. Row count alone will mislead you in both directions.

Where do I find the autoload values core treats as autoloaded?

They are returned by wp_autoload_values_to_autoload(), which wp_load_alloptions() uses in its query. Read that function on your installed version rather than hardcoding a list from a tutorial; the accepted values have changed across releases, and the update_option() changelog notes that 'yes' and 'no' were deprecated in 6.7.0.