← Back to blog

WordPress transients: where they live, why they pile up, and what is safe to delete

The SandyWP team 7 min read

A transient is a cached value with an expiry date, stored as two rows in wp_options: _transient_<name> holds the value and _transient_timeout_<name> holds a Unix timestamp. Core has cleaned up the expired ones on a daily cron event since WordPress 4.9, so the common advice that they "accumulate forever until you install a cleaner" is out of date.

The rows that genuinely cost you something are the transients set with no expiry, because those are stored with autoload = yes and read on every single request. That distinction is the whole post.

What set_transient() actually writes

set_transient() branches on whether you passed an expiry:

set_transient( 'my_api_response', $data, 12 * HOUR_IN_SECONDS );
// _transient_my_api_response          autoload = no
// _transient_timeout_my_api_response  autoload = no, value = 1760000000

set_transient( 'my_api_response', $data );
// _transient_my_api_response          autoload = yes
// (no timeout row at all)

With an expiry you get two rows, neither autoloaded. With no expiry you get one row, autoloaded, and it never expires.

That second form is the one to hunt for. An autoloaded option is fetched in the single SELECT that WordPress runs before it does anything else, so a 400KB serialised API response with no expiry is 400KB added to every page load, every admin screen, and every AJAX call.

Why expired transients are not deleted immediately

get_transient() is lazy. It compares the timeout row to time(), and only if someone asks for an expired transient does it delete both rows and return false.

So a plugin that sets a transient, is then deactivated, leaves its rows behind. Nothing ever requests them again, so nothing triggers the lazy cleanup.

That is the gap core closed in 4.9 with delete_expired_transients(), added in changeset 41963 and wired to a delete_expired_transients cron event that runs daily. It is a single multi-table DELETE that joins the value row to its timeout row, plus a second pass for orphaned timeout rows whose value row is already gone.

Two consequences nobody mentions. First, if WP-Cron is not running on your site, that daily event never fires, and then the old advice is correct again by accident.

Second, the function returns immediately when an external object cache is in use:

if ( ! $force_db && wp_using_ext_object_cache() ) {
    return;
}

Check whether your transients are even in the database

This is the step the top guides skip, and it decides whether any of the rest applies. If wp-content/object-cache.php exists and the drop-in is working, set_transient() calls wp_cache_set() with the group transient and never touches wp_options at all.

wp eval 'var_dump( wp_using_ext_object_cache() );'

If that prints true, a database cleaner will scan your options table, find nothing, and report success. Your transients are in Redis or Memcached, where they expire on their own and need no cleanup.

People spend an afternoon on this before realising the rows were never there.

If it prints false, as it does on a default install and on every SandyWP sandbox, the rows are real and you can count them.

Count what you have before deleting anything

Four queries, in the order worth running. From a shell with WP-CLI:

# 1. How many transient rows exist at all
wp db query "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '\_transient\_%';"

# 2. How many are expired right now
wp db query "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '\_transient\_timeout\_%' AND option_value < UNIX_TIMESTAMP();"

# 3. The ones that cost you on every request: no expiry, autoloaded
wp option list --autoload=on --search='_transient_*' --fields=option_name,size_bytes \
  --orderby=size_bytes --order=desc --format=table

# 4. Total autoloaded weight, transients and everything else
wp eval 'printf( "%d KB autoloaded\n", strlen( serialize( wp_load_alloptions() ) ) / 1024 );'

Query 3 is the one that finds real problems. A site with 40,000 expired transients and 30KB of autoload is fine, and core will tidy it tonight.

A site with 200 transients where one of them is a 2MB autoloaded product feed is slow on every request.

Anything over roughly 800KB of autoloaded options is worth investigating. Site Health has flagged this since WordPress 6.6, and we wrote up how to read that page and which of its tests are about your server rather than your site.

Deleting them with WP-CLI, not a plugin

WP-CLI ships a transient command, so the cleanup needs no plugin and no phpMyAdmin:

wp transient list --format=table          # see them, with expiry
wp transient delete --expired             # the safe one
wp transient delete --all                 # everything, including live caches
wp transient delete --expired --network   # site transients on multisite

--expired is the version you can run on production without thinking about it. It targets exactly the rows core's cron would have removed, and it works in the object-cache case too by passing $force_db.

--all is a different proposition. It is usually fine, because a well-written transient is a cache with a rebuild path, and the next request regenerates it.

The exceptions are the plugins that use transients as cheap state rather than as cache: a licence check result, an import cursor, a rate-limit counter, a half-finished background job. Deleting those does not rebuild anything, it resets something.

Orphaned timeout rows, and why the counts never match

You will often find more _transient_timeout_ rows than _transient_ rows, or the reverse. That is normal and comes from three places.

A direct DELETE in phpMyAdmin that matched _transient_% without matching the timeout prefix separately leaves the timeouts behind. The LIKE '\_transient\_%' pattern does match the timeout rows too, since _transient_timeout_foo starts with _transient_, which is why ad-hoc SQL on this table goes wrong in both directions.

A crashed request between the two add_option() calls leaves a value with no timeout, which then behaves as a transient that never expires. And site transients use a different prefix entirely, _site_transient_, stored in wp_sitemeta on multisite and in wp_options on a single site, so any query written only for _transient_ silently misses them.

Core's second cleanup pass handles the orphaned timeouts. Nothing in core handles a value row whose timeout went missing, which is the one case where a manual look is justified.

Find out which plugin is writing them

Knowing you have 60,000 transient rows is not actionable. Knowing that 59,000 of them are named _transient_jetpack_* or _transient_wc_product_* is.

wp db query "SELECT SUBSTRING_INDEX(REPLACE(option_name,'_transient_',''),'_',2) AS prefix,
             COUNT(*) AS n, SUM(LENGTH(option_value)) AS bytes
             FROM wp_options WHERE option_name LIKE '\_transient\_%'
             GROUP BY prefix ORDER BY n DESC LIMIT 15;"

The prefix is the plugin. From there the fix is a plugin setting or a bug report, not a nightly cleaner, and a cleaner that runs every night against a plugin writing 2,000 rows an hour is treating a symptom.

Test the delete before you run it on a live site

This is where a throwaway copy earns its keep, and it is the honest answer to "is it safe to delete all transients".

Nobody can tell you in the abstract, because the answer depends on which plugins on your site treat a transient as state. What you can do is clone the site, run wp transient delete --all on the copy, and then use it: log in, load the shop, run the checkout, open the admin screens that matter.

With SandyWP that is a Cloner push from the live site into a fresh sandbox, then:

sandywp ssh my-copy
wp transient delete --all

If something breaks, you found a plugin using transients as state and you have its name. If nothing breaks, you have your answer with evidence rather than a shrug.

And since the sandbox is disposable, resetting it and trying the narrower --expired version costs nothing.

The same copy is the right place to test the opposite experiment: delete the autoloaded transients you found in query 3 and see whether anything notices. On production that is a gamble, on a copy it is a measurement.

When transients are the wrong tool

Three cases where the answer is not "clean up the transients".

If you are storing something that must not vanish, a transient is the wrong container. It can be evicted by an object cache under memory pressure at any time, expiry or not, so an import cursor or a payment state belongs in its own option or a custom table.

If you need the value on every request, an autoloaded option you manage yourself is clearer than an expiry-less transient that looks temporary and is not.

And if your transient table is enormous because something is caching per-URL or per-user data, the fix is the caching key, not the cleanup schedule. A transient per visitor is a design problem that no nightly job will solve.

The short version

Core deletes expired transients daily, so the pile-up story is about sites where WP-Cron is broken or where an object cache is in play. Run wp transient delete --expired if you want it done now.

The number worth looking at is not the row count, it is the autoloaded bytes from query 3 and 4. And before --all goes anywhere near a live site, run it on a disposable copy and click around, because the one plugin that uses a transient as state is the one that will not tell you until checkout fails.