To run a one-off bulk operation across thousands of WordPress posts (recalculate a meta value, re-slug ten thousand products, backfill a field that used to be optional), put the code in a PHP file and run it with wp eval-file from the command line, not a script you hit in a browser. A browser-hit script runs as a web request, so it inherits PHP's max_execution_time, a single page load's memory limit, and a hard stop the moment the browser or a proxy gives up: halfway through you get a white screen and no idea which posts were processed.
wp eval-file has none of those limits. It loads WordPress fully and runs your file with no web-request timeout and whatever memory you give the CLI process. The catch is that WordPress was built to render one page and exit, so a long loop inside a single bootstrap accumulates memory in ways a normal request never reveals. This is the harness I use to keep a bulk run fast and flat on memory, whether it touches a hundred rows or a hundred thousand.
Why WP-CLI, not a browser script
wp eval-file path/to/script.php loads WordPress exactly as a request would (plugins, theme functions.php, the whole stack), then executes your file in the global scope. You get WP_Query, update_post_meta, every plugin's functions, all of it, with three things a browser hit can't give you:
- No execution timeout. CLI PHP defaults
max_execution_timeto0(unlimited). A 40-minute job is fine. - Real memory headroom. Set it per-invocation instead of fighting
php.ini. - Honest output.
WP_CLI::log()and exit codes, so a cron or a deploy script knows whether the job actually finished.
wp eval-file te-bulk-update.phpIf the job only needs core (no plugin hooks should fire during the run), skip the plugin load and it starts faster and behaves more predictably:
wp eval-file te-bulk-update.php --skip-plugins --skip-themesThe harness
Here is the shape I reach for. It processes posts in fixed-size batches, suspends the caches that only slow a write-heavy job down, and clears the object cache every batch so memory stays flat for the whole run.
<?php
/**
* te-bulk-update.php — WP-CLI bulk-operation harness.
*
* Run with: wp eval-file te-bulk-update.php
* Author: Ishan Karunaratne — https://techearl.com/wordpress-run-bulk-scripts-wp-cli
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return; // Refuse to run anywhere but WP-CLI.
}
// Writes don't benefit from the object cache; suspending it avoids the cost of
// populating and invalidating cache entries we'll never read back this run.
wp_suspend_cache_addition( true );
wp_suspend_cache_invalidation( true );
$batch_size = 200;
$paged = 1;
$processed = 0;
$started = microtime( true );
do {
$query = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'any',
'posts_per_page' => $batch_size,
'paged' => $paged,
'fields' => 'ids', // Only pull IDs; don't hydrate full post objects.
'no_found_rows' => true, // Skip the SQL_CALC_FOUND_ROWS count we don't need.
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'orderby' => 'ID',
'order' => 'ASC',
) );
if ( empty( $query->posts ) ) {
break;
}
foreach ( $query->posts as $post_id ) {
te_bulk_apply( (int) $post_id );
$processed++;
}
// Flush the runtime object cache and clear the saved-query log so neither
// grows without bound across batches. This is the line that keeps memory flat.
WP_CLI\Utils\wp_clear_object_cache();
WP_CLI::log( sprintf( 'Batch %d done: %d posts processed.', $paged, $processed ) );
$paged++;
} while ( true );
WP_CLI::success( sprintf(
'%d posts in %.1fs (%.0f/s).',
$processed,
microtime( true ) - $started,
$processed / max( microtime( true ) - $started, 0.001 )
) );
/**
* The actual per-post work. Replace the body with whatever the job needs.
*/
function te_bulk_apply( int $post_id ): void {
$price = (float) get_post_meta( $post_id, 'price', true );
if ( $price <= 0 ) {
return; // Idempotent skip: nothing to do for this row.
}
update_post_meta( $post_id, 'price_with_tax', round( $price * 1.2, 2 ) );
}Run it and the output reads like a progress bar you can trust:

The four things that actually matter
Strip the harness down and it comes to four decisions, each of which fixes a specific failure I have watched bulk jobs hit.
Batch the query, never posts_per_page => -1
-1 means "load every matching post into memory at once." On a few hundred rows it is fine; on fifty thousand it is an out-of-memory crash before the loop even starts. Pulling a fixed batch (paged + a sane posts_per_page) caps the working set no matter how big the table is. Pair it with 'fields' => 'ids' so each batch is an array of integers, not fully-hydrated WP_Post objects with their meta and terms eagerly loaded.
Turn off the bookkeeping the job doesn't read
A normal page render wants found_rows (for pagination) and the meta/term caches (because the template will ask for them). A write loop wants none of that. 'no_found_rows' => true drops the second COUNT(*) query per batch; 'update_post_meta_cache' => false and 'update_post_term_cache' => false stop WordPress from pre-fetching data you are about to overwrite anyway.
Suspend the object cache for writes
wp_suspend_cache_addition() and wp_suspend_cache_invalidation() tell WordPress to stop populating and invalidating cache entries during the run. In a write-heavy loop those entries are pure overhead, you never read them back, and on a persistent object cache (Redis, Memcached) the invalidation traffic alone can dominate the job. Suspend them, do the work, and the cache repopulates naturally on the next real page view.
Flush the object cache inside the loop
This is the non-obvious one, and it is why a job that looks fine on staging dies at 80% on production. Even with caching suspended, WordPress and many plugins hold references in the in-process object cache and in $wpdb->queries (when SAVEQUERIES is on). Across tens of thousands of iterations that quietly climbs until the process is killed. WP_CLI\Utils\wp_clear_object_cache() resets the runtime object cache and clears the saved-query log each batch, so memory traces a flat sawtooth instead of a ramp to the ceiling.
Make it idempotent
A bulk run gets interrupted: someone closes the laptop, the SSH session drops, you Ctrl-C to check something. Write the per-row work so that running it twice changes nothing the second time. The te_bulk_apply above skips rows with no price and writes a derived value that is the same every run, so re-running after an interruption is safe. If your job is genuinely one-directional (incrementing a counter, appending to a log), guard it with a "done" flag in meta and skip rows that already carry it. The few minutes this costs to write back is cheaper than the afternoon you'll spend reconciling a half-applied run. For a production-grade version of this with a dry run, a backup gate, and a change-only changelog wrapped around the write, see safely bulk-updating custom fields.
Timing, so you know what you're dealing with
The microtime() bookends and the throughput line at the end are not decoration. The first time you run the harness against a real dataset, that number tells you whether the full job is a 10-second affair or a 40-minute one, which changes how you run it (inline, in a screen/tmux session, or as a scheduled job). Throughput also surfaces a slow te_bulk_apply: if you are doing 20 posts a second, something inside the loop is making a remote call or an unindexed query, and that is worth fixing before you point it at the whole table. For where the memory and query time actually go, and the cache knobs behind each line of this harness, see WP_Query at scale: performance and memory.
Where this leads
This harness is the engine under most of the bulk workflows on this site. Once you can reliably apply a function to every post from the command line, the only remaining question is where the data comes from. When it comes from a spreadsheet a non-developer maintains, the same loop reads the sheet with the Google Sheets API and writes each row with update_post_meta (or the WooCommerce CRUD), which is one half of syncing WordPress from a Google Sheet. And if your fields are managed by ACF rather than native meta, the read/write calls change (update_field in place of update_post_meta) but the harness does not, see ACF fields vs native post meta for which call to make.
Sources
Authoritative references this article was fact-checked against.
- wp eval-file - WP-CLI Command Referencedeveloper.wordpress.org
- Tips and best practices - WP-CLI Handbookmake.wordpress.org
- wp_suspend_cache_addition() - WordPress Developer Referencedeveloper.wordpress.org
- WP_Query - WordPress Developer Referencedeveloper.wordpress.org
- wp_cache_flush() - WordPress Developer Referencedeveloper.wordpress.org





