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⏱️ 7 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:      Ishan Karunaratne
 * 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;
}

The command, class, and helper all carry the te-/TE_ prefix so the plugin's own names stay clear of collisions with anything else on the site.

A dry run, then the real thing

Before changing anything, look at what is actually stored. Here are the currency values across a slice of the posts: most are already on the target USD, and a handful are still on the old GBP, which are the rows this run is for:

A terminal running a wp db query that lists the currency meta value for posts 5001 to 5008, showing 5001 and 5002 already USD and 5003 through 5008 still GBP
The before-state: a quick DB peek at the stored values. Two posts are already USD, six are still GBP.

That before-and-after is the whole reason the next two screenshots are worth comparing. Run the command 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.

A yes/no prompt is too easy to muscle-memory through. Making the live mode an explicit --live flag means the safe mode is the default you get for free, and the dangerous mode is something you have to consciously type. The dry run also gives you a real preview to check, not just a count.

The command writes a timestamped JSON file in wp-content containing the id and previous value of every targeted row, before the first write. To restore, loop the JSON and call update_post_meta with the saved value. For a full safety net, also take a wp db export before large runs.

Slightly, because it reads each row's current value before deciding to write. That read is cheap and it buys you two things: idempotency (re-running is safe) and a changelog of genuine changes. On a large run, skipping the no-op writes often makes it faster overall, not slower.

The pattern holds, but write WooCommerce data through the CRUD (wc_get_product(), the setters, save()) rather than poking _price meta directly, so lookup tables stay consistent. Keep the same dry-run, backup, and changelog rails around the CRUD calls.

It is fine for a few thousand rows since the query only pulls IDs. For very large tables, batch the query the way the WP-CLI bulk-script harness does (paged, with the object cache flushed each batch) so memory stays flat, and keep all four safety rails around it.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressWP-CLICustom FieldsBulk EditPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

AI + WP-CLI + ACF for bulk content updates. Schema-aware update_field, content rewrites, image alt backfills, the safety patterns that prevent disasters.

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.

How to register a custom WP-CLI command in WordPress: a command class wired up with WP_CLI::add_command(), public methods as subcommands, args and flags documented with @synopsis, and a make_progress_bar() loop for batch jobs.

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().

Comparing Advanced Custom Fields with native WordPress post meta, and how both store data in the same wp_postmeta table

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.