Real-Time Engineering3,702 words

Safe WordPress Database Migrations with dbDelta and Online DDL

Run reliable WordPress custom-table migrations with dbDelta-compatible SQL, schema versioning, online DDL and reversible deployment safeguards.

ContentsKey takeaways

On this page0% read

moxseo wordpress database migrations dbdelta online ddl featured

Key takeaways

  • `dbDelta()` compares a CREATE TABLE statement with the live table and alters it without dropping data, but it is picky about formatting (two spaces after `PRIMARY KEY`, lowercase data types, and so on).
  • It can add columns, widen existing ones and create indexes. It cannot rename columns, drop removed ones or safely change foreign key constraints.
  • A versioned Migration Manager built from one class per step makes schema changes repeatable, even when a site jumps several plugin versions at once (say, v1.2 straight to v3.0).
  • On tables above roughly 500,000 rows a plain `ALTER TABLE` can block writes for minutes. Use MySQL 8.0 `ALGORITHM=INSTANT` where it applies, or move the work to Action Scheduler background batches.
  • Build the charset clause with `$wpdb->get_charset_collate()`. Otherwise you risk subtle corruption and “Illegal mix of collations” errors in JOINs, especially with multilingual UTF-8 data.
  • A shadow table swap (`RENAME TABLE v1 TO old, v2 TO v1`) restructures a table while orders keep flowing.
  • PHPUnit schema tests confirm that tables and indexes come out right on both MySQL 8.0 and MariaDB 10.11 runners.

Once a custom WordPress application or commercial plugin reaches real volume, the default data model (posts, postmeta, terms, options) stops scaling. High-volume transactional work such as ledger logging, multi-vendor commission accounting, webhook event streams or IoT sensor telemetry calls for dedicated custom MySQL tables.

Creating the table on first activation is only the beginning. Requirements change: you will need new columns, different data types, composite indexes, retired structures and backfills over millions of rows. A migration that fails can corrupt customer data, lock tables while customers are checking out, or throw fatal PHP errors, and a slow or locked store also hurts the speed and crawlability your SEO depends on.

WordPress ships dbDelta() to create tables and diff schemas, but it is known for strict parsing quirks, almost no error reporting and no support for dropping or renaming columns. This guide dissects how dbDelta() parses SQL, builds a PSR-4 Schema Migration Framework, handles zero-downtime MySQL 8.0 DDL, runs cursor-based Action Scheduler backfills, performs shadow table atomic swaps, covers foreign key integrity and collation conflicts, and finishes with PHPUnit integration tests and WP-CLI tooling.

How Strict `dbDelta()` Is About Syntax

dbDelta(), defined in wp-admin/includes/upgrade.php, has no real SQL grammar parser. It uses regular expressions to compare the existing table schema with the SQL string you hand it, so a stray space or a wrongly capitalized keyword can make it fail silently or issue the same ALTER TABLE on every page load.

`dbDelta()` Formatting Checklist

Syntax RequirementCorrect FormatIncorrect Format (Causes Failure)
Separate Line Per FieldEach column definition on its own separate line.Multiple fields declared on a single line separated by commas.
PRIMARY KEY WhitespacePRIMARY KEY (id) (Must have TWO spaces after KEY).PRIMARY KEY (id) (Single space causes regex failure).
Index KeywordKEY idx_user_id (user_id)INDEX idx_user_id (user_id) (`INDEX` is often ignored).
SQL Keyword CasingCREATE TABLE wp_custom ( in uppercase.create table wp_custom ( in lowercase.
Field Type Casingvarchar(255) NOT NULL (Types in lowercase, attributes uppercase).VARCHAR(255) not null
Charset & CollateAppend $wpdb->get_charset_collate(); at the end.Hardcoding DEFAULT CHARSET=utf8 (Breaks emojis / 4-byte UTF-8).

A Reliable `dbDelta()` DDL Template

This is the exact layout a dependable table definition needs:

<?php
declare(strict_types=1);

function wpstack_get_invoices_schema(): string {
    global $wpdb;
    $table_name = $wpdb->prefix . 'wpstack_invoices';
    $charset_collate = $wpdb->get_charset_collate();

    return "CREATE TABLE {$table_name} (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        order_id bigint(20) unsigned NOT NULL,
        customer_id bigint(20) unsigned NOT NULL,
        invoice_number varchar(64) NOT NULL DEFAULT '',
        total_amount decimal(12,4) NOT NULL DEFAULT '0.0000',
        currency varchar(3) NOT NULL DEFAULT 'USD',
        status varchar(32) NOT NULL DEFAULT 'pending',
        created_at datetime NOT NULL DEFAULT '1000-01-01 00:00:00',
        updated_at datetime NOT NULL DEFAULT '1000-01-01 00:00:00',
        PRIMARY KEY  (id),
        KEY idx_order_id (order_id),
        KEY idx_customer_status (customer_id, status),
        KEY idx_created_at (created_at)
    ) {$charset_collate};";
}

What `dbDelta()` Will Not Do

dbDelta() is good at creating tables and adding missing columns, but it has real limits you must plan around:

  • No column drops: Delete a field from the schema string and dbDelta() just ignores the omission. It never runs DROP COLUMN, which protects data from accidental loss.
  • No column renames: If you change user_id to customer_id, dbDelta() sees a brand-new column, adds it with default values and leaves the old user_id column and its data behind.
  • No primary key changes: Turning a single-column primary key into a composite key is not supported.
  • No foreign keys: dbDelta() neither parses nor maintains FOREIGN KEY ... REFERENCES constraints.
  • Not for heavy migrations on huge tables: Running dbDelta() in an admin request against a very large table risks PHP timeouts and HTTP 504 errors.

Collation and Character Set Traps in MySQL 8.0

A particularly sneaky failure shows up when a custom table is joined to a WordPress core table (for example INNER JOIN wp_users ON wp_wpstack_invoices.customer_id = wp_users.ID). If the two tables use different character sets or collations, MySQL stops with an error:

-- Fatal Database Error
Error: 1267 SQLSTATE: HY000 (ER_CANT_AGGREGATE_2COLLATIONS)
Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_unicode_520_ci,IMPLICIT) for operation '='

It happens when a collation is hardcoded or left to the server default. On WordPress 6.x with MySQL 8.0, core tables normally use utf8mb4_unicode_520_ci or utf8mb4_0900_ai_ci. Appending $wpdb->get_charset_collate() to your DDL makes the new table match them exactly.

Designing a Versioned PSR-4 Migration Engine

To work around those gaps, mature teams wrap schema changes in a versioned migration framework. It records every applied migration in its own store and runs schema diffs and data transformations in order.

1. The Interface (`MigrationInterface.php`)

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabaseMigrations;

interface MigrationInterface {
    /**
     * Unique semantic version or timestamp (e.g. "2026_10_01_001").
     */
    public function getVersion(): string;

    /**
     * Human-readable description of migration purpose.
     */
    public function getDescription(): string;

    /**
     * Execute forward migration logic.
     */
    public function up(): void;

    /**
     * Optional rollback logic for staging test environments.
     */
    public function down(): void;
}

2. A First Migration That Creates the Table (`Migration_2026_10_01_CreateInvoicesTable.php`)

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabaseMigrations;

use wpdb;

class Migration_2026_10_01_CreateInvoicesTable implements MigrationInterface {
    public function __construct(private readonly wpdb $db) {}

    public function getVersion(): string {
        return '2026_10_01_001';
    }

    public function getDescription(): string {
        return 'Create initial custom invoices table with composite indexes.';
    }

    public function up(): void {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        $tableName = $this->db->prefix . 'wpstack_invoices';
        $charsetCollate = $this->db->get_charset_collate();

        $sql = "CREATE TABLE {$tableName} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            order_id bigint(20) unsigned NOT NULL,
            customer_id bigint(20) unsigned NOT NULL,
            invoice_number varchar(64) NOT NULL DEFAULT '',
            total_amount decimal(12,4) NOT NULL DEFAULT '0.0000',
            status varchar(32) NOT NULL DEFAULT 'pending',
            created_at datetime NOT NULL DEFAULT '1000-01-01 00:00:00',
            PRIMARY KEY  (id),
            KEY idx_order_id (order_id),
            KEY idx_customer_status (customer_id, status)
        ) {$charsetCollate};";

        dbDelta($sql);
    }

    public function down(): void {
        $tableName = $this->db->prefix . 'wpstack_invoices';
        $this->db->query("DROP TABLE IF EXISTS {$tableName}");
    }
}

3. Renaming Columns and Backfilling Data

When an update has to rename a column or reshape data, combine dbDelta() with explicit transactional DDL statements:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabaseMigrations;

use wpdb;

class Migration_2026_10_15_AddTaxColumnsAndRenameStatus implements MigrationInterface {
    public function __construct(private readonly wpdb $db) {}

    public function getVersion(): string {
        return '2026_10_15_002';
    }

    public function getDescription(): string {
        return 'Add tax_rate column and safely migrate legacy status codes.';
    }

    public function up(): void {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        $tableName = $this->db->prefix . 'wpstack_invoices';
        $charsetCollate = $this->db->get_charset_collate();

        // 1. Use dbDelta to add new columns safely
        $sql = "CREATE TABLE {$tableName} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            order_id bigint(20) unsigned NOT NULL,
            customer_id bigint(20) unsigned NOT NULL,
            invoice_number varchar(64) NOT NULL DEFAULT '',
            total_amount decimal(12,4) NOT NULL DEFAULT '0.0000',
            tax_rate decimal(5,2) NOT NULL DEFAULT '0.00',
            tax_amount decimal(12,4) NOT NULL DEFAULT '0.0000',
            status varchar(32) NOT NULL DEFAULT 'pending',
            created_at datetime NOT NULL DEFAULT '1000-01-01 00:00:00',
            PRIMARY KEY  (id),
            KEY idx_order_id (order_id),
            KEY idx_customer_status (customer_id, status)
        ) {$charsetCollate};";

        dbDelta($sql);

        // 2. Execute transactional data transform
        $this->db->query("START TRANSACTION");
        try {
            // Transform legacy status values
            $this->db->query(
                "UPDATE {$tableName} 
                 SET status = 'paid_in_full' 
                 WHERE status = 'complete'"
            );

            // Compute default tax amounts for existing rows
            $this->db->query(
                "UPDATE {$tableName} 
                 SET tax_amount = total_amount * 0.20, tax_rate = 20.00 
                 WHERE tax_amount = 0.0000 AND status = 'paid_in_full'"
            );

            $this->db->query("COMMIT");
        } catch (Throwable $e) {
            $this->db->query("ROLLBACK");
            throw $e;
        }
    }

    public function down(): void {
        $tableName = $this->db->prefix . 'wpstack_invoices';
        $this->db->query("ALTER TABLE {$tableName} DROP COLUMN tax_rate, DROP COLUMN tax_amount");
    }
}

4. The Coordinator (`SchemaMigrationManager.php`)

The coordinator decides the run order, reads which migration versions are already applied from the database, and prevents anything running twice:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabase;

use wpdb;
use WPStackEnterprisePluginDatabaseMigrationsMigrationInterface;

class SchemaMigrationManager {
    private const OPTION_KEY = 'wpstack_applied_schema_migrations';

    /** @var list */
    private array $migrations = [];

    public function __construct(private readonly wpdb $db) {}

    public function registerMigration(MigrationInterface $migration): void {
        $this->migrations[] = $migration;
    }

    /**
     * Execute all pending migrations in chronological order.
     *
     * @return array{executed: list, skipped: list}
     */
    public function migrate(): array {
        $applied = $this->getAppliedMigrations();
        $executed = [];
        $skipped = [];

        // Sort migrations by version string
        usort($this->migrations, static fn(MigrationInterface $a, MigrationInterface $b) => strcmp($a->getVersion(), $b->getVersion()));

        foreach ($this->migrations as $migration) {
            $version = $migration->getVersion();

            if (in_array($version, $applied, true)) {
                $skipped[] = $version;
                continue;
            }

            // Execute migration step
            $migration->up();

            // Record version as applied
            $applied[] = $version;
            update_option(self::OPTION_KEY, $applied, false);
            $executed[] = $version;
        }

        return [
            'executed' => $executed,
            'skipped'  => $skipped,
        ];
    }

    /**
     * Retrieve list of previously applied migration version tags.
     *
     * @return list
     */
    public function getAppliedMigrations(): array {
        $applied = get_option(self::OPTION_KEY, []);
        return is_array($applied) ? $applied : [];
    }
}

Backfilling Data in the Background with Action Scheduler

Suppose a migration adds a computed column to a table with 2,000,000+ rows. A single UPDATE wp_custom SET column = calc() locks the whole table for 45+ seconds, halts writes and causes replication lag.

Instead, keep the schema change separate from the heavy data transformation and process the data through an Action Scheduler queue that advances a cursor:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabaseQueue;

use wpdb;

class SchemaBackfillWorker {
    public const HOOK_NAME = 'wpstack_async_schema_backfill';
    public const GROUP_NAME = 'wpstack_migrations';
    private const BATCH_SIZE = 500;

    public function __construct(private readonly wpdb $db) {}

    public function register(): void {
        add_action(self::HOOK_NAME, [$this, 'processBatch'], 10, 1);
    }

    public function dispatchInitialJob(): void {
        if (!as_has_scheduled_action(self::HOOK_NAME, ['last_id' => 0], self::GROUP_NAME)) {
            as_schedule_single_action(time(), self::HOOK_NAME, ['last_id' => 0], self::GROUP_NAME);
        }
    }

    public function processBatch(int $lastId): void {
        $tableName = $this->db->prefix . 'wpstack_invoices';

        // Fetch primary keys of next batch using cursor pagination (avoids OFFSET performance cliff)
        $ids = $this->db->get_col(
            $this->db->prepare(
                "SELECT id FROM {$tableName} 
                 WHERE id > %d AND tax_amount = 0.0000 
                 ORDER BY id ASC 
                 LIMIT %d",
                $lastId,
                self::BATCH_SIZE
            )
        );

        if (empty($ids)) {
            // Backfill finished! Mark migration flag as complete
            update_option('wpstack_tax_backfill_completed', true, false);
            return;
        }

        $idPlaceholders = implode(',', array_map('intval', $ids));
        $maxProcessedId = max(array_map('intval', $ids));

        // Execute batch update on small subset of locked rows
        $this->db->query(
            "UPDATE {$tableName} 
             SET tax_amount = total_amount * 0.20, tax_rate = 20.00 
             WHERE id IN ({$idPlaceholders})"
        );

        // Schedule next batch immediately
        as_schedule_single_action(time(), self::HOOK_NAME, ['last_id' => $maxProcessedId], self::GROUP_NAME);
    }
}

Shadow Table Swaps Without Downtime

Changing primary keys, switching storage engines or shedding gigabytes of bloat is too risky to do in place on a busy store. The shadow table atomic swap pattern avoids that:

  1. Create the shadow table: Provision wp_wpstack_invoices_v2 with the final schema.
  2. Write to both tables: In the plugin’s repository layer, save each new record to v1 and v2 at the same time.
  3. Backfill history: Copy existing rows from v1 to v2 in chunks with Action Scheduler.
  4. Swap the tables: Rename both in one atomic MySQL statement, which completes instantly:
  5. Retire the old table: After seven days of clean telemetry, drop wp_wpstack_invoices_legacy_backup.

With this pattern, a structural change carries no downtime and no risk of losing data, which suits high-availability setups and custom plugin work alike. MoxSEO’s web development team can help plan and review such migrations; see our web development services.

Foreign Keys and Cascading Deletes in Application Code

WordPress core stays away from database-level foreign keys (such as FOREIGN KEY (user_id) REFERENCES wp_users(ID) ON DELETE CASCADE) so it keeps working with MyISAM and older storage engines. In plugins, leaning only on MySQL foreign keys can also deadlock against core’s deletion hooks.

A hybrid approach works better: solid database indexes, plus application-level listeners on WordPress deletion actions that cascade the cleanup:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabase;

use wpdb;

class DatabaseCascadeIntegrityManager {
    public function __construct(private readonly wpdb $db) {}

    public function register(): void {
        // Listen for WordPress user deletions
        add_action('deleted_user', [$this, 'handleUserDeleted'], 10, 2);
        // Listen for WooCommerce order deletions
        add_action('woocommerce_delete_order', [$this, 'handleOrderDeleted'], 10, 1);
    }

    public function handleUserDeleted(int $userId, ?int $reassignId): void {
        $tableName = $this->db->prefix . 'wpstack_invoices';

        if ($reassignId !== null && $reassignId > 0) {
            // Reassign customer invoices to target user
            $this->db->update($tableName, ['customer_id' => $reassignId], ['customer_id' => $userId]);
        } else {
            // Soft-delete or mark invoices as orphaned for accounting compliance
            $this->db->update($tableName, ['status' => 'anonymized_gdpr'], ['customer_id' => $userId]);
        }
    }

    public function handleOrderDeleted(int $orderId): void {
        $tableName = $this->db->prefix . 'wpstack_invoices';
        // Delete child invoices when parent order is permanently removed
        $this->db->delete($tableName, ['order_id' => $orderId]);
    }
}

Running Migrations Across a Multisite Network

On a Multisite network with thousands of sub-sites, running migrations in a single request at network activation will eventually exhaust PHP workers. Process sub-sites in cursor-based batches instead:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginDatabaseMultisite;

use WP_Site;
use WPStackEnterprisePluginDatabaseSchemaMigrationManager;

class MultisiteMigrationOrchestrator {
    public const ASYNC_HOOK = 'wpstack_multisite_batch_migrate';
    private const SITE_CHUNK_SIZE = 50;

    public function register(): void {
        add_action('wp_initialize_site', [$this, 'migrateNewSite'], 10, 1);
        add_action(self::ASYNC_HOOK, [$this, 'processSiteBatch'], 10, 1);
    }

    public function migrateNewSite(WP_Site $site): void {
        switch_to_blog((int) $site->blog_id);
        try {
            global $wpdb;
            $manager = new SchemaMigrationManager($wpdb);
            $manager->migrate();
        } finally {
            restore_current_blog();
        }
    }

    public function processSiteBatch(int $lastSiteId): void {
        $sites = get_sites([
            'number'   => self::SITE_CHUNK_SIZE,
            'site__gt' => $lastSiteId,
            'public'   => 1,
            'spam'     => 0,
            'deleted'  => 0,
        ]);

        if (empty($sites)) {
            update_site_option('wpstack_network_migration_complete', true);
            return;
        }

        $highestId = $lastSiteId;

        foreach ($sites as $site) {
            $siteId = (int) $site->blog_id;
            $highestId = max($highestId, $siteId);

            switch_to_blog($siteId);
            try {
                global $wpdb;
                $manager = new SchemaMigrationManager($wpdb);
                $manager->migrate();
            } finally {
                restore_current_blog();
            }
        }

        // Enqueue next subsite chunk
        as_schedule_single_action(time() + 2, self::ASYNC_HOOK, ['last_site_id' => $highestId], 'wpstack_migrations');
    }
}

Online Migrations for High-Traffic Tables

Once a custom table passes 500,000 rows, an ordinary ALTER TABLE run through dbDelta() can lock it for minutes, block WooCommerce checkouts and bring 504 Gateway Timeouts.

DDL OperationMySQL 8.0 AlgorithmLocking BehaviorProduction Impact
Add Column at Table EndALGORITHM=INSTANTLOCK=NONEZero downtime (Completes in < 10ms regardless of table size).
Add Secondary IndexALGORITHM=INPLACELOCK=NONEReads and concurrent writes remain fully active during build.
Modify Column Data TypeALGORITHM=COPYLOCK=SHAREDHigh Risk: Locks table writes. Must use Action Scheduler batching.
Add Foreign Key ConstraintALGORITHM=INPLACELOCK=NONERequires validation scan; minimal concurrency impact.

Incident Walkthrough: A Table Lockout on Black Friday

Take an online luxury goods store running custom order-fulfillment plugins whose checkout failed completely on Black Friday. At 00:05 UTC an automated plugin update ran dbDelta() inside a plugins_loaded hook to add an index to an 8-million-row table.

1. What Happened and Why

  • Metadata lock pile-up: The ALTER TABLE asked for an exclusive metadata lock, but checkout transactions still held read locks. MySQL queued the DDL and every new order write queued behind it.
  • Worker exhaustion: Within 40 seconds PHP-FPM hit its 256-worker limit and the whole store returned 504 Gateway Timeouts.

2. How It Was Fixed

  1. Killed the blocked DDL: The on-call team ran SHOW PROCESSLIST and killed the waiting ALTER TABLE thread, which restored checkout immediately.
  2. Moved to online DDL: They added the composite index with ALTER TABLE wp_orders ADD KEY idx_status (status), ALGORITHM=INPLACE, LOCK=NONE; in a planned off-peak window.
  3. Changed the policy: Synchronous dbDelta() calls in general page-load hooks were banned, and the versioned SchemaMigrationManager now runs only from WP-CLI deploy scripts.

Managing Schema Changes with WP-CLI

These WP-CLI commands expose migrations to DevOps automation pipelines:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginCli;

use WP_CLI;
use WPStackEnterprisePluginDatabaseSchemaMigrationManager;
use WPStackEnterprisePluginDatabaseMigrationsMigration_2026_10_01_CreateInvoicesTable;
use WPStackEnterprisePluginDatabaseMigrationsMigration_2026_10_15_AddTaxColumnsAndRenameStatus;

class SchemaCliCommand {
    /**
     * Run all pending custom database schema migrations.
     *
     * ## EXAMPLES
     *
     *     wp wpstack-schema migrate
     */
    public function migrate(array $args, array $assocArgs): void {
        global $wpdb;
        $manager = new SchemaMigrationManager($wpdb);
        $manager->registerMigration(new Migration_2026_10_01_CreateInvoicesTable($wpdb));
        $manager->registerMigration(new Migration_2026_10_15_AddTaxColumnsAndRenameStatus($wpdb));

        WP_CLI::log('Starting database schema migration check...');
        $result = $manager->migrate();

        if (empty($result['executed'])) {
            WP_CLI::success('Database schema is already up to date. No pending migrations.');
            return;
        }

        foreach ($result['executed'] as $version) {
            WP_CLI::log("Successfully applied migration: [{$version}]");
        }

        WP_CLI::success('All pending schema migrations completed successfully.');
    }

    /**
     * Display status of applied and pending migrations.
     *
     * ## EXAMPLES
     *
     *     wp wpstack-schema status
     */
    public function status(array $args, array $assocArgs): void {
        global $wpdb;
        $manager = new SchemaMigrationManager($wpdb);
        $applied = $manager->getAppliedMigrations();

        WP_CLI::line('Applied Schema Migrations:');
        if (empty($applied)) {
            WP_CLI::line('  (None)');
            return;
        }

        foreach ($applied as $version) {
            WP_CLI::line("  - [X] {$version}");
        }
    }
}

if (defined('WP_CLI') && WP_CLI) {
    WP_CLI::add_command('wpstack-schema', SchemaCliCommand::class);
}

Testing Migrations with PHPUnit

PHPUnit exercises the full migration lifecycle and checks that tables are created, column changes apply cleanly and indexes exist:

<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use WPStackEnterprisePluginDatabaseSchemaMigrationManager;
use WPStackEnterprisePluginDatabaseMigrationsMigration_2026_10_01_CreateInvoicesTable;
use WPStackEnterprisePluginDatabaseMigrationsMigration_2026_10_15_AddTaxColumnsAndRenameStatus;

class Test_SchemaMigrationsIntegration extends WP_UnitTestCase {
    private SchemaMigrationManager $manager;
    private string $tableName;

    public function setUp(): void {
        parent::setUp();
        global $wpdb;

        $this->tableName = $wpdb->prefix . 'wpstack_invoices';
        $wpdb->query("DROP TABLE IF EXISTS {$this->tableName}");
        delete_option('wpstack_applied_schema_migrations');

        $this->manager = new SchemaMigrationManager($wpdb);
        $this->manager->registerMigration(new Migration_2026_10_01_CreateInvoicesTable($wpdb));
        $this->manager->registerMigration(new Migration_2026_10_15_AddTaxColumnsAndRenameStatus($wpdb));
    }

    public function tearDown(): void {
        global $wpdb;
        $wpdb->query("DROP TABLE IF EXISTS {$this->tableName}");
        parent::tearDown();
    }

    public function test_migrations_create_table_and_apply_incremental_columns(): void {
        global $wpdb;

        // 1. Execute migrations
        $result = $this->manager->migrate();
        $this->assertCount(2, $result['executed']);

        // 2. Verify table exists
        $tableExists = $wpdb->get_var("SHOW TABLES LIKE '{$this->tableName}'");
        $this->assertSame($this->tableName, $tableExists);

        // 3. Verify columns exist
        $columns = $wpdb->get_col("DESCRIBE {$this->tableName}", 0);
        $this->assertContains('id', $columns);
        $this->assertContains('order_id', $columns);
        $this->assertContains('tax_rate', $columns);
        $this->assertContains('tax_amount', $columns);

        // 4. Verify re-running migration performs zero duplicate executions
        $secondRun = $this->manager->migrate();
        $this->assertEmpty($secondRun['executed']);
        $this->assertCount(2, $secondRun['skipped']);
    }
}

Troubleshooting Migrations in Production

For complex custom-table migrations on multisite and larger installs, this table lists the usual symptoms, causes and fixes:

Issue SymptomRoot CauseResolution Procedure
dbDelta() fails to add index or executes duplicate ALTERsOnly one space after PRIMARY KEY, or INDEX used where KEY is required.Write PRIMARY KEY (id) with exactly two spaces between `KEY` and `(`, and declare every index as `KEY name (col)`.
Foreign key constraint creation failsThe child and parent columns have different data types (for example INT(11) versus BIGINT(20) UNSIGNED).Make sure the referenced ID columns have the same data types and collations as the parent WordPress tables.
Character corruption / emojis broken in custom tableThe table was created with the default latin1 instead of the WordPress table collation.Put $wpdb->get_charset_collate() at the end of every CREATE TABLE string.
Table lock timeout on ALTER TABLEA long-running read on the custom table is holding up the DDL statement.Use MySQL 8.0 ALGORITHM=INSTANT, or schedule the change for an off-peak maintenance window.
Migration runs on every page loadThe applied version is not being saved in wp_options, or the version comparison has a bug.Confirm that update_option('wpstack_applied_schema_migrations', $applied, false) actually stores the version tags.
Multisite sub-sites missing custom table schemasThe migration ran only on the main site (blog ID 1) during network activation.Hook the migration manager into wp_initialize_site so each new sub-site gets its schema.

Common Questions

What does `dbDelta()` do in WordPress?

`dbDelta()` is a core helper that compares a CREATE TABLE statement with the existing table and runs whatever ALTER TABLE statements are needed to update the schema, without dropping existing data.

Why does `dbDelta()` need two spaces after `PRIMARY KEY`?

Its parser is a set of regular expressions, and the pattern expects `PRIMARY KEY (` with two spaces. With one space the match fails and the primary key is skipped.

Will `dbDelta()` remove columns I no longer use?

No. To avoid accidental data loss it never issues `DROP COLUMN`. Remove obsolete columns yourself with an explicit `ALTER TABLE table_name DROP COLUMN column_name`.

What is the safe way to rename a column?

Do it in phases: add the new column with `dbDelta()`, copy the data across with a background Action Scheduler task, switch your code to read the new column, and drop the old column in a later release.

What does MySQL 8.0 `ALGORITHM=INSTANT` do for WordPress plugins?

It lets MySQL add a column by changing only the table metadata, which takes milliseconds and avoids copying table data or locking reads and writes.

Why use `$wpdb->get_charset_collate()` for custom tables?

It gives the table the same character set and collation as the rest of the WordPress database (for example `utf8mb4_unicode_520_ci`), so emoji store correctly and JOINs against core tables do not hit collation conflicts.

How can I test schema migrations in CI?

Use PHPUnit with `WP_UnitTestCase`: run the migration classes in order against a clean MySQL test database, then use `SHOW TABLES` and `DESCRIBE table_name` to assert the expected columns and indexes exist.

How should migrations run on a Multisite network?

For per-site prefixed tables, migrate the current site on plugin activation, hook `wp_initialize_site` for sub-sites created later, and use an asynchronous Action Scheduler batch worker for the sub-sites that already exist.

Want this applied to your site?

A free 30-minute call with Sakshi Kumari or Ashish Khan, the specialists who run MoxSEO. Bring your site and your numbers; they will tell you what to fix first and what the data cannot tell you.

A call is not an engagement. Plans start at $699 a month, and we never guarantee rankings.