Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 06 25 MIN

Hooks and the data layer

Options, Transients, and What Belongs Where

What belongs in wp_options, what belongs in a transient, and what belongs in neither, plus the queries that find the rows costing you every request.

Somewhere on a site you maintain, a plugin stores a list of every product ID it has ever synced in a single option. The list grows, nothing ever prunes it, and the site's uncached response time creeps up month by month. Nobody connects the two, because the plugin is not on the front end and the option is not large enough to look like a problem.

WordPress gives you two generic stores and no pressure to use either well. wp_options takes anything you hand it and never complains. set_transient() looks like a cache, so it feels cost-free. Both are rows in the same table, both are read through the same option cache, and one of them is read on every single request whether you asked for it or not.

Autoload is a promise to read the value on every request

The wp_options table has an autoload column. When a request starts, wp_load_alloptions() issues one query for every row whose autoload value is in the autoload list, unserialises them, and puts the whole set in the alloptions object cache bucket. From then on get_option() answers from memory for anything in that set. An option outside the set costs one query the first time it is read and nothing after.

That is the whole trade. Autoloaded rows are paid for once per request even when nobody reads them. Non-autoloaded rows are paid for only when something reads them. A settings option that every page view needs earns its place. A list of synced IDs, or a payload only the settings screen reads, does not.

SELECT COUNT(*) AS rows_autoloaded, SUM(LENGTH(option_value)) AS bytes
  FROM wp_options
 WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');

On a stock WordPress 7.0.7 install with the default theme and no plugins, that returns 117 rows and 37,613 bytes of payload. On the same install with five options of 100 KB each added with autoload enabled, the query returned 122 rows and 549,613 bytes. Five more rows, and half a megabyte more to fetch on every uncached request.

SetupAutoloaded rowsPayloadQuery and fetch
Clean 7.0.7 install 117 37,613 bytes 0.86 ms
Plus five 100 KB autoloaded options 122 549,613 bytes 1.13 ms
Same rows moved out of autoload 117 37,613 bytes 0.87 ms

The timings are from one MySQL 9.6 server on the same machine with the cache cold, so treat the milliseconds as an observation rather than a law. The payload column is the number that transfers on a busy site once per uncached request, and on a site behind a persistent object cache it is the size of an entry pushed across the network instead of built locally.

One thing that changed recently is worth knowing because it affects the audit. Autoload is no longer a boolean in the database. WordPress 6.6 introduced auto-on, auto-off and auto, and the default for an option created without an explicit autoload argument is auto, which the loader treats as on. If you reuse a query from an older article that filters on autoload = 'yes', you will undercount. Use the list the loader uses, or call wp_autoload_values_to_autoload() and build the IN list from it.

Options and transients differ only in what happens at expiry

Transients feel like a different store. They are not. Without a persistent object cache, set_transient() writes one or two rows into wp_options: _transient_name for the value, and _transient_timeout_name for the expiry timestamp when you set one.

  • With an expiry, the timeout row and the value row are both written with autoload off, so the value sits outside the autoload set.
  • Without an expiry, only the value row is written, and it is written with autoload on. The value is not just an option, it is an option loaded on every request.

So set_transient( 'wpsifero_recent', $data ) with no third argument is a worse version of update_option(): same storage, same autoload behaviour, plus a naming convention that hides it from anyone auditing the table by option name.

The eviction story is the other half, and it differs by environment.

Row with a timeout expiredValue row with no timeout row
Without a persistent object cache Deleted on the next read, along with the timeout row Never expires and stays forever
With a persistent object cache Expiry is the cache backend's business; the entry drops out on its own Kept in the option table, autoloaded, and still read on every request

I checked the first row by manufacturing an expired transient directly in the table, then calling get_transient(): the read returned false and both rows were gone. The second I checked by inserting a value row with autoload on and no timeout row: get_transient() returned the 50 KB payload and the row was still there afterwards, still autoloaded, still in the alloptions set. Nothing cleans up a transient that has no timeout, because nothing knows it was supposed to be temporary.

