The object cache stores the results of work WordPress has already done, such as a loaded post, an option or the result of a custom query, so the same request does not repeat it. Used well, it removes database queries from every page. Used carelessly, it serves stale data. This lesson covers both sides.
General caching theory (cache-aside, TTLs, stampedes) has its own course. Here we focus on how WordPress implements it.
What the object cache is
WordPress always has an object cache. Core uses it constantly: when you call get_post(), get_option() or get_post_meta(), the result is stored in the cache, and the second call in the same request is answered from memory.
By default the cache is non-persistent. It is a PHP array inside the WP_Object_Cache class, and it disappears when the request ends. Every new page load starts empty.
A persistent object cache keeps those values between requests in an in-memory store such as Redis or Memcached. WordPress supports this through a drop-in file, wp-content/object-cache.php. When that file exists, WordPress loads it instead of the default cache class, and every wp_cache_* call goes to the external store.
| Default cache | Persistent cache | |
|---|---|---|
| Storage | PHP memory | Redis, Memcached or similar |
| Lifetime | One request | Until expiry or eviction |
| Setup | Nothing | Server software plus object-cache.php drop-in |
| Helps | Repeated lookups within a page | Repeated lookups across all visitors |
You can check which one is active in code:
if ( wp_using_ext_object_cache() ) {
// A persistent object cache drop-in is loaded.
}
From the terminal, wp cache type reports the cache type WP-CLI detects.
The core functions
The API is small. These are the functions you will use most:
| Function | What it does |
|---|---|
wp_cache_get( $key, $group, $force, &$found ) | Read a value. Returns false on a miss. |
wp_cache_set( $key, $data, $group, $expire ) | Store a value, overwriting any existing one. |
wp_cache_add( $key, $data, $group, $expire ) | Store only if the key does not exist yet. |
wp_cache_replace( $key, $data, $group, $expire ) | Store only if the key already exists. |
wp_cache_delete( $key, $group ) | Remove a value. |
wp_cache_incr( $key, $offset, $group ) | Increase a numeric value. |
wp_cache_get_multiple( $keys, $group ) | Read several keys in one call. |
$expire is in seconds. 0 means "no expiry", which for a persistent cache means the value stays until it is deleted or evicted for memory.
A complete caching example
The usual pattern is "check the cache, otherwise compute and store". Here a plugin counts published events per venue, which needs a slow meta query.
<?php
/**
* Plugin Name: My Plugin Venue Stats
*/
defined( 'ABSPATH' ) || exit;
function myplugin_get_venue_event_count( int $venue_id ): int {
$found = false;
$count = wp_cache_get( 'venue_count_' . $venue_id, 'myplugin_venues', false, $found );
if ( $found ) {
return (int) $count;
}
$query = new WP_Query(
array(
'post_type' => 'myplugin_event',
'post_status' => 'publish',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => 'myplugin_venue_id',
'meta_value' => $venue_id,
)
);
$count = (int) $query->found_posts;
wp_cache_set( 'venue_count_' . $venue_id, $count, 'myplugin_venues', HOUR_IN_SECONDS );
return $count;
}
Two details matter:
- The
$foundargument.wp_cache_get()returnsfalseon a miss, butfalse(or0) can also be a real cached value. Passing$foundby reference tells you which it was. Without it, a venue with zero events would never be cached. - The group.
myplugin_venueskeeps your keys apart from core's and lets a backend treat them together.
Cache the smallest useful result: a count, an array of IDs, a rendered fragment. Caching whole WP_Query objects stores far more than you need and can include data you did not mean to keep.
Cache groups
Every key lives in a group. Core uses groups such as posts, post_meta, options, terms and users. Prefix your own groups like everything else in a plugin.
Two functions change how groups behave with a persistent cache:
add_action( 'init', 'myplugin_register_cache_groups' );
function myplugin_register_cache_groups(): void {
// Never send this group to the persistent store. It lives for one request only.
wp_cache_add_non_persistent_groups( array( 'myplugin_request' ) );
// On Multisite, share this group across every site in the network.
wp_cache_add_global_groups( array( 'myplugin_network' ) );
}
Use non-persistent groups for data that is cheap to build but used many times in one request, or that must never be shared, such as values tied to the current user's session.
Invalidation: the hard part
Cached data is a copy. When the original changes, the copy must go. There are three common approaches.
Delete on change
Remove the key when the source data changes. For the venue count, an event being saved, trashed or deleted changes the number.
add_action( 'save_post_myplugin_event', 'myplugin_flush_venue_count', 10, 1 );
add_action( 'before_delete_post', 'myplugin_flush_venue_count', 10, 1 );
function myplugin_flush_venue_count( int $post_id ): void {
$venue_id = (int) get_post_meta( $post_id, 'myplugin_venue_id', true );
if ( $venue_id > 0 ) {
wp_cache_delete( 'venue_count_' . $venue_id, 'myplugin_venues' );
}
}
This is precise but easy to get wrong: if the venue meta changes from 4 to 7, you need to clear both counts. Map every path that changes the data before you trust it.
Versioned keys with last_changed
Core uses a cleverer pattern for queries. It stores a last_changed value per group and includes it in cache keys. When any post changes, core updates last_changed for the posts group, so every old key is simply never read again.
function myplugin_get_featured_ids(): array {
$last_changed = wp_cache_get_last_changed( 'posts' );
$key = 'featured_ids:' . $last_changed;
$ids = wp_cache_get( $key, 'myplugin_queries' );
if ( false === $ids ) {
$ids = get_posts(
array(
'post_type' => 'post',
'posts_per_page' => 10,
'fields' => 'ids',
'meta_key' => 'myplugin_featured',
'meta_value' => '1',
'no_found_rows' => true,
)
);
wp_cache_set( $key, $ids, 'myplugin_queries', DAY_IN_SECONDS );
}
return $ids;
}
Old entries are left to expire. Always set an expiry with this pattern so the store does not fill with dead keys.
Short expiry
For data you cannot track, such as a remote API, accept slight staleness and set a short $expire. This is the honest option when you cannot know when the source changes.
Flushing
wp_cache_flush() empties the whole cache. On a persistent backend shared by several sites, that can mean all of them. Avoid calling it from plugin code.
To clear one group, current versions of WordPress provide wp_cache_flush_group(), but not every backend implements it. Check first:
if ( wp_cache_supports( 'flush_group' ) ) {
wp_cache_flush_group( 'myplugin_venues' );
}
From WP-CLI:
wp cache flush
wp cache get venue_count_12 myplugin_venues
wp cache delete venue_count_12 myplugin_venues
WP-CLI runs in its own PHP process. Without a persistent cache, wp cache get only sees that process's empty cache, so it tells you nothing about the web requests. The commands are useful only when a persistent backend is installed.
Adding a persistent backend
The setup has three parts:
- Run the store. Install Redis or Memcached on the server, or use a managed service. Keep it on a private network, never exposed to the internet.
- Install the PHP extension the drop-in needs, for example
phpredisormemcached. - Install the
object-cache.phpdrop-in. Free, well maintained plugins exist for common backends; they place the drop-in and add a status screen. Their configuration usually lives inwp-config.phpconstants, documented by each plugin.
If several WordPress sites share one Redis instance, give each a unique key prefix or database number so their keys never collide. The drop-in's documentation names the constant to set.
Transients and the object cache
Transients from the previous lesson change behaviour once a persistent cache is active. set_transient() stores the value in the object cache instead of the wp_options table. That makes them faster, but it also means a transient can disappear at any time when the store evicts it under memory pressure.
The rule has always been the same: treat a transient or cache entry as something that might be missing, and always be able to rebuild it.
What not to cache
- Per-user data in shared groups. Include the user ID in the key, or use a non-persistent group.
- Data that must not be lost. The cache is not storage. Orders, form entries and settings belong in the database.
- Very large values. Backends have item size limits and big values slow down every read.
- Things that are already cheap. Core already caches posts, meta and options. Wrapping
get_option()in your own cache adds nothing.
Summary
- WordPress always has an object cache; by default it lasts one request.
- A persistent cache such as Redis or Memcached is added with the
object-cache.phpdrop-in, and then everywp_cache_*call persists between requests. - Use
wp_cache_get()with the$foundargument, thenwp_cache_set()with a prefixed group and a sensible expiry. - Invalidate by deleting keys on change, by versioning keys with
wp_cache_get_last_changed(), or with short expiries. - Avoid
wp_cache_flush()in plugin code; preferwp_cache_flush_group()wherewp_cache_supports( 'flush_group' )is true. - Cached values and transients can vanish at any time, so code must always be able to rebuild them.