Skip to content
System nominalUTC --:--:--Articles 080
MODULE 02 Lesson 05 / 18 28 MIN

Data that outlives the request

Options, Transients and Upgrade Routines

The plugin's install function is safe to run twice, which is what makes it safe. What belongs in an option, what in a transient, and how to gate an upgrade.

Options are where a plugin's memory goes when a table is more machinery than the data deserves. They are also the easiest place in WordPress to lose a customer's configuration: one update_option() on a code path that runs during an upgrade, and the tracking prefix an owner set last year is back to its factory value. The plugin's install routine avoids that, but it avoids it in a way you have to read twice, and the same routine hides a problem that only shows up on stores that update plugins the normal way.

Everything here was run against a working WordPress 7.0.7, and the numbers below are from that run rather than from memory.

Three ways to remember something, and only one of them persists

An option lives in wp_options as a serialised value and stays until something deletes it. A transient also lives in wp_options, as a pair of rows with an expiry timestamp, and disappears when something asks for it after that timestamp. Neither is memory: both are database rows.

SELECT option_name, autoload, LENGTH(option_value) FROM wp_options
WHERE option_name LIKE 'sfct%' OR option_name LIKE '\_transient%sfct%';

On the lab install that query returns four rows for the plugin. Two are the settings and status options. Two are the halves of a transient, the value row and its _transient_timeout_ partner, and that is the shape to expect from every transient you set.

What the install routine actually does

Here is the function, quoted in full:

function sfct_install_default_options() {
	if ( ! get_option( 'sfct_settings' ) ) {
		add_option(
			'sfct_settings',
			array(
				'tracking_prefix' => 'SFCT',
				'tracking_format' => 'random',
				'random_length'   => 12,
				'api_token'       => wp_generate_password( 32, false, false ),
			)
		);
	}

	sfct_ensure_default_logistics_statuses();
}

The guard is the whole point. add_option() itself does nothing when the option already exists, so the if is defensive rather than load-bearing, and the same pattern appears one function down: sfct_ensure_default_logistics_statuses() reads get_option( 'sfct_logistics_statuses', null ) and returns early if it is a non-empty array.

Reversing the choice to update_option() would change the meaning of the code from "create these on a fresh install" to "reset these every time this runs". The measurable difference is the api_token. It is generated with wp_generate_password( 32, false, false ), which returns a 32-character alphanumeric string, and it is the key a courier's webhook uses to talk to the site. If a later version of the plugin ever replaced this call with an unconditional update_option(), every store would lose its webhook authentication on the next release, and the failure would look like the courier's problem.

Run the install twice on a store whose owner has already saved a tracking prefix of MINE and an owner-supplied API token, and both survive unchanged. That is the behaviour to protect in a test.

The autoload question, answered by the source rather than the docs

Every option carries an autoload flag. WordPress reads the preloadable ones in a single query on every request and keeps them in memory, which is fast when the list is short and expensive when a plugin adds a large value to it.

On this WordPress 7.0.7 install, both plugin options are stored with autoload = 'auto'. That is not the plugin choosing and it is not the add_option() default you might have in mind: modern WordPress writes auto and decides at preload time, so an option smaller than the preload threshold gets loaded on every request. The statuses option is 1,392 bytes, the settings option 158 bytes, and neither is large enough to justify turning autoload off. Two options of this size are not the problem. The pattern that is a problem is a plugin that writes a serialised array of its entire settings screen, or a list of cached API responses, into an option with autoload on, because that cost is paid on every request for pages that never touch it.

The rule that follows: an option is for configuration, which the plugin needs on most requests and which changes rarely. When you store a settings array, store the fields that the plugin reads, not a copy of the form it rendered. When the value is large or is a cache, it is a transient, and when it is per-object state, it belongs on that object.

A transient for state that must expire on its own

The plugin uses exactly one transient, and it uses it for a problem a transient is right for. When the plugin is activated before WooCommerce is present, it does not install anything. It records an intent and lets a later request act on it:

function sfct_plugin_activate( $network_wide = false ) {
	if ( ! sfct_is_woocommerce_active() ) {
		set_transient( 'sfct_woocommerce_dependency_redirect', '1', MINUTE_IN_SECONDS * 10 );
		return;
	}

	if ( function_exists( 'sfct_activate' ) ) {
		sfct_activate( $network_wide );
	}
}

The value is one byte. The behaviour that matters is the ten-minute expiry. A redirect that fires once and then expires is the correct shape for "show the person who activated this a setup screen"; an option would leave a flag on every request until something remembered to delete it, and on a site where the admin never visits the screen again it would sit there indefinitely.

What belongs in a transient: cache entries for expensive computation or a remote call, one-time flags, and anything whose absence is as acceptable as its presence. What does not belong in one: anything you cannot afford to lose at an arbitrary moment. Transients disappear without warning when a cache evicts them, and on a site with a persistent object cache they may never reach the database at all, so a plugin that treats a transient as storage has a bug that only reproduces under memory pressure.

An upgrade routine is two lines of code and one honest question