Two habits follow. Always pass an expiration, and pick one that reflects how stale the data is allowed to be rather than a round number. And when you delete a transient, remember that without an object cache you are deleting two option rows, which is why delete_transient() exists and why dropping only the value row leaves an orphan timeout behind.

update_option is safe on a hot path, and it is also a silent no-op

Two facts about the write path decide where you can call it. The first is a guard that has been in core for years: update_option() compares the new value against the old one, and returns false without touching the database when they are equal. Comparing an array against itself costs microseconds; the query would cost a round trip. On my test install, 200 update_option() calls with an unchanged array value averaged 0.0098 ms each and issued zero writes.

The comparison is on serialised form, so it is stricter than ==. Two arrays with the same pairs in a different order are not equal by that test and do trigger a write. That is the right default for settings and the wrong shape for anything you rebuild in a different order each request.

The second fact concerns the autoload argument, and its behaviour depends on whether the option already exists. When the option is missing, update_option() hands off to add_option() and the argument is applied. When the option exists and the value has genuinely changed, the argument is applied too. When the option exists and the value is identical, the equality guard returns first and the flag is left alone. The argument is a preference about the row's state, not an instruction that always runs.

update_option( 'wps_verify_new',   'v', false );  // missing option  -> autoload off
update_option( 'wps_verify_new2',  'v' );         // missing option  -> autoload auto
update_option( 'wps_verify_ex',    'v2', false ); // changed value   -> autoload off
update_option( 'wps_verify_same',  $same, false); // same value      -> returns false, flag untouched

Calling update_option() on a hot path is fine when the value changes rarely and you are not sure whether it changed, because the cost of the no-op is an in-memory comparison. It is not fine when the value changes on every request: every request then writes a row and invalidates the options cache, and on a site with a persistent object cache that means pushing the whole alloptions entry across the network again. Store counters in the object cache or in a custom table, not in an option.

The failure mode is a list that only grows

The shape to watch for is a plugin that treats one option as a collection. A list of product IDs, a queue of pending exports, a map of remote IDs to local IDs, a log of the last thousand sync events. It starts at a few hundred bytes, and every month it gets bigger, and because it is autoloaded the cost lands on every request including ones that never touch the plugin.

This failure is hard to see because nothing about it looks wrong in isolation. The option name is sensible. The plugin's code is not slow. The site is only 40 ms slower than it was last year, which nobody attributes to a database row. The symptom that finally gets attention is usually a database size warning, and by then the list has thousands of entries.

Three fixes, in order of preference. Keep the list out of options entirely and put it in a custom table, which the next lesson covers. Store it as a bounded value: if you only ever read the most recent hundred entries, cap the list at a hundred when you write it. Or keep the list but stop autoloading it, which is the smallest change and the one to reach for first when the data is genuinely small and read on one screen.

Audit the autoload set, then demote what does not belong

Run the ranking query first. It answers "which rows in the autoload set are large", which is the question the row count cannot answer.

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

Then pull the per-plugin totals, because a plugin with fifty 2 KB options is as much of a problem as one with a single 100 KB option, and harder to notice.

SELECT SUBSTRING_INDEX(option_name, '_', 1) AS prefix,
       COUNT(*) AS rows_autoloaded,
       SUM(LENGTH(option_value)) AS bytes
  FROM wp_options
 WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
 GROUP BY prefix
 ORDER BY bytes DESC
 LIMIT 20;

Now the demotion. Do not reach for update_option() with the same value, because the equality guard returns before the autoload argument is ever applied. The function that exists for exactly this job is wp_set_option_autoload( $option, $autoload ), which changes the flag and leaves the value alone.

// The value stays. Only the autoload flag moves.
$moved = wp_set_option_autoload( 'wpsifero_synced_ids', false );

// Batch version, for a whole plugin's worth of options at once.
wp_set_option_autoload_values( array(
    'wpsifero_synced_ids'   => false,
    'wpsifero_export_queue' => false,
    'wpsifero_api_cache'    => false,
) );
before:                           autoload=on
wp_set_option_autoload( ..., false)  -> true
after:                            autoload=off
value still readable:             100000 bytes
present in the alloptions set:    false

