The WordPress Transient API gives you a simple, expiry-aware key-value store that automatically uses Redis or Memcached when a persistent object cache is installed — and falls back to the database when it isn’t. It sits between a full-page cache plugin (too coarse) and a raw wp_options entry (permanent, always-DB), making it the right tool whenever you need to cache one specific piece of expensive data for a known period of time.
The Cache-Aside Pattern and Why false !== Matters
Every transient use follows the same pattern: check the cache, return immediately on a hit, compute and store on a miss. The one detail developers trip over is the strict inequality check.
function get_cached_data(): mixed {
$key = 'myplugin_expensive_v1';
$cached = get_transient( $key );
// false === means "cache miss" — strict check matters here.
// get_transient() returns false for missing OR expired keys.
// Valid cached values of 0, '', or [] would be lost with a loose ==.
if ( false !== $cached ) {
return $cached;
}
$data = run_expensive_computation(); // slow DB query, remote call, etc.
set_transient( $key, $data, HOUR_IN_SECONDS * 6 );
return $data;
}
get_transient() returns false for both a cache miss and an expired entry. Because valid cached data can be 0, an empty string, or an empty array, a loose == false check would treat those as misses and recompute on every request. Always use false !== get_transient().
Scenario 1: Caching Complex WP_Query Results
A WP_Query with nested meta_query and tax_query clauses can generate five or more SQL joins. Running it on every page load adds measurable latency. Caching the result for several hours brings that cost to near zero for most visitors. See the WP_Query advanced guide for the query structure itself — the caching wrapper below applies to any expensive query.
function get_featured_projects(): array {
$key = 'myplugin_featured_projects_v1';
$cached = get_transient( $key );
if ( false !== $cached ) {
return $cached;
}
$query = new WP_Query( array(
'post_type' => 'project',
'posts_per_page' => 10,
'meta_query' => array(
array(
'key' => '_featured',
'value' => '1',
'compare' => '=',
),
),
'tax_query' => array(
array(
'taxonomy' => 'project_type',
'field' => 'slug',
'terms' => array( 'web', 'mobile' ),
),
),
'no_found_rows' => true, // skip FOUND_ROWS() when pagination is not needed
) );
// Cache the posts array, not the WP_Query object.
// WP_Query objects hold references to WP globals and can be large.
$posts = $query->posts;
wp_reset_postdata();
set_transient( $key, $posts, HOUR_IN_SECONDS * 12 );
return $posts;
}
Cache the $query->posts array, not the WP_Query object. Objects carry references to WordPress globals and can be unexpectedly large when serialized for storage.
Invalidating the Cache on Content Change
Time-based expiry works for data that can tolerate slight staleness. For content that must be accurate after an edit, hook into save_post or edited_term to bust the cache immediately.
add_action( 'save_post_project', 'myplugin_bust_project_cache' );
function myplugin_bust_project_cache( int $post_id ): void {
delete_transient( 'myplugin_featured_projects_v1' );
}
// For term changes that affect the query:
add_action( 'edited_term', 'myplugin_bust_on_term_edit', 10, 3 );
function myplugin_bust_on_term_edit( int $term_id, int $tt_id, string $taxonomy ): void {
if ( 'project_type' === $taxonomy ) {
delete_transient( 'myplugin_featured_projects_v1' );
}
}
Use the save_post_{post_type} action variant to avoid a get_post_type() lookup inside the hook — WordPress passes the correct action automatically when the post type is known.
Scenario 2: Caching External API Calls
Remote HTTP calls introduce latency you do not control — the remote server may be slow, rate-limited, or unavailable. Wrapping them in a transient converts an unreliable dependency into a fast local read for the duration of the cache TTL.
function get_cached_stock_data( string $ticker ): array|false {
$key = 'stock_' . sanitize_key( $ticker );
$stale_key = 'stock_stale_' . sanitize_key( $ticker );
$cached = get_transient( $key );
if ( false !== $cached ) {
return $cached;
}
$response = wp_remote_get(
esc_url_raw( "https://api.example.com/price/{$ticker}" ),
array( 'timeout' => 5 )
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
// API is down — serve stale data if available rather than a blank.
return get_transient( $stale_key );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
set_transient( $key, $data, MINUTE_IN_SECONDS * 15 ); // short TTL
set_transient( $stale_key, $data, DAY_IN_SECONDS ); // stale safety net
return $data;
}
The “stale safety net” transient (a much longer TTL) lets you serve the last known good response if the API is down — users see slightly outdated data instead of a blank or an error. This is a useful pattern for any third-party feed where availability is not guaranteed.
Scenario 3: Caching REST API Responses
REST endpoints that run heavy aggregation queries or call external services on every GET request are a natural fit for transient caching. The cache key should reflect any parameters that change the result — use md5() to collapse a complex parameter set into a safe, fixed-length key.
register_rest_route( 'myplugin/v1', '/reports', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'myplugin_get_reports',
'permission_callback' => '__return_true',
) );
function myplugin_get_reports( WP_REST_Request $request ): WP_REST_Response {
$period = sanitize_key( $request->get_param( 'period' ) ?: 'monthly' );
$key = 'myplugin_reports_' . md5( $period );
$cached = get_transient( $key );
if ( false !== $cached ) {
return new WP_REST_Response( $cached );
}
$data = myplugin_build_report( $period );
set_transient( $key, $data, HOUR_IN_SECONDS );
return new WP_REST_Response( $data );
}
Only cache GET responses — never write transients inside a POST/PUT/DELETE callback. For the full permission model and per-argument validation on REST routes, see the custom REST API endpoints guide.
Key Length and the md5() Pattern
WordPress stores transients in wp_options with the key prefixed as _transient_ (11 chars) and a matching timeout row prefixed as _transient_timeout_ (19 chars). The option_name column is a VARCHAR(191), which caps the total stored key at 191 characters — leaving you 172 usable characters for the transient key itself.
// Transient keys max out at 172 characters (including the _transient_ prefix = 11 chars).
// For dynamic keys built from user input or long slugs, hash them:
$raw_key = 'myplugin_user_' . $user_id . '_' . $category_slug . '_' . $date_range;
$safe_key = 'mp_' . md5( $raw_key ); // always <= 35 chars
$transient = get_transient( $safe_key );
A short prefix (mp_) before the hash keeps keys identifiable in the database without risking a collision with another plugin’s hashed keys.
The $expiration = 0 Special Case
Passing 0 as the expiration creates a transient that never expires on its own — WordPress stores the value without a corresponding _transient_timeout_ entry. It survives indefinitely until deleted manually.
// $expiration = 0 means the transient never expires on its own.
// WordPress stores it without a _transient_timeout_ entry.
// It behaves like update_option() but goes through the object cache if available.
// Use this for rarely-changing computed values you want to control manually:
set_transient( 'myplugin_computed_config', $config, 0 );
// Compare to update_option() — functionally similar but NOT identical:
// - update_option() always writes to wp_options.
// - set_transient($key, $value, 0) uses the object cache when available.
// Use transients when you want the object-cache benefit; options when you need
// the value to survive even if the object cache is flushed.
The difference from update_option() is subtle but meaningful: a transient with $expiration = 0 is still routed through the object cache when one is installed, so reads are faster on cached environments. Use it for rarely-changing computed values (a processed configuration blob, an aggregated report that only rebuilds on demand) where you want object-cache performance but manual control over expiry.
Multisite: Network Transients
On a WordPress multisite network, set_transient() writes to the current site’s wp_options table. If you need data shared across every site in the network — a license status, a shared API token — use the site transient API instead.
// Network (multisite) transients — stored in the network's sitemeta table.
// Use these for data shared across all sites in a network.
function get_network_license(): array|false {
$cached = get_site_transient( 'myplugin_license' );
if ( false !== $cached ) {
return $cached;
}
$license = fetch_license_from_server();
set_site_transient( 'myplugin_license', $license, DAY_IN_SECONDS );
return $license;
}
// Bust it from any site in the network:
delete_site_transient( 'myplugin_license' );
Network transients land in the network’s wp_sitemeta table and are visible from any site in the installation. The same object-cache auto-upgrade behaviour applies.
Transients vs Options vs wp_cache_*
Choosing the right storage mechanism avoids both over-engineering and unnecessary database hits.
| Mechanism | Persistence | Object-cache aware | Best for |
|---|---|---|---|
set_transient() |
Until expiry (or object-cache flush) | Yes — auto-upgrades | Expensive queries, remote API calls, rendered fragments |
update_option() |
Permanent (manual delete) | No — always DB | Plugin settings, persistent configuration |
wp_cache_set() |
Request-scoped without object cache; TTL-based with it | Yes (is the object cache) | Per-request deduplication, Redis/Memcached-first setups |
set_site_transient() |
Until expiry | Yes | Shared data across a multisite network |
When a persistent object cache (Redis, Memcached) is installed, set_transient() calls go to the cache server — there is no wp_options write at all. On a standard shared host without an object cache, transients hit the database on every miss but are still preferable to running the computation on every request.
When Not to Use Transients
Transients are not the right tool in every scenario:
- Per-user data at scale. Storing a transient per logged-in user creates one database row per user. For user-specific cached values, use
wp_cache_set()with a user-scoped group key, or store the value in user meta. - Very high-write environments. If the source data changes on every request, the overhead of writing and immediately invalidating the transient exceeds the cost of just computing the value.
- Long-running calculations. If generating the data takes more than a couple of seconds, it blocks the request that triggers the cache miss (the thundering herd / cache stampede problem). Offload the computation to a background job instead. The Action Scheduler guide covers the background processing pattern that pairs with transient caching: the background job populates the transient; the request reads it.
wp_cache_* vs Transients: Which to Prefer on Redis/Memcached
With a persistent object cache installed, both APIs write to the same backend. The practical distinction is that wp_cache_set() skips the wp_options database row entirely and is marginally faster to write. For new code on a Redis environment, wp_cache_set() with a custom group is the leaner choice. For code that must work correctly on both cached and uncached environments, the Transient API’s automatic fallback to the database makes it the safer default.
// When Redis or Memcached is installed, prefer wp_cache_* for per-request,
// non-persistent data — it skips the wp_options write entirely:
function get_sidebar_fragment(): string {
$key = 'sidebar_html';
$group = 'myplugin';
$cached = wp_cache_get( $key, $group );
if ( false !== $cached ) {
return $cached;
}
$html = render_sidebar();
// No expiration needed — object cache entries are per-server-process.
wp_cache_set( $key, $html, $group, HOUR_IN_SECONDS );
return $html;
}
// wp_cache_* without a persistent object cache → in-memory, request-scoped only.
// Transients without a persistent object cache → survives across requests (database).
// With Redis/Memcached both APIs write to the same backend — pick whichever fits.
For a complete walkthrough of the PHP enqueue and performance hooks that complement caching, see the WordPress performance guide.
Quick-Reference Checklist
- Always use
false !== get_transient()— neverif ( ! $cached ). - Cache the result data (e.g.
$query->postsarray), not the computation object. - Use WordPress time constants (
HOUR_IN_SECONDS,DAY_IN_SECONDS) for readability. - Bust transients on content change with
save_post_{post_type}oredited_term. - Hash long or dynamic keys with
md5()— keep total key under 172 characters. - Use
set_site_transient()for network-wide data on multisite installations. - Offload cache-miss computations that take more than ~2 seconds to a background job.
- Prefer
wp_cache_set()on Redis/Memcached-only environments for the leaner write path.