WordPress stores the database schema version it last installed in the db_version option, and runs its own upgrade routines when that number falls behind. The plugin borrowed the shape but not the storage: the lab install contains db_version, because WordPress put it there, and no plugin option equivalent. There is no sfct_db_version, no upgrader_process_complete handler and no version comparison anywhere in the source. sfct_install_tables() runs when the plugin is activated and never again.

The bootstrap registers that entry point against activation only:

register_activation_hook( __FILE__, 'sfct_plugin_activate' );
register_deactivation_hook( __FILE__, 'sfct_plugin_deactivate' );

Whatever the hook does, it does when a plugin is activated. WordPress's upgrader fires upgrader_process_complete and upgrader_post_install, and the install that replaces a plugin's files does not call activate_plugin(). So a store that updates from the dashboard never runs this code again. The next release that adds a column, an index or a table ships that schema to fresh installs and to nobody else, and the site keeps working on the old shape until the queries that wanted the new column start returning wrong answers.

A version gate fixes that, and the shape is short enough to keep in your head:

add_action( 'plugins_loaded', 'sfct_maybe_upgrade' );

function sfct_maybe_upgrade() {
	if ( SFCT_VERSION === get_option( 'sfct_db_version' ) ) {
		return;                       // the common case: one option read, nothing else
	}

	sfct_install_tables();            // idempotent, so re-running costs nothing but time
	sfct_run_schema_migrations();     // the DROP and RENAME statements dbDelta will not issue
	update_option( 'sfct_db_version', SFCT_VERSION, false );
}

Three things make it work. The comparison runs on load, so it repairs a site whose files were replaced without an activation. sfct_install_tables() is safe to re-run, which this lesson measured: the second call returned an empty array from both dbDelta() calls and changed nothing, and an install run against a logs table holding 5,000 rows left all 5,000 rows in place. And the third argument to update_option() is false, which turns autoloading off for a value that no request needs until the version changes. Measured cost of the version-gated path when nothing has changed: one option read, under a tenth of a millisecond. Measured cost of the ungated path on the same machine: about 1.7 ms of dbDelta() work every time, which is survivable but pointless, and grows with the number of tables and columns you describe.

Write the gate on plugins_loaded rather than init if the migration has to exist before other plugins query your tables. And treat the migration function as the place for the statements dbDelta() cannot write: the DROP COLUMN or DROP INDEX from the previous lesson, a RENAME, a backfill. Those are the operations that need a stored version, because unlike dbDelta() they are not idempotent, and running one twice is not harmless. A DROP COLUMN on a column that is already gone is an error; a backfill that runs twice doubles the data it was written to move.

Lab 25 minutes

Add a version gate to a plugin of your own, then try to break it.

Part one: write myplugin_maybe_upgrade() so it installs tables with dbDelta(), records the schema version in an option that does not autoload, and does nothing at all when the stored version is current. Register it on plugins_loaded.

Part two: prove the gate works. Add a counter to the function, bump the version constant through three fake releases, and load the site once per release. Then load it ten more times without changing the constant.

Part three: break it deliberately. Delete the stored version option on a site whose tables already have the new column, and load the site again. What has to be true about your migration statements for that to be safe?

Show the solution and the reasoning

Part one, in the shape the plugin would need. The option name carries the plugin's prefix so it cannot collide, and autoload is off:

define( 'MYPLUGIN_SCHEMA', '1.2.0' );

add_action( 'plugins_loaded', 'myplugin_maybe_upgrade' );

function myplugin_maybe_upgrade() {
	if ( get_option( 'myplugin_schema' ) === MYPLUGIN_SCHEMA ) {
		return;
	}

	myplugin_install_tables();
	myplugin_run_migrations();
	update_option( 'myplugin_schema', MYPLUGIN_SCHEMA, false );
}

Part two: with three version bumps you should see the work happen three times, once per release, and the ten extra loads should add nothing. If your counter still climbs after the constant stops changing, the usual causes are a strict comparison against a value WordPress returns as a string, an update_option() call that sits above the early return, or a dbDelta() call placed in the plugin's main file outside the gated function. The reason to count rather than eyeball it is that all three of those look correct when you read the code.

Part three is the interesting one, and there is no single right answer. Deleting the stored version forces the migration to run again on a schema that has already moved, so every statement has to be written to tolerate that. A CREATE TABLE IF NOT EXISTS and a dbDelta() call tolerate it by design. An ADD COLUMN does not, so it needs either dbDelta() or a check against information_schema before it runs. A DROP COLUMN does not, so it needs the same kind of check. A backfill is the one to think hardest about, because the safe version has to know which rows it has already processed; a WHERE migrated = 0 column, or a per-row marker, turns a repeated backfill into a no-op instead of a doubling.

The alternative is to make the version option impossible to lose, and you cannot. It lives in the same table as everything else and is restored from the same backups. The right posture is to assume a migration will run twice on a schema that has already changed, write every statement to survive that, and keep the version gate as an optimisation rather than as the thing that makes the migration correct. Once you take that position, the difference between running the install on every activation and gating it stops being about correctness. Both are correct. One of them does the work on every store after every update, and the other does not.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.