The reason to prefer this over delete and re-add is that it is one UPDATE that touches only the autoload column, and its cache handling is narrower than you might expect. Demoting removes the affected names from the cached alloptions array in place. Promoting deletes the whole entry and lets it rebuild. Demotion is the cheap direction, which is convenient, because it is the direction you will be going.

The limit of all of this is that autoloading is not the only cost an option has. Every get_option() for a non-autoloaded row is still a query, and a plugin that reads forty small options in a loop on every page view is doing forty queries. Moving those rows out of autoload makes the numbers look better and the site slower. If a group of settings is only ever read together, keep it in one option, autoloaded, and read it once.

Lab 25 minutes

On a local WordPress 7.0.7 install, run the two audit queries and record the total autoloaded payload as your baseline. Then create the problem on purpose: write a plugin that keeps a list of post IDs in one option and appends to it on every save_post, with autoload left at the default.

Generate fifty posts, re-run the audit, and note the size. Then answer three questions. What did update_option( 'wpsifero_synced_ids', get_option( 'wpsifero_synced_ids' ), false ) do to the autoload flag, and why? What does wp_set_option_autoload() do differently? And after demotion, is the list still readable by the plugin that wrote it?

Finally, replace the growing option with a bounded one: keep only the twenty most recent IDs and measure the option size again once the list has been written to fifty times.

Show the solution and the reasoning

The audit is the easy half. When I ran this on a clean 7.0.7 install, the option holding fifty post IDs came out at 496 bytes with autoload auto, which the loader counts as on. It was created without an explicit autoload argument, which is exactly how a real plugin ends up autoloading a list it never meant to autoload. The lesson is not the byte count at fifty rows, it is the slope: 9.9 bytes per entry measured on that data, which is roughly 484 KB at fifty thousand entries, fetched and unserialised by every uncached request.

update_option() with the same value did nothing to the autoload flag. The equality guard sits above the autoload handling, so the function returns false before it ever decides what to write. This is the trap worth internalising: the obvious demotion code looks correct, reports failure, and almost nobody checks the return value of an update_option() call.

wp_set_option_autoload() issues an UPDATE against the specific row and changes only the autoload column. The value is untouched, so the plugin keeps working. Verified on 7.0.7: the flag went from on to off, get_option() still returned the full 100 KB value, and the name disappeared from the alloptions set. The cost of that is real and worth naming: the next read of that option is a query rather than a memory lookup, so demote the options that are read rarely, not the ones on your critical path.

// Activation: create the option with autoload off, once.
add_option( 'wpsifero_synced_ids', array(), '', false );

// Bounded write: the list never grows past its useful size.
function wpsifero_record_synced( int $post_id ): void {
    $ids = get_option( 'wpsifero_synced_ids', array() );

    if ( ! is_array( $ids ) ) {
        $ids = array();
    }

    array_unshift( $ids, $post_id );
    $ids = array_slice( array_unique( $ids ), 0, 20 );

    update_option( 'wpsifero_synced_ids', $ids );
}

Three decisions in that function are deliberate. array_slice() after array_unique() keeps the cap honest when the same post is saved twice. The value is read and written on save_post rather than on a page view, so the read costs one query per save instead of one per request. And the option is created by the plugin's activation routine with autoload off rather than decided at write time.

That last point is worth being precise about, because the third argument to update_option() does not do what people assume. On a new install the argument is applied when the option is created. On an existing option it is only applied if the value changed on that call, and when the value is identical the equality guard returns first and the flag is left where it is. So a plugin that created its list autoloaded and then always passes false will keep it autoloaded forever. Set the flag where the option is created, and use wp_set_option_autoload() to fix a row that is already wrong.

What this solution does not fix is concurrent saves. Two saves in the same second can both read the same list and the second write wins, losing the first post ID. If that matters, the list is not really an option any more: it is a table with rows, a primary key and an insert per event, which is where the next lesson goes.

Progress

Mark this lesson done and it stays marked in this browser.