Enterprise WordPress · 7 min read

WP-CLI at Scale: Automation, Audits and Safe Migrations

Batching, memory ceilings, custom commands, dry runs and resumable migrations - how WP-CLI behaves once the database is too big to hold in memory.

By Praful Patel · Last updated
WP-CLI at Scale: Automation, Audits and Safe Migrations - article cover
WP-CLI at Scale: Automation, Audits and Safe Migrations - article cover

Short answer

WP-CLI stops behaving like a convenience tool the moment the database is larger than memory. A loop over get_posts() that works on 500 rows will exhaust memory at 500,000. Everything below is about batching, resumability and dry runs — the properties that let a long-running command be interrupted without leaving the data half-migrated.

WP-CLI is the only sane way to operate a large WordPress platform: bulk edits, content audits, migrations, index rebuilds and scheduled maintenance all belong here rather than in an admin screen that times out.

Memory is the first wall

Two things fill memory during a long command: the result set, and WordPress’s own object cache, which accumulates every post, term and meta row you touch.

$paged = 1;

do {
	$ids = get_posts( array(
		'post_type'              => 'product',
		'posts_per_page'         => 200,
		'paged'                  => $paged,
		'fields'                 => 'ids',   // never hydrate full objects
		'no_found_rows'          => true,    // skip the expensive count
		'update_post_meta_cache' => false,
		'update_post_term_cache' => false,
	) );

	foreach ( $ids as $id ) {
		acme_process( $id );
	}

	// Without this the object cache grows until the process is killed.
	if ( function_exists( 'wp_cache_flush_runtime' ) ) {
		wp_cache_flush_runtime();
	}

	$paged++;
} while ( ! empty( $ids ) );

Three details do the work. 'fields' => 'ids' avoids hydrating full WP_Post objects. Disabling the meta and term cache priming stops two extra queries per batch you are not using. Flushing the runtime cache between batches is what keeps memory flat instead of climbing.

Paging by ID beats paging by offset

On a large table, OFFSET 100000 makes the database walk a hundred thousand rows to discard them. Worse, if the command modifies the rows it is iterating, offsets shift underneath you and records get skipped. Track the last processed ID instead:

WHERE ID > %d ORDER BY ID ASC LIMIT 200

This is stable under concurrent writes and gives you a natural resume point.

Make every destructive command resumable

A migration that takes forty minutes will be interrupted — a deploy, a timeout, a laptop closing. If it cannot resume, the only safe response is to restore a backup and start again.

Record progress durably as you go: the last processed ID in an option, or a per-record flag in meta. Then make the command skip what it has already done. The same mechanism makes the command idempotent, which means running it twice is harmless — the property that lets you re-run without thinking.

Dry run by default

Any command that writes should support --dry-run, and it should be the behaviour people reach for first. A dry run reports exactly what would change, without changing it.

WP_CLI::add_command( 'acme migrate-meta', function ( $args, $assoc ) {
	$dry = ! empty( $assoc['dry-run'] );
	$log = WP_CLI\Utils\make_progress_bar( 'Migrating', $total );

	foreach ( $batch as $id ) {
		$old = get_post_meta( $id, '_legacy_price', true );
		if ( '' === $old ) {
			continue;
		}

		if ( $dry ) {
			WP_CLI::log( sprintf( '#%d  %s -> %s', $id, $old, acme_normalise( $old ) ) );
		} else {
			update_post_meta( $id, '_price', acme_normalise( $old ) );
		}

		$log->tick();
	}

	$log->finish();
	WP_CLI::success( $dry ? 'Dry run complete. No data written.' : 'Migration complete.' );
} );

Content audits

WP-CLI is the fastest way to answer questions about a site that no admin screen will tell you. Output as CSV and analyse elsewhere rather than trying to format in the terminal:

# Posts with no featured image, as CSV
wp post list --post_type=post --format=csv --fields=ID,post_title,post_date \
  | while IFS=, read -r id rest; do
      [ -z "$(wp post meta get "$id" _thumbnail_id 2>/dev/null)" ] && echo "$id,$rest"
    done

# Orphaned meta rows left by a removed plugin
wp db query "SELECT meta_key, COUNT(*) c FROM wp_postmeta
             WHERE meta_key LIKE '_oldplugin%' GROUP BY meta_key ORDER BY c DESC;"

The audits worth running on a schedule: posts without featured images, drafts older than a year, autoloaded options over a threshold, orphaned meta from removed plugins, and unattached media. The autoloaded options one is frequently the highest-value — a bloated wp_options autoload set is loaded on every single request.

Search-replace on migrations

Domain changes are the standard reason to reach for search-replace, and the reason it must be WP-CLI rather than SQL is serialisation. A naive SQL REPLACE corrupts serialised arrays whose string lengths no longer match the content.

# Always dry run first; --report-changed-only keeps the output readable.
wp search-replace 'https://staging.example.com' 'https://example.com' \
  --dry-run --report-changed-only --all-tables-with-prefix

# Then, with a backup taken and GUIDs left alone.
wp search-replace 'https://staging.example.com' 'https://example.com' \
  --skip-columns=guid --all-tables-with-prefix

--skip-columns=guid matters: GUIDs are permanent identifiers for feed readers, not URLs, and rewriting them makes every existing item look new.

Running it safely in production

  • Take a backup first, and confirm it restores. An untested backup is a hypothesis.
  • Run under screen or tmux so a dropped connection does not kill a half-finished migration.
  • Throttle deliberately. A tight loop of writes can saturate the database and take the site down. A short sleep between batches costs minutes and avoids an outage.
  • Watch replication lag if you run read replicas — bulk writes are exactly what makes replicas fall behind.
  • Log to a file, not just stdout, so there is a record of what ran when something looks wrong afterwards.

Custom commands belong in a plugin

Register commands from a small mu-plugin rather than the theme. They then survive a theme switch, are available when the theme is broken — which is often exactly when you need them — and can be unit tested without loading the front end.

Wrap the work in the same query and caching discipline as any other code path; a WP-CLI process is subject to the same database as the front end, and a badly written command is a self-inflicted denial of service. The underlying discipline is the same one in Full-Stack Web Performance.

Questions people actually ask

Why does my WP-CLI command run out of memory?
Almost always the object cache growing across a long loop, or hydrating full post objects when you only need IDs. Batch the query, pass 'fields' => 'ids', disable meta and term cache priming, and flush the runtime cache between batches. Raising the memory limit treats the symptom and delays the same failure.

Is it safe to run search-replace on production?
With a tested backup, a dry run reviewed first, and --skip-columns=guid, yes – it is the correct tool, because it handles serialised data that a raw SQL replace would corrupt. Run it during low traffic and confirm the row counts in the dry run match what you expect before committing.

How do I run WP-CLI on WordPress VIP?
Through the platform’s own CLI access rather than SSH into a container, and with the platform’s constraints in mind: long-running commands are subject to limits, so batching and resumability are requirements rather than good practice. Destructive commands generally need review before they can run.

Should custom commands live in the theme or a plugin?
A must-use plugin. Commands in a theme disappear on a theme switch and are unavailable when the theme has a fatal error, which is frequently when you most need them. An mu-plugin also loads before regular plugins, which is useful for repair commands.

How do I stop a bulk command taking the site down?
Throttle it. Small batches, a short sleep between them, and monitoring of database load and replication lag while it runs. A migration that takes twenty minutes instead of four is a good trade against saturating the database that is also serving visitors.

← Back to all insights