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.
| Setup | Autoloaded rows | Payload | Query 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 expired | Value 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.
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.