TechEarl

Safely Bulk-Update Custom Fields in WordPress

A bulk custom-field update with no undo is one typo away from wrecking thousands of posts. Here is a safe pattern (and a downloadable WP-CLI command) with a dry run, a backup gate, a change-only changelog, and idempotent writes.

Ishan Karunaratne⏱️ 6 min readUpdated
Share thisCopied
Running a safe bulk custom-field update in WordPress with a dry run, backup gate, and change-only changelog

Safely bulk-updating a custom field across thousands of WordPress posts boils down to four safety rails: a dry run that writes nothing, a backup taken before the first write, a changelog of every value that actually changed, and idempotent writes you can repeat. A bulk update has no undo button, one wrong meta key or bad transform silently rewrites a value on ten thousand posts, so the safety has to be built into the operation itself rather than left to "be careful." This article is that pattern, packaged as a WP-CLI command you can drop into any site.

It builds directly on the WP-CLI bulk-script harness, the difference is everything wrapped around the write so a mistake is recoverable instead of permanent.

The four rails

  1. Dry run by default. The command previews changes and writes nothing unless you explicitly pass --live. The dangerous mode is the one you have to opt into, not the one you fall into.
  2. Backup gate. A live run refuses to start unless it has first written a backup of the rows it is about to touch. No backup, no write.
  3. Change-only changelog. Rows whose value already matches the target are skipped, and every row that does change is logged as old → new. You finish with an exact record of what moved.
  4. Idempotent writes. Running the command twice changes nothing the second time, because a row already at the target value is a no-op.

The command

Drop this in a small plugin file (wp-content/plugins/te-safe-batch/te-safe-batch.php) and activate it. It registers the wp te-batch update_meta command (WP-CLI derives the subcommand from the method name, so the underscore is expected).

php
<?php
/**
 * Plugin Name: TE Safe Batch Updater
 * Plugin URI:  https://techearl.com/wordpress-safe-batch-update-custom-fields
 * Description: A WP-CLI command for bulk custom-field updates with a dry run, backup gate, and change-only changelog.
 * Version:     1.0.0
 * Author:      TechEarl
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
	return;
}

class TE_Safe_Batch_Command {

	/**
	 * Bulk-update a post-meta key across a post type, safely.
	 *
	 * ## OPTIONS
	 *
	 * --meta_key=<key>
	 * : The meta key to update.
	 *
	 * --value=<value>
	 * : The new value to set.
	 *
	 * [--post_type=<type>]
	 * : Post type to target. Default: post.
	 *
	 * [--live]
	 * : Actually write. Without this flag the command is a dry run.
	 *
	 * ## EXAMPLES
	 *     wp te-batch update_meta --meta_key=currency --value=USD
	 *     wp te-batch update_meta --meta_key=currency --value=USD --live
	 */
	public function update_meta( $args, $assoc ): void {
		$meta_key  = $assoc['meta_key'];
		$value     = $assoc['value'];
		$post_type = $assoc['post_type'] ?? 'post';
		$live      = isset( $assoc['live'] );

		$ids = get_posts( array(
			'post_type'      => $post_type,
			'post_status'    => 'any',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
		) );

		// Rail 2: a live run must back up the rows it will touch, first.
		if ( $live ) {
			$backup = te_safe_batch_backup( $ids, $meta_key );
			WP_CLI::log( "Backup written: {$backup}" );
		}

		$changes = array();
		foreach ( $ids as $id ) {
			$current = get_post_meta( $id, $meta_key, true );

			// Rail 3 + 4: skip rows already at the target. Idempotent, and it
			// keeps the changelog to genuine changes only.
			if ( (string) $current === (string) $value ) {
				continue;
			}

			$changes[] = array( 'id' => $id, 'old' => $current, 'new' => $value );

			if ( $live ) {
				update_post_meta( $id, $meta_key, $value );
			}
		}

		if ( empty( $changes ) ) {
			WP_CLI::success( 'Nothing to change. Every row already matches.' );
			return;
		}

		WP_CLI\Utils\format_items( 'table', array_slice( $changes, 0, 20 ), array( 'id', 'old', 'new' ) );

		$mode = $live ? 'updated' : 'WOULD update (dry run)';
		WP_CLI::success( sprintf( '%d rows %s. %d already matched.', count( $changes ), $mode, count( $ids ) - count( $changes ) ) );

		if ( ! $live ) {
			WP_CLI::log( 'Re-run with --live to apply. A backup is taken automatically before the first write.' );
		}
	}
}

WP_CLI::add_command( 'te-batch', 'TE_Safe_Batch_Command' );

/**
 * Rail 2: dump the current key/value for every target row to a timestamped
 * file before any write, so a bad run is always recoverable.
 */
function te_safe_batch_backup( array $ids, string $meta_key ): string {
	$rows = array();
	foreach ( $ids as $id ) {
		$rows[] = array( 'id' => $id, $meta_key => get_post_meta( $id, $meta_key, true ) );
	}
	$file = WP_CONTENT_DIR . '/te-batch-backup-' . $meta_key . '-' . gmdate( 'Ymd-His' ) . '.json';
	file_put_contents( $file, wp_json_encode( $rows ) );
	return $file;
}

Because the code here is a generic data-modification tool, the plugin header attributes it to TechEarl rather than to me personally, and the command, class, and helper all carry the te-/TE_ prefix.

A dry run, then the real thing

Run it without --live first. Every time. The output shows exactly which rows would move and what they would move to, and writes nothing:

A terminal running the te-batch update_meta command as a dry run, listing six rows whose currency value would change from GBP to USD, ending with a count of 6 would-change and 4997 already matched, and a prompt to re-run with --live
The dry run: a table of old to new for each row that would change, a count, and no writes.

Read that table. Is the count what you expected? Are the old values what you thought they were? A dry run that reports "9,800 rows would change" when you expected a few hundred is the moment to stop and check your --meta_key, not after you have rewritten them. When the preview is right, re-run with --live. Now it takes the backup, writes only the rows that differ, and logs the same table as a record of what actually changed:

A terminal running the same command with the --live flag, showing the backup file path written first, then the table of changed rows and a success line confirming the rows were updated
The live run writes a backup first, applies only the changed rows, and reports what moved.

If the live run was wrong, the backup JSON has the previous value of every row you touched, so a recovery script can put them back. That file is the difference between an inconvenience and a disaster.

Prove the rails with a test

The whole point of the dry run is that it does not write. That is exactly the kind of claim worth pinning down with a test, because the day it quietly starts writing in dry-run mode is the day the safety is gone. A focused PHPUnit test seeds a known value, runs the command without --live, and asserts the value is untouched:

php
public function test_dry_run_writes_nothing(): void {
	$post_id = self::factory()->post->create();
	update_post_meta( $post_id, 'currency', 'GBP' );

	// Dry run: no --live flag.
	( new TE_Safe_Batch_Command() )->update_meta( array(), array(
		'meta_key' => 'currency',
		'value'    => 'USD',
	) );

	$this->assertSame( 'GBP', get_post_meta( $post_id, 'currency', true ), 'Dry run must not write.' );
}

public function test_live_run_is_idempotent(): void {
	$post_id = self::factory()->post->create();
	update_post_meta( $post_id, 'currency', 'GBP' );

	$cmd = new TE_Safe_Batch_Command();
	$cmd->update_meta( array(), array( 'meta_key' => 'currency', 'value' => 'USD', 'live' => true ) );
	$cmd->update_meta( array(), array( 'meta_key' => 'currency', 'value' => 'USD', 'live' => true ) );

	$this->assertSame( 'USD', get_post_meta( $post_id, 'currency', true ) );
}

Run them with wp scaffold plugin-tests set up, or inside any WordPress PHPUnit harness. The first test is the one that matters: it is the contract that the dry run is genuinely safe.

ACF fields need update_field

One important caveat. If the field you are bulk-updating is managed by ACF rather than a plain native meta key, swap get_post_meta/update_post_meta for get_field/update_field inside the command, so ACF's reference twin and return formatting stay correct. The reasoning, and the failure mode if you don't, is covered in ACF fields vs native post meta. Everything else (the dry run, the backup, the changelog, the idempotency) stays exactly the same.

This safe-updater is the tool I reach for behind the bigger workflows in this cluster, including syncing WordPress from a Google Sheet, where the sheet is the source of truth and the command applies it. A spreadsheet of changes is only as safe as the script that lands it.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressWP-CLICustom FieldsBulk EditPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Using AI to Update ACF Fields and WordPress Content

AI plus WP-CLI plus ACF is the canonical pattern for bulk content updates that used to take a careful afternoon. Schema-aware update_field calls, content rewrites at scale, image alt backfills, and the safety patterns that prevent disasters.

Add a Custom WP-CLI Command in WordPress

How to register a custom WP-CLI command: guard it with defined('WP_CLI'), wire it up with WP_CLI::add_command(), turn class methods into subcommands, document args with @synopsis, and show progress with make_progress_bar().

ACF Fields vs Native Post Meta in WordPress

ACF and native post meta both write to the same wp_postmeta table. Here is what register_post_meta gives you, what ACF adds on top, and the read/write rules so a bulk script and a content editor never fight over the same field.