Options, Transients and Object Cache
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 8.4 (WordPress needs PHP 7.4 or later and recommends 8.3 or later), WP-CLI 2.12.0 and
the Redis Object Cache plugin 3.0.0, and checked on 2026-10-28. WordPress
7.2, in beta and due in December 2026, does not change these APIs so far. It covers the developer side of the three
key/value stores that every plugin uses: options for settings and state that must persist, transients for data that
can be recomputed, and the object cache for data that is expensive to read within and, with a persistent backend,
across requests. The examples belong to the lumen-catalog plugin of the fictitious publisher Lumen Press.
Performance and Caching sets up the Redis Object Cache drop-in and
measures autoloaded options; settings screens (register_setting(), options.php) are in
Administration Menus and Settings API, and the
wp_options table in Database Schema.
Which store for which data
| Store | Lives in | Survives | Use it for |
|---|---|---|---|
Option |
|
Until deleted |
Settings and state the plugin cannot rebuild: |
Transient |
|
At most until it expires; possibly much less |
Results that can be recomputed or fetched again: an API answer, a costly query, rendered HTML |
Object cache |
Process memory (default); Redis, Memcached or APCu with a drop-in |
One request (default); until evicted or expired (persistent) |
Data read several times per request or per minute: post objects, query results, the card data of a book |
Post meta, custom table |
|
Until deleted |
Data that belongs to a post or must be queried, such as the |
Anything that would be lost for good if it disappeared belongs in an option, meta or a table. Transients and the object cache are caches: code that reads them must always be able to rebuild the value.
The Options API
| Function | Behaviour |
|---|---|
|
Returns the stored value, unserialized. Returns |
|
Adds an option; returns |
|
Updates an option, or adds it when missing. Returns |
|
Deletes the row and its cache entries |
|
Return, or only load into the cache, several options with one query |
|
Changes autoload of several options without touching their values; |
|
Network-wide options, stored in |
Values are serialized with maybe_serialize(), so arrays and objects can be stored directly; do not call serialize()
or json_encode() yourself unless another program reads the row. An option name is at most 191 characters (the
option_name column), and it must carry the plugin’s prefix, because every plugin and core share one namespace. The
names alloptions and notoptions are reserved: wp_protect_special_option() stops any write to them.
How get_option() finds a value
-
The filters
pre_option_{$option}andpre_option(6.1) can short-circuit the call by returning anything butfalse. A test can force a value this way without writing to the database. -
wp_load_alloptions()has already loaded every autoloaded option, in one query, at the start of the request. If the option is there, it is returned. -
Otherwise the
notoptionsentry of theoptionscache group lists names known not to exist, so a missing option costs one query per request (or none with a persistent object cache). -
Otherwise the value is read from the
optionscache group, and on a miss with its ownSELECT, then cached. -
A missing option returns the result of the
default_option_{$option}filter, whichregister_setting()uses to supply a registereddefault. The value then passes through theoption_{$option}filter.
That is why a non-autoloaded option is not slow in itself: it costs one query the first time per request, and none when
a persistent object cache holds it. get_options() or wp_prime_option_caches() reduce several such reads to one query.
Autoload since WordPress 6.6
The autoload column decides whether wp_load_alloptions() reads a row on every request. WordPress 6.6 replaced the
old yes and no with on and off for explicit choices, and auto, auto-on and auto-off for rows where the
caller made no choice. auto is loaded today, but core reserves the right to change that. The full table of values, the
800 KB Site Health limit and SQL to measure a site are in
Database Schema and
Performance and Caching.
For plugin code, the rules are:
-
Always pass the autoload argument.
truefor small values that most requests read (a schema version, a feature flag used on the front end),falsefor anything read on one admin screen, by a cron job or by a REST route. Passingnull(the default) leaves the decision towp_determine_option_autoload_value(). -
Do not pass
'yes'or'no'. They still work, but have been deprecated since 6.7. -
Large values are not autoloaded automatically, but only when no choice was made. With
null, a serialized value larger than 150,000 bytes (filterwp_max_autoloaded_option_size) is stored asauto-off. An explicittrueautoloads it whatever its size. -
update_option()only changes autoload when the value changes. For an existing option whose value is unchanged it returnsfalseand keeps the old autoload. Usewp_set_option_autoload_values()to change autoload alone. -
The
wp_default_autoload_valuefilter (6.6) sets the value used when the caller passesnull, including calls from core screens and the REST API that pass nothing. Returningfalsestoresauto-off.
The following script shows the return values and the stored autoload for the calls the catalogue makes. Run on a fresh
7.1.3 site with wp eval-file options-check.php:
<?php
// options-check.php: run with `wp eval-file options-check.php`.
global $wpdb;
add_option( 'lumen_catalog_db_version', '3', '', true ); // read on every request
add_option( 'lp_catalog_settings', array( 'isbn_prefix' => '978' ), '', false );
add_option( 'lp_catalog_import_log', array() ); // no choice: WordPress decides
add_option( 'lp_catalog_cover_index', str_repeat( 'x', 200000 ) ); // large, no choice
var_dump( update_option( 'lumen_catalog_db_version', '3' ) ); // unchanged value
var_dump( get_option( 'lp_catalog_missing' ) ); // missing option
var_dump( get_option( 'lp_catalog_missing', array() ) ); // missing, with a default
var_dump( update_option( 'lp_catalog_enabled', false ) ); // missing option, false
add_option( 'lp_catalog_paused', false );
var_dump( get_option( 'lp_catalog_paused' ) ); // stored false
echo $wpdb->get_var( "SELECT autoload FROM {$wpdb->options} WHERE option_name = 'lp_catalog_import_log'" ), "\n";
$changed = wp_set_option_autoload_values( array( 'lp_catalog_import_log' => false, 'lumen_catalog_db_version' => true ) );
echo wp_json_encode( $changed ), "\n";
foreach ( $wpdb->get_results(
"SELECT option_name, autoload FROM {$wpdb->options}
WHERE option_name LIKE 'lp\_catalog\_%' OR option_name LIKE 'lumen\_catalog\_%' ORDER BY option_name"
) as $row ) {
printf( "%-26s %s\n", $row->option_name, $row->autoload );
}
Source: Common APIs Handbook: Options
Output:
bool(false)
bool(false)
array(0) {
}
bool(false)
bool(false)
auto
{"lp_catalog_import_log":true,"lumen_catalog_db_version":false}
lp_catalog_cover_index auto-off
lp_catalog_import_log off
lp_catalog_paused auto
lp_catalog_settings off
lumen_catalog_db_version on
Read top to bottom, the output shows the pitfalls of the API:
-
update_option()with an unchanged value returnsfalse. Code that treatsfalseas an error after an update logs false failures; compare with the old value first if the difference matters. -
get_option()cannot tell a missing option from a storedfalse. Pass a default of another type (array(),null) when the difference matters. -
update_option( 'lp_catalog_enabled', false )on a missing option returnsfalseand creates nothing: the missing value (false) and the new value are identical, so the function stops before falling through toadd_option(). Store booleans as0/1or'yes'/'no'strings, or useadd_option()first. -
A stored
falsecomes back asfalsein the same request, but as the empty string''in the next one (a separatewp evalprintedstring(0) ""), becausefalseis saved as an empty column. -
wp_set_option_autoload_values()returns, per option, whether the row changed (lumen_catalog_db_versionwas alreadyon). The 200,000-byte option added without a choice becameauto-off; the small one becameauto.
Older books predate WordPress 6.6 and describe the column as yes/no with add_option() defaulting to yes. Both
statements are out of date: the default is now null (WordPress decides), and WP-CLI’s wp option add stores on
when --autoload is omitted, unlike add_option().
Transients
A transient is a named value with an optional expiration in seconds. The API has three functions per scope:
| Function | Notes |
|---|---|
|
Name at most 172 characters. |
|
Returns |
|
Returns |
|
Network-wide versions; name at most 167 characters |
Values are serialized like options. Use a prefix (lumen_ for the catalogue) and, for derived keys, a short hash:
'lumen_search_' . md5( $query ) stays well under the length limit.
Where a transient is stored
wp_using_ext_object_cache() decides. Without a persistent object cache drop-in, a transient is two options:
_transient_<name> with the value and, when an expiration is given, _transient_timeout_<name> with the expiry
timestamp. Site transients use _site_transient_ and _site_transient_timeout_, in wp_options on a single site and
in wp_sitemeta on Multisite. With a drop-in (and during installation), the same functions call wp_cache_set() in
the transient or site-transient group with the expiration as TTL, and nothing is written to the database.
<?php
// transients-check.php: run with `wp eval-file transients-check.php`.
global $wpdb;
set_transient( 'lumen_featured_books', array( 101, 102 ), HOUR_IN_SECONDS );
set_transient( 'lumen_genre_counts', array( 'fiction' => 12 ) ); // no expiration
var_dump( set_transient( 'lumen_genre_counts', array( 'fiction' => 12 ) ) ); // same value again
set_site_transient( 'lumen_network_notice', 'Imprint launch', DAY_IN_SECONDS );
printf( "external object cache: %s\n", wp_using_ext_object_cache() ? 'yes' : 'no' );
foreach ( $wpdb->get_results(
"SELECT option_name, autoload FROM {$wpdb->options}
WHERE option_name LIKE '%transient%lumen%' ORDER BY option_name"
) as $row ) {
printf( "%-46s %s\n", $row->option_name, $row->autoload );
}
Source: Common APIs Handbook: Transients
Without and then with the Redis drop-in described in
Performance and Caching
(after wp transient delete --all and wp cache flush):
bool(false)
external object cache: no
_site_transient_lumen_network_notice off
_site_transient_timeout_lumen_network_notice off
_transient_lumen_featured_books off
_transient_lumen_genre_counts on
_transient_timeout_lumen_featured_books off
bool(true)
external object cache: yes
With Redis, redis-cli --scan --pattern 'lumenpress:*lumen*' listed lumenpress:wp:transient:lumen_featured_books,
lumenpress:wp:transient:lumen_genre_counts and lumenpress:wp:site-transient:lumen_network_notice. Two details in the
database case matter:
-
A transient without expiration is autoloaded (
on), and so is loaded on every request until deleted. Give every transient an expiration unless it is small and read on most requests. -
Setting the same value again returns
falseon the database backend (it ends inupdate_option()) andtruewith Redis. Do not use the return value to detect errors.
Expiration is a maximum
The expiration is the longest time a transient may live, not a promise that it will. It disappears earlier when a
persistent cache evicts it under memory pressure (Redis with allkeys-lru), restarts or is flushed (wp cache flush,
a deployment, a plugin’s flush button); when another node uses a different cache (APCu is per server; see
Scaling and High Availability); or when a plugin, a database
upgrade or wp transient delete --all removes it.
On the database backend, expired transients are removed in two ways: get_transient() deletes an expired row when it is
read, and the daily delete_expired_transients cron event (since 4.9) deletes the rest in bulk. Statements that expired
transients stay in wp_options until a visitor reads them, still found in some tutorials and in the "Related" note of
the WP_Object_Cache reference page, are out of date. With a persistent cache, delete_expired_transients() does
nothing (unless $force_db is true), because the backend expires keys itself. The cron event is described in
WP-Cron and Scheduled Tasks.
wp transient list, get, set, delete and delete --expired manage transients from WP-CLI. With a drop-in,
wp transient delete --all warns that it only deletes those in the database, and wp cache flush is needed instead.
Transient hooks
| Hook | Purpose |
|---|---|
|
Return anything but |
|
Filters the value read |
|
Change the value or the expiration before storing |
|
Fire after a successful set. |
|
Fire before and after deletion |
Site transients |
The same hooks with |
The object cache API
The object cache is the layer the other two use: options are cached in the options group (with the alloptions and
notoptions entries), transients in transient with a drop-in, and core caches posts, meta, terms, users, comments and,
since 6.1, WP_Query results (post-queries group). Plugins use the same functions.
| Function | Behaviour |
|---|---|
|
Returns the value or |
|
Stores unconditionally |
|
Stores only if the key does not exist; returns |
|
Stores only if the key exists |
|
Removes one key |
|
Atomic counters on a persistent backend |
|
Several keys of one group in one round trip; missing keys come back as |
|
Empties the whole cache, for every site sharing the backend |
|
Empties only the in-memory copy of the current request |
|
Empties one group, if the backend supports it |
|
|
|
A per-group timestamp used to invalidate derived keys |
|
Store a value with a salt (usually a |
|
Declare groups shared by all sites of a network, or never sent to the persistent backend |
Keys are strings or integers (an empty key triggers _doing_it_wrong since 6.1) and are unique per group. On
Multisite, a non-global group is also scoped per site; switch_to_blog() switches it.
The default cache and drop-ins
Without a drop-in, WP_Object_Cache is a PHP array: it lives for one request, ignores $expire, and returns a clone
of a cached object so that callers cannot change the cached copy. A drop-in (wp-content/object-cache.php) replaces it
with a shared backend; WP-CLI’s wp cache type prints Default or the backend name. Code must give the same result
with either, which means:
-
Never rely on a value surviving to the next request, and never rely on
$expireto clean up (the default cache ignores it; Redis honours it). -
Keep values serializable. A drop-in serializes them, so closures and resources cannot be cached, and objects come back as new instances.
-
Check
wp_cache_supports()before the newer functions. Core defines all of them incache-compat.phpfor older drop-ins, but the compatiblewp_cache_flush_group()refuses to run (it triggers_doing_it_wrongand returnsfalse) when the drop-in has not declared support. The Common APIs Handbook still says such a call may flush the whole cache, and its example calls a function that does not exist,wp_cache_flush_cache(); in 7.1.3 the compatible function flushes nothing.
Groups and non-persistent groups
A group is a namespace; the catalogue uses lumen_catalog. Core keeps counts, plugins and theme_json out of Redis
as non-persistent groups, and a plugin does the same for data that must not outlive the request:
<?php
// In Plugin::boot(): this group stays in PHP memory even with Redis.
wp_cache_add_non_persistent_groups( array( 'lumen_catalog_request' ) );
A wp eval-file script exercised the functions in the lumen_catalog group. The results were identical with the
default cache and with Redis:
| Call | Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
With Redis, no key of the lumen_catalog_request group appeared in redis-cli --scan. Redis Object Cache 3.0.0
supports flush_group, and it rewrites : in keys to - (the key card:5 of the next section is stored as
lumenpress:wp:lumen_catalog:card-5).
Cache invalidation patterns
A cached value is only correct if every change to its source removes or outdates it. Three patterns cover most cases:
| Pattern | How | Use when |
|---|---|---|
Delete on write |
|
The cached value maps to one object, such as the card of one book |
Last-changed salt |
Read |
Many derived keys (lists, counts, searches) depend on any object of a type. Core uses it for post, comment, term, user and site queries since 6.9 |
Expiry only |
A short expiration and no hooks |
The source is outside WordPress (a remote API) and slightly stale data is acceptable |
Flushing a whole group with wp_cache_flush_group() works too, but it removes unrelated keys of the group and depends
on backend support; the last-changed salt costs one key read.
The catalogue’s Lumen\Catalog\Cache class, booted from Plugin::boot() (see
Plugin Fundamentals), caches the latest book IDs with a salt and the card
data of each book under its own key, and clears both from hooks. save_post_lp_book is the dynamic
save_post_{$post->post_type} action; it fires for every save of a book, including Quick Edit and REST, but not for
its revisions, whose post type is revision; deleted_post_lp_book (6.6) covers permanent deletion. The lumen/latest-books block in
Static and Dynamic Blocks can render from these two methods.
<?php
// Object cache helpers of the catalogue: lumen-catalog/src/Cache.php
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
final class Cache {
public const GROUP = 'lumen_catalog';
public static function boot(): void {
add_action( 'save_post_lp_book', array( self::class, 'forget_book' ) );
add_action( 'deleted_post_lp_book', array( self::class, 'forget_book' ) );
add_action( 'updated_post_meta', array( self::class, 'forget_meta' ), 10, 3 );
add_action( 'added_post_meta', array( self::class, 'forget_meta' ), 10, 3 );
}
/** IDs of the latest published books, valid until any book changes. */
public static function latest_book_ids( int $count = 6 ): array {
$salt = wp_cache_get_last_changed( self::GROUP );
$key = 'latest:' . $count;
$ids = wp_cache_get_salted( $key, self::GROUP, $salt );
if ( false === $ids ) {
$ids = get_posts(
array(
'post_type' => 'lp_book',
'post_status' => 'publish',
'numberposts' => $count,
'fields' => 'ids',
'no_found_rows' => true,
)
);
wp_cache_set_salted( $key, $ids, self::GROUP, $salt, HOUR_IN_SECONDS );
}
return array_map( 'intval', $ids );
}
/** Card data (title, link, ISBN, stock) of several books, in one cache round trip. */
public static function book_cards( array $ids ): array {
$keys = array();
foreach ( $ids as $id ) {
$keys[ (int) $id ] = 'card:' . (int) $id;
}
$cached = wp_cache_get_multiple( array_values( $keys ), self::GROUP );
$cards = array();
foreach ( $keys as $id => $key ) {
$card = $cached[ $key ] ?? false;
if ( false === $card ) {
$card = array(
'title' => get_the_title( $id ),
'url' => get_permalink( $id ),
'isbn' => (string) get_post_meta( $id, 'lp_isbn', true ),
'stock' => (int) get_post_meta( $id, 'lp_stock', true ),
);
wp_cache_set( $key, $card, self::GROUP, DAY_IN_SECONDS );
}
$cards[ $id ] = $card;
}
return $cards;
}
/** Runs on save_post_lp_book and deleted_post_lp_book. */
public static function forget_book( int $post_id ): void {
wp_cache_delete( 'card:' . $post_id, self::GROUP );
wp_cache_set_last_changed( self::GROUP );
}
/** Runs when post meta is added or updated, for example by the stock sync. */
public static function forget_meta( $meta_id, int $object_id, string $meta_key ): void {
if ( in_array( $meta_key, array( 'lp_isbn', 'lp_stock' ), true ) ) {
wp_cache_delete( 'card:' . $object_id, self::GROUP );
}
}
}
The salted functions arrived in 6.9, which is the plugin’s Requires at least; on older versions the same pattern is
written by putting the last_changed value into the key ("latest:{$count}:{$salt}"), as core did before 6.9. Old
keys are then never deleted, only left to expire or be evicted, which is why the $expire argument is set. Two
limits of hook-based invalidation: a direct SQL write ($wpdb->update(), an import tool) fires no hook, and
update_post_meta() with an unchanged value fires no updated_post_meta. Code that writes that way calls
wp_cache_set_last_changed() itself.
A wp eval-file script counted the queries of Cache::latest_book_ids( 3 ) called twice, then again after renaming
the newest book with wp_update_post(), and read its card. With two books (IDs 4 and 5), both backends printed:
latest=5,4 queries=1
again: queries=0
after save: queries=1
The Lighthouse Ledger (2nd ed.)
Deleting a book with wp_delete_post( $id, true ) changed the salt as well. Across requests the backends differ: a
following wp eval of the same call counted 0 database queries for the whole request with Redis and 8 without it.
Caching the distributor API response
The lp_catalog_sync_stock cron job of WP-Cron and Scheduled
Tasks downloads the distributor’s stock list. Other code needs the same list within minutes: the per-book sync queued
on save_post_lp_book, wp lumen import and the stock endpoint of the REST API. The distributor rate-limits its API,
so the list is fetched once and kept in a transient, lumen_distributor_stock, for 15 minutes. The HTTP client that
fetches and caches it, lumen_catalog_fetch_stock() in includes/distributor.php, is the one of
HTTP and Filesystem APIs, which
also defines the LP_CATALOG_STOCK_TRANSIENT constant and normalizes ISBNs to digits. It never caches a failure.
Because the transient can vanish, and the distributor can be down, the function below wraps it and keeps the last good
answer in a non-autoloaded option, lp_catalog_stock_snapshot. Page views never call either function; they read the
lp_stock meta that the job writes.
<?php
// Stock list with a last-good fallback: lumen-catalog/includes/stock-cache.php
// Requires includes/distributor.php (lumen_catalog_fetch_stock() and LP_CATALOG_STOCK_TRANSIENT).
defined( 'ABSPATH' ) || exit;
const LP_CATALOG_STOCK_SNAPSHOT = 'lp_catalog_stock_snapshot';
/** Stock levels keyed by normalized ISBN: the transient, else the distributor, else the last good copy. */
function lp_catalog_get_distributor_stock( bool $refresh = false ): array {
$stock = lumen_catalog_fetch_stock( $refresh );
if ( ! is_wp_error( $stock ) ) {
// update_option() writes nothing when the list is unchanged.
update_option( LP_CATALOG_STOCK_SNAPSHOT, $stock, false );
return $stock;
}
// The distributor failed: serve the last good copy and retry in five minutes.
$snapshot = get_option( LP_CATALOG_STOCK_SNAPSHOT, array() );
set_transient( LP_CATALOG_STOCK_TRANSIENT, $snapshot, 5 * MINUTE_IN_SECONDS );
return $snapshot;
}
The lp_catalog_sync_stock() job of the cron page changes in one place: instead of its own wp_remote_get() and
json_decode(), it loops over lp_catalog_get_distributor_stock( true ) as $isbn => $quantity, so the job always
fetches a fresh list, refreshes the transient for every other caller and keeps the snapshot current. When the
distributor is down, the job gets the snapshot back and rewrites the values the books already have. Design choices:
-
The stored value is never
false: an empty array means "no stock data", andis_array()tells a hit from a miss. -
lumen_catalog_fetch_stock()caches only good answers, so a recovery shows at the next call. The wrapper adds a deliberate negative cache: after a failure it caches the snapshot for five minutes, so a distributor outage does not turn every caller into another 10-second request. -
The snapshot option is the only copy that must survive, so it is an option with
falseautoload, not a transient. -
Each
update_post_meta()firesupdated_post_meta, which removes the card of that book from the object cache through theCacheclass above. -
Two simultaneous misses can both call the distributor. That is acceptable here; when it is not, take a short lock first with
wp_cache_add( 'stock_lock', 1, 'lumen_catalog', 30 ), which only one caller wins on a persistent backend. On the default cache the lock exists only in the current request, soadd_option()(which fails if the row exists) is the lock that works everywhere. HTTP timeouts,wp_safe_remote_get()and error handling are covered in HTTP and Filesystem APIs.
A must-use plugin answered https://distributor.example/ through the
pre_http_request filter with two books, counted
the calls, and answered 503 while an option was set. The check script deleted the transient, called
lp_catalog_get_distributor_stock() three times and printed the number of HTTP calls, then forced a refresh with the
distributor down and printed the result, and finally ran the lp_catalog_sync_stock action.
Output, identical without and with the Redis drop-in:
HTTP calls after three reads: 1
Array
(
[9781234567897] => 12
[9781234567903] => 0
)
HTTP calls: 2
{"time":1793200573,"updated":2}
The failed refresh returned the snapshot instead of an empty list. With Redis,
redis-cli ttl 'lumenpress:wp:transient:lumen_distributor_stock' returned 899 right after the job ran and the
database held no transient row; without Redis, the value and timeout rows were both stored with autoload off.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
A transient never expires and slows every page |
It was set without expiration, so it is autoloaded. Give it an expiration |
Hundreds of |
Rows written before the drop-in are no longer read. |
A cached list stays stale after an import |
The importer writes with SQL and fires no hooks. Call |
|
It flushes the whole backend database. Use a key prefix per site, as in Performance and Caching |
Security
Cache keys built from request data must not let one user read another’s data: include the user ID in the key, or use
a non-persistent group, and never cache output that contains a nonce or private content in a shared group. Cached values
are trusted only as much as their source: the distributor answer is sanitized before it is stored and escaped on output.
Never unserialize() values from outside, and keep Redis private (Redis: Security).