The usual way to test a plugin upgrade routine is to deactivate the plugin, drop in the new files, reactivate, and check the database changed. That test passes on code your users never run.
WordPress does not deactivate a plugin when it updates it, so register_activation_hook() never fires on an update. Your migration runs from a version comparison on a normal page load instead, under conditions the deactivate-reactivate test removes.
This post covers the four documented reasons an upgrade-routine test gives a false pass, and a routine that puts the real path under test on a disposable copy.
Where the upgrade code actually runs
There is no "my plugin was updated" hook. The standard pattern, and the one every guide recommends, is to keep a schema version constant in the code, keep the last-migrated version in an option, and compare the two on plugins_loaded or admin_init.
add_action( 'plugins_loaded', function () {
$stored = get_option( 'myplugin_db_version', '0' );
if ( version_compare( $stored, MYPLUGIN_DB_VERSION, '<' ) ) {
myplugin_migrate( $stored );
update_option( 'myplugin_db_version', MYPLUGIN_DB_VERSION );
}
} );
That means three things are true at migration time, and none of them are true in the deactivate-reactivate test.
The plugin is already active. The old data is already in the database, in whatever shape the old version left it. And the request is a normal front-end or admin request, possibly one of several arriving at once.
Reason 1: you tested activation, not update
register_activation_hook() runs when someone activates the plugin. Updating a plugin through the admin, through WP-CLI, or by pushing new files does not deactivate it first, so the activation hook does not fire.
If your table creation lives only in the activation hook and your migration lives only in the version compare, an install that has never been deactivated will run the migration against tables the activation hook created years ago, at whatever schema that version used. That is a different starting state from a fresh activation.
Test both paths separately. A fresh install and an upgraded install are two different databases that happen to run the same code.
Reason 2: upgrader_process_complete fires under the old code
If you wired the migration to upgrader_process_complete instead, the hook reference is explicit about what happens:
When you use the
upgrader_process_completeaction hook in your plugin and your plugin is the one which under upgrade, then this action will run the old version of your plugin.
PHP loaded the old files at the start of the request. The new files landed on disk during it. Your callback is the old callback, with the old constants and the old class definitions, so a migration that references anything added in the new version fatals or silently does nothing.
This one is easy to miss in testing, because the hook does fire, the log shows it fired, and the site keeps working. The schema just never changes.
Reason 3: you tested one version hop
You have version 3.4 installed and you upgrade to 3.5. That is the only jump you ever test, because it is the only jump you have set up.
Your users are not all on 3.4. Some are on 2.9 and have auto-updates off, and when they finally update they go straight to 3.5 in one step. Every migration between the two has to run, in order, in one request.
The fix in code is to make each migration idempotent and to loop from the stored version forward rather than branching on "the previous release". The fix in testing is to install an old version deliberately:
wp plugin install my-plugin --version=2.9.0 --force
WP-CLI documents --version as getting "that particular version from wordpress.org, instead of the stable version", and --force as overwriting "any installed version of the plugin, without prompting for confirmation". That is your downgrade. Test the longest hop you still support, not the shortest one.
Reason 4: dbDelta did less than the result array says
Most schema migrations call dbDelta(), and its documented behaviour is narrower than the name suggests.
It will not rename a column. Per the function reference: "If you change the name of a field, an empty column with the new name will be created, but the old column is not removed." Your test then reads the new column, finds it exists, and passes, while every row in it is empty and the real data is still sitting in the old column.
It ignores FOREIGN KEY, ignores column and key comments, and is case-sensitive about lowercase data types. If the statement contains IF NOT EXISTS and the table already exists, it does nothing at all, "which is the whole point of 'delta'".
And the return value is not evidence. The docs note the result array "may say 'Created table {yourtablename}' even if the table was not created, but should have been". Assert against DESCRIBE, never against what dbDelta() told you.
The routine
Do this on a throwaway copy, not on staging. A staging site has already been through your migrations once, so its schema is the post-migration schema and the test cannot fail the way a real user's site fails.
Spin up a disposable sandbox, or launch one from a template that already has the surrounding stack, and open a shell with SSH.
1. Install the oldest version you support and use it properly.
wp plugin install my-plugin --version=2.9.0 --force --activate
Then create real data through the UI: the settings, the custom post type entries, the rows in your custom table. A migration against an empty table always succeeds. The bugs live in rows with NULL where the new schema expects a value, in serialised options written by a version that used a different array shape, and in the one row someone entered an emoji into.
2. Snapshot the schema and the data.
wp db query "DESCRIBE wp_myplugin_items" --skip-column-names > /tmp/before-schema.txt
wp db query "SELECT COUNT(*) FROM wp_myplugin_items" --skip-column-names
wp option get myplugin_db_version
wp option get myplugin_settings --format=json > /tmp/before-settings.json
3. Upgrade the way a user does.
wp plugin install my-plugin --version=3.5.0 --force
No deactivation, no reactivation. The plugin stays active across the swap, exactly as it does in the admin.
4. Trigger a page load, because the swap is not the migration.
The files changed, but your version compare runs on plugins_loaded. Nothing has run it yet.
curl -s -o /dev/null -w '%{http_code}\n' https://your-sandbox.sandywp.site/
wp option get myplugin_db_version
If the version option still reads 2.9.0, the migration did not run, and you have found the bug before your users did.
5. Diff, and assert on the data rather than the schema.
wp db query "DESCRIBE wp_myplugin_items" --skip-column-names > /tmp/after-schema.txt
diff /tmp/before-schema.txt /tmp/after-schema.txt
wp db query "SELECT COUNT(*) FROM wp_myplugin_items WHERE new_column IS NULL"
A new column that exists and is entirely NULL is the dbDelta rename failure from reason 4. The schema diff alone would have shown a pass.
6. Turn on debug mode and run the whole thing again.
Migrations produce warnings rather than fatals: undefined array keys from an option that changed shape, deprecated calls, $wpdb errors that get swallowed. WP_DEBUG_LOG catches them; the site loading fine does not.
The failure the routine will not catch
Two concurrent requests can both read the version option before either writes it, and both run the migration. WordPress's own option API makes this possible, and it is a known problem: core ticket #25623 covers the general shape of it, and Yoast SEO hit it in their own upgrade routine.
A quiet sandbox has one request at a time, so it never happens there. If your migration is not safe to run twice, that has to be handled in code with a lock, and reasoned about rather than tested. Making every step idempotent is cheaper than making it exclusive.
The other thing a sandbox will not tell you is time. An unbatched UPDATE over a table with two million rows finishes instantly on your test data and hits the host's execution limit on a real store. If your migration touches an unbounded table, batch it and schedule the batches, and size the test data to match the largest install you know about.
Run it on the pull request
The version you actually ship is the one in the pull request, not the one on your laptop. Wiring the sandbox to GitHub gets a real install per PR, so the migration test runs against the branch under review rather than against whatever your working tree contained that afternoon.
The whole routine is a handful of WP-CLI commands, which makes it scriptable through the CLI and cheap to run on every release rather than on the ones you remember to worry about. If you are shipping a plugin to other people's sites, it belongs in the release checklist next to testing that it uninstalls cleanly.
Migration bugs are the expensive kind. They land silently, they land on production, and by the time someone notices the data has been in the wrong shape for a week.
