Transients and the Options Table Tax
Somewhere in your WordPress database is a table called wp_options. It was designed to hold your site's settings — the site title, the timezone, which theme is active. A few hundred small rows, read once per request, cached in memory. Tidy.
Then the ecosystem happened. Plugins discovered that wp_options is the easiest place in WordPress to store anything at all, and WordPress itself built its caching system — transients — on top of the same table. The result, on a typical site that's a few years old, is an options table doing triple duty as a settings store, a cache backend, and a junk drawer. And because of one specific mechanism called autoloading, a chunk of that junk drawer gets loaded into memory on every single request your site serves, forever, whether anything uses it or not.
That's the tax. It's invisible in any single request, it grows a little every month, and nobody budgeted for it. Let's itemize the bill.
How the options table is supposed to work
Each row in wp_options has four columns that matter: a name, a value, an ID, and a flag called autoload. On every request, WordPress runs one early query — effectively SELECT option_name, option_value FROM wp_options WHERE autoload = 'yes' — and keeps the results in memory. Any later get_option() call for an autoloaded option is free: no query, just a memory lookup.
This is a genuinely good design at the intended scale. Loading 300 small settings in one query beats 300 individual queries by a mile. The design assumption, though, is that autoloaded data stays small — a few hundred kilobytes of actual settings. The failure mode is that autoload='yes' was the historical default for add_option() and update_option(), so for years, every plugin that didn't explicitly opt out was signing its data up for the every-request memory load. A 500KB serialized array of 'related posts analysis'? Autoloaded. A page builder's per-template CSS cache? Autoloaded. Logs from a plugin you deleted years ago? Still there, still autoloaded, still riding along on every page view.
Rules of thumb worth memorizing: total autoloaded size under ~800KB is comfortable; 1–2MB and you're paying measurable overhead on every request; past 3–5MB it's a primary performance problem — that's megabytes fetched from MySQL, transferred, and unserialized into PHP structures before WordPress has done any actual work. (Recent WordPress versions — 6.6 onward — stopped autoloading very large new options by default, which helps new sites. It doesn't clean up the sediment on old ones.)
Enter transients: caching with a catch
Transients are WordPress's official temporary-data API, and the interface is genuinely nice:
set_transient( 'weather_data', $data, HOUR_IN_SECONDS );$data = get_transient( 'weather_data' ); // false if expired or missing
Fetch something expensive — an external API response, a complex query result, a rendered fragment — store it with an expiry, and read it cheaply until it lapses. Every well-behaved plugin uses transients, and they're the right tool for the job. The catch is in the word 'expiry,' and in where transients actually live.
Without a persistent object cache, transients are just options rows. Each transient becomes two rows in wp_options: one for the value (_transient_weather_data) and one for the expiration timestamp (_transient_timeout_weather_data). And here's the part that surprises nearly everyone: expiry is lazy. WordPress does not run a cleanup process that deletes transients when their time is up. An expired transient is deleted only when something asks for it again and WordPress notices it's stale. A transient that's never requested again — a cache keyed to a search query, a user session, a URL parameter — never expires in any practical sense. It just sits there.
Now consider a plugin that creates per-item transients: one per product price lookup, one per visitor geolocation, one per unique search. Each key is used once and never read again. None of them are ever deleted. Sites routinely accumulate tens of thousands of orphaned transient rows this way; in pathological cases the options table — remember, the settings table — grows to hundreds of megabytes. WordPress core added a weekly scheduled cleanup of expired transients (via WP-Cron) in 4.9, which caps the worst accumulation, but it only fires if WP-Cron itself is healthy, and it does nothing about transients set with no expiry at all — those are stored effectively as permanent options, and depending on WordPress version and how they were created, may even end up autoloaded.
So the full tax return reads: a caching system, designed to make your site faster, that by default writes to your most performance-sensitive table, cleans up after itself only lazily, and can leak its cache entries into the memory-loaded-on-every-request set. It's not a bug. It's a stack of individually reasonable decisions whose sum you're paying for.
Audit: find out what you're actually paying
Twenty minutes with WP-CLI (or phpMyAdmin, if you must) gives you the full picture. Take a database backup first — everything below is read-only until the cleanup section, but you'll want the backup for that part anyway.
1. Total autoloaded size
wp option list --autoload=on --format=total_bytes
Compare against the thresholds above. This single number is the best one-line health check the options table has.
2. The biggest autoloaded rows
wp option list --autoload=on --fields=option_name,size_bytes --format=csv | sort -t, -k2 -n -r | head -20
Read the names. You'll recognize plugins you still use (fine, maybe), plugins you deleted years ago (pure waste), and rows starting with _transient_ (cache data in the every-request set — a red flag worth investigating).
3. Transient population
wp transient list --format=count
Or the SQL version, which also shows the expired share:
SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '\_transient\_%';
Calibration: a few hundred transients is normal metabolism. Thousands warrant a look at which prefixes dominate (group by the characters after _transient_ — the naming usually identifies the plugin). Tens of thousands means some plugin is generating unique cache keys per request, and that plugin is the actual problem — cleanup without fixing it is mopping with the tap running.
Cleanup: paying down the balance
In order, safest first — and after the backup you already took:
- Delete expired transients.
wp transient delete --expired. Completely safe by definition: nothing can legitimately read an expired transient. - Delete all transients.
wp transient delete --all. Also safe in the sense that transients are, by contract, disposable — any plugin must be able to rebuild them. The cost is a temporarily cold cache: the next requests re-do the expensive work. Do it during a quiet hour, not mid-campaign. - Remove orphaned plugin options. For each recognizable dead plugin in your top-20 list:
wp option delete old_plugin_option_name. This is the manual, judgment-required step — when unsure whether an option belongs to something living, search the plugin directory's code for the option name before deleting. - De-autoload the legitimate-but-large. Some data should exist but has no business loading on every request — an admin-only report cache, an import log. Flip the flag:
wp option set-autoload heavy_option_name off. The data stays; it now loads only when actually requested. (One honest caveat: options fetched on every request genuinely belong in autoload — moving those out trades one big query for many small ones. De-autoload the rarely-read, not the always-read.) - Re-measure. Run the total-bytes command again. On a neglected site, going from 4MB autoloaded to 600KB is a common outcome, and it's a improvement every uncached request enjoys — admin included, which is why the dashboard often feels snappier immediately.
The structural fix: a persistent object cache
Everything above manages the symptom. The cure is changing where transients live. When a persistent object cache — Redis, typically, via a host-provided drop-in — is active, WordPress routes the entire transient API to it automatically. set_transient() writes to Redis, not MySQL. Expiry becomes real: Redis evicts expired keys itself, natively, no lazy cleanup, no WP-Cron dependency. The options table goes back to being a settings table.
| Behavior | Without object cache | With Redis/Memcached |
|---|---|---|
| Transients stored in | wp_options (MySQL) | Memory |
| Expiry | Lazy — on next read, plus weekly cron sweep | Native, automatic |
| Orphan accumulation | Yes, unbounded for never-read keys | No |
| Read cost | Query (or autoload memory) | Memory lookup |
| Survives restart | Yes | No — and that's fine; it's a cache |
Most managed WordPress hosts offer Redis as a checkbox plus a drop-in plugin (Redis Object Cache being the standard). If your host offers it and your site has real traffic or a large database, turning it on is the single highest-leverage move in this entire article. Two footnotes for the diligent: first, clean up the existing transient rows in MySQL after enabling Redis — routing new writes to memory doesn't remove the old sediment. Second, a rare gotcha: a handful of plugins misuse transients as short-term storage (form tokens, one-time links) and assume database persistence; if something odd breaks right after enabling the object cache, that's the pattern to suspect — and the plugin to report a bug against, because the transient contract has never guaranteed persistence.
For the developers in the room
If you write plugin or theme code, the tax is partly levied by people like us. House rules:
- Pass the autoload argument explicitly.
update_option( 'my_report_cache', $data, false )— that third argument is the difference between a row that's read on demand and one that rides every request. Settings read on every page load: autoload. Everything else: don't. - Always set a transient expiry. A transient with no expiration is a permanent option wearing a disguise.
- Never generate unbounded transient keys. One transient per search term, URL, or visitor is a database leak, not a cache. Cache the bounded thing (the dataset), not the unbounded thing (the query).
- Delete your data on uninstall. An
uninstall.phpthat removes your options and transients is the difference between a guest and a squatter.
The short version
WordPress's options table starts every request, and everything you let accumulate there is a toll paid by every visitor and every admin click. The audit takes twenty minutes: measure autoloaded bytes against the ~800KB comfort line, count transients, read the names of the biggest rows. The cleanup takes an hour: expired transients, dead plugins' leftovers, de-autoload the heavyweights. The structural fix takes a checkbox, if your host offers Redis. None of this is glamorous — it's the database equivalent of cleaning the gutters. But it's the rare performance work where the numbers are legible before and after, the risk is low, and the improvement applies to literally every request your site will ever serve. Taxes should be this easy to lower.
Photo: https://kaboompics.com/ on Pexels