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
- 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. - 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.
- 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. - 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
/**
* 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:

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:

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:
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.
- WP-CLI Commands - WP-CLI Command Referencedeveloper.wordpress.org
- WP_CLI::add_command() - WP-CLI Handbookmake.wordpress.org
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- wp db export - WP-CLI Command Referencedeveloper.wordpress.org
- WP_CLI\Utils\format_items() - WP-CLI Handbookmake.wordpress.org





