Database migrations are structured, version-controlled changes to your database schema or data that need to run exactly once and in a defined order. In vanilla WordPress plugin development there is no built-in migration runner like Laravel’s Artisan or Doctrine Migrations, but you can implement a reliable migration system in about 30 lines using the Options API and dbDelta(). The pattern is: store the current database version in a WordPress option; on every plugins_loaded or admin_init hook, compare the stored version against the required version defined in your code; if they differ, run the pending migration functions in order and update the stored version. dbDelta() is the WordPress-native function for creating and altering tables — it compares your desired SQL against the actual table structure and makes only the necessary changes, making it safe to call multiple times. For data migrations (renaming meta keys, transforming values, backfilling columns), you write plain $wpdb queries inside the migration function. Running migrations on plugins_loaded (not init) ensures they fire before any query-dependent code runs. This approach works for plugins and themes alike, and is especially important when your plugin is deployed to client sites where you cannot manually run SQL — the migration runs automatically on the next page load after a plugin update. Combine this with the safe $wpdb queries guide and transactions for production-safe migrations.
Problem: Your WordPress plugin needs to create or alter database tables and migrate data when it updates, without requiring manual SQL execution on each client site.
Solution: Add the following migration system to your plugin’s main file:
define( 'HELLOADMIN_DB_VERSION', '1.2.0' );
add_action( 'plugins_loaded', 'helloadmin_run_migrations' );
function helloadmin_run_migrations(): void {
$installed = get_option( 'helloadmin_db_version', '0.0.0' );
if ( version_compare( $installed, '1.0.0', '<' ) ) {
helloadmin_migration_1_0_0();
}
if ( version_compare( $installed, '1.1.0', '<' ) ) {
helloadmin_migration_1_1_0();
}
if ( version_compare( $installed, '1.2.0', '<' ) ) {
helloadmin_migration_1_2_0();
}
if ( $installed !== HELLOADMIN_DB_VERSION ) {
update_option( 'helloadmin_db_version', HELLOADMIN_DB_VERSION );
}
}
// Migration 1.0.0: create custom table
function helloadmin_migration_1_0_0(): void {
global $wpdb;
$table = $wpdb->prefix . 'helloadmin_logs';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
action VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql ); // safe to call multiple times
}
// Migration 1.1.0: add a column to existing table
function helloadmin_migration_1_1_0(): void {
global $wpdb;
$table = $wpdb->prefix . 'helloadmin_logs';
// Check if column already exists before adding it
$col = $wpdb->get_results( "SHOW COLUMNS FROM {$table} LIKE 'ip_address'" );
if ( empty( $col ) ) {
$wpdb->query( "ALTER TABLE {$table} ADD COLUMN ip_address VARCHAR(45) NOT NULL DEFAULT '' AFTER action" );
}
}
// Migration 1.2.0: rename a postmeta key across all posts
function helloadmin_migration_1_2_0(): void {
global $wpdb;
$wpdb->update(
$wpdb->postmeta,
[ 'meta_key' => 'helloadmin_new_key' ],
[ 'meta_key' => 'helloadmin_old_key' ]
);
}
NOTE: dbDelta() is very strict about SQL formatting: each column definition must be on its own line, there must be exactly two spaces between the column name and its type, and the PRIMARY KEY definition must be on its own line. Deviating from this format causes dbDelta() to re-run the ALTER TABLE statement on every page load. Always test your migration SQL in a staging environment before deploying to production. For large data migrations that touch thousands of rows, add a batch size limit and reschedule the remainder with a one-time WP-Cron event rather than doing everything in a single request.