Skip to main content
Adzbyte
TutorialsWordPress

Safe Content Migrations with WP-CLI

Adrian Saycon
Adrian Saycon
September 16, 20265 min read
Safe Content Migrations with WP-CLI

A safe WordPress content migration is repeatable, observable, and reversible. WP-CLI gives long-running work a better environment than a browser request, but the command still needs a dry run, bounded batches, idempotent writes, progress output, and a rollback strategy. This tutorial migrates a legacy subtitle custom field to adz_subtitle without deleting the source. The pattern works for taxonomy, block, and metadata migrations because it treats the command as an operational tool rather than a one-off loop. Each safeguard has a specific failure mode that you can test before production.

Define completion before writing the command

The migration contract is simple: copy a non-empty legacy value when the destination is empty; never overwrite a destination value; mark migrated posts with a version; preserve the source until verification is complete. A second run should make no additional changes.

Write down counts you expect before production: eligible records, already migrated records, conflicts, skips, and failures. Those numbers become the audit trail and tell you whether a “successful” command actually covered the intended population.

Register a focused WP-CLI command

Load the command only when WP-CLI is active. Accept --dry-run, --batch-size, and --after-id so operators can preview, constrain, and resume work.

if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'adz migrate-subtitles', 'Adz_Subtitle_Migration_Command' );
}

class Adz_Subtitle_Migration_Command {
    public function __invoke( $args, $assoc_args ) {
        $dry_run   = WP_CLIUtilsget_flag_value( $assoc_args, 'dry-run', false );
        $batch     = max( 1, min( 500, (int) ( $assoc_args['batch-size'] ?? 100 ) ) );
        $after_id  = max( 0, (int) ( $assoc_args['after-id'] ?? 0 ) );

        $this->run( $dry_run, $batch, $after_id );
    }
}

These are the command entry point and its arguments; the run() implementation combines the selection and record-processing fragments below. Bound the batch size even for privileged CLI users. Accidental values such as one million should not turn a controlled operation into a memory incident.

Page by stable IDs instead of offsets

Offsets become unreliable when the dataset changes during a migration. Query IDs greater than the last processed ID and order ascending.

private function next_ids( $after_id, $batch ) {
    global $wpdb;

    return $wpdb->get_col( $wpdb->prepare(
        "SELECT p.ID
         FROM {$wpdb->posts} p
         INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
         WHERE p.ID > %d
           AND p.post_type = 'post'
           AND pm.meta_key = 'subtitle'
         GROUP BY p.ID
         ORDER BY p.ID ASC
         LIMIT %d",
        $after_id,
        $batch
    ) );
}

Direct SQL is appropriate for selecting a large, precise set, but prepare dynamic values and use WordPress APIs for writes so metadata caches and hooks behave predictably.

Make each record idempotent

Classify every post before changing it. Skip completed records, flag destination conflicts, and write a migration version only after the destination update succeeds.

private function migrate_one( $post_id, $dry_run ) {
    $source = get_post_meta( $post_id, 'subtitle', true );
    $target = get_post_meta( $post_id, 'adz_subtitle', true );

    if ( '' === trim( (string) $source ) ) {
        return 'empty';
    }
    if ( '' !== (string) $target ) {
        return 'conflict';
    }
    if ( $dry_run ) {
        return 'would-migrate';
    }

    $ok = update_post_meta( $post_id, 'adz_subtitle', sanitize_text_field( $source ) );
    if ( false === $ok ) {
        return 'failed';
    }

    update_post_meta( $post_id, '_adz_subtitle_migration', '2026-09' );
    return 'migrated';
}

Do not treat an existing destination as safe to overwrite. A conflict may represent a manual correction or data created by the new system.

Report progress and a resumable cursor

Process one batch at a time, count each outcome, and print the last processed ID. If the command fails, that cursor provides a deliberate restart point.

do {
    $ids = $this->next_ids( $after_id, $batch );
    foreach ( $ids as $post_id ) {
        $result = $this->migrate_one( (int) $post_id, $dry_run );
        $counts[ $result ] = ( $counts[ $result ] ?? 0 ) + 1;
        $after_id = (int) $post_id;
    }
    wp_cache_flush();
    WP_CLI::log( "Processed through post ID {$after_id}" );
} while ( count( $ids ) === $batch );

For very large sites, avoid flushing all caches on every record. A batch boundary balances stale objects against unnecessary cache churn.

Run the migration as an operation

  1. Back up the database and verify that the backup can be restored.
  2. Run the command with --dry-run --batch-size=50 in staging.
  3. Review conflict and eligible counts against an independent query.
  4. Run a small real batch, inspect representative posts, and test front-end rendering.
  5. Run the remaining batches during a defined change window.
  6. Run the command again and confirm zero new migrations.
  7. Keep the legacy field through an observation period before scheduling cleanup.

A backup is not the only rollback mechanism. Because the source remains untouched, the application can temporarily read the old field first or a reversal command can remove only destination values bearing this migration’s version marker.

Protect against multisite and environment mistakes

On multisite, decide whether the command targets one site or iterates sites explicitly; do not assume the current blog is the network. Print the site URL, environment type, database prefix, dry-run state, and parameters before processing. For production, consider requiring a --yes flag after displaying that summary.

Avoid calling external services inside the migration loop unless retries and rate limits are designed. If enrichment is required, store a durable queue of IDs and separate selection from network work.

Run representative hooks in staging before deciding whether production should suppress them. Metadata updates may invalidate caches, rebuild search indexes, or notify external systems. Those effects might be necessary, dangerously expensive, or duplicative. Document the choice instead of removing hooks reflexively, and provide a reconciliation step for any downstream system intentionally bypassed during migration.

Keep migrations boring and inspectable

The best migration command can be interrupted halfway, rerun, explained from its logs, and reversed without improvisation. Stable ID pagination, explicit conflicts, and version markers create those properties. WP-CLI’s commands cookbook covers command registration and arguments; the production safety comes from the operational contract you build around those APIs.

Photo by Tima Miroshnichenko on Pexels.

Adrian Saycon

Written by

Adrian Saycon

A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.

Latest Articles

From the Blog

View all articles