14+ years building on WordPress / Replies in under 5 hours
Performance 10 min read · Updated July 2026

Properly Using the WordPress Transient API for Caching Custom Data

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Properly Using the WordPress Transient API for Caching Custom Data
TL;DR

The WordPress Transient API caches specific expensive operations — complex <code>WP_Query</code> calls, remote API responses, rendered fragments — for a set TTL using <code>get_transient()</code>, <code>set_transient()</code>, and <code>delete_transient()</code>. Always check <code>false !== get_transient()</code> (strict). When Redis or Memcached is active, transients automatically use the object cache with no code changes needed. Bust the cache explicitly on content change with <code>save_post</code> hooks rather than relying solely on TTL expiry.

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() — never if ( ! $cached ).
  • Cache the result data (e.g. $query->posts array), 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} or edited_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.

Frequently asked questions

get_transient() returns false to signal a cache miss or an expired entry. Valid cached values can legitimately be 0, an empty string '', or an empty array [] — all of which are falsy. A loose == false or if ( ! $cached ) check would treat those as misses and rerun the expensive computation on every request. The strict false !== get_transient() guard is the only safe form.

When a persistent object cache plugin (such as Redis Object Cache or W3 Total Cache's Memcached backend) is active, WordPress automatically routes set_transient() and get_transient() calls to the cache server instead of wp_options. No _transient_ rows are written to the database at all. This makes transients significantly faster on cached environments, and your code requires no changes — the upgrade is transparent.

There is no built-in WordPress function for prefix-based deletion. The practical approaches are: (1) version your key suffix — change v1 to v2 in the key name and old entries expire naturally; (2) maintain a list of keys in a separate option and loop delete_transient() over it; or (3) on sites with direct database access, run a DELETE FROM wp_options WHERE option_name LIKE '_transient_myplugin_%' query (remembering to also delete the matching _transient_timeout_ rows). Option 1 is cleanest for most use cases.

The option_name column in wp_options is VARCHAR(191). WordPress prepends _transient_ (11 characters) and _transient_timeout_ (19 characters) to your key for the two rows it writes. This leaves a practical maximum of 172 characters for your transient key. For dynamic keys built from user input, slugs, or long parameter strings, hash them with md5() to keep the key safely under that limit.

Both store a value permanently (no automatic expiry). The key difference is that set_transient() with an expiration of 0 is routed through the object cache when Redis or Memcached is installed, making reads faster. update_option() always writes to and reads from the wp_options database table. Use a zero-expiry transient when you want the object-cache performance benefit but still need the value to survive cache flushes (the database is the fallback). Use update_option() when the value must persist regardless of any cache layer.

Use wp_cache_set() when you know a persistent object cache (Redis, Memcached) is installed and you want to skip the wp_options database write entirely — it is marginally faster to write and group-based flushing is cleaner. The Transient API is the better default when your code must work correctly on both cached and uncached environments, because it automatically falls back to the database on installs without Redis or Memcached. For most plugin code that needs to ship broadly, the Transient API is the safer choice.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →