wp_insert_post is convenient: it runs every hook, fires every filter, updates term counts, builds a revision, and writes the post-meta in a single call. Convenient is also what makes it expensive. Loop it 50,000 times for a CSV import and PHP's memory usage climbs linearly: the object cache fills with every inserted post, every taxonomy term gets re-counted on every insert, hooks accumulate state, and PHP's garbage collector never gets a chance to breathe. The fix is rarely "raise memory_limit" (that just delays the OOM): the real fix is to batch the inserts, flush the cache between batches, suspend the heaviest operations for the duration of the import, and reach for $wpdb->insert() when the WordPress hook ecosystem isn't required. Below: every lever I pull when a bulk import won't finish.
Why does wp_insert_post consume so much memory?
wp_insert_post was designed for the admin "Add New Post" workflow, where one post is created per request and the request ends within a second or two. In a bulk-import loop it leaks memory in five ways: (1) every inserted post and its meta lands in the WordPress object cache ($wp_object_cache) and never gets evicted within the same PHP process; (2) every taxonomy term reference triggers wp_update_term_count_now, which re-queries and re-counts every term assignment; (3) every save fires save_post, wp_insert_post, post_updated, and 20+ other hooks, each of which can register more state; (4) post revisions get written, doubling row count; (5) PHP's autoload + globals from the bootstrap stay resident regardless. The fix is to chunk inserts (100-500 per batch), call wp_cache_flush() and gc_collect_cycles() between batches, suspend term/comment counting with wp_defer_term_counting(true), suspend cache invalidation with wp_suspend_cache_invalidation(true), and disable revisions during the import. When meta isn't critical, drop down to $wpdb->insert() directly. For the underlying PHP memory_limit knob, see PHP Memory Limit: How to Fix 'Allowed Memory Size Exhausted'.
Jump to:
- What's actually consuming the memory
- Fix 1: Chunk in batches of 100 to 500
- Fix 2: Flush the object cache between batches
- Fix 3: Defer term and comment counting
- Fix 4: Suspend cache invalidation
- Fix 5: Disable revisions and autosaves
- Fix 6: Strip non-essential hooks during the import
- Fix 7: Bypass with $wpdb->insert when safe
- Before/after memory profile
- Common pitfalls
- What to do next
- FAQ
What's actually consuming the memory
Run any large wp_insert_post loop with PHP's memory introspection and you'll see the floor rising every iteration:
foreach ($rows as $row) {
wp_insert_post($row);
error_log(sprintf(
'memory: %s, peak: %s',
size_format(memory_get_usage(true)),
size_format(memory_get_peak_usage(true))
));
}The pattern: memory grows by 50 KB to 500 KB per insert depending on the post size, the number of meta keys, and how many plugins are listening on save_post. Multiply by 50,000 posts and you're looking at 2.5 GB to 25 GB of resident memory at the end of the loop. PHP's default memory_limit = 128M is exhausted within the first few thousand inserts.
The culprits, in order of impact:
- WordPress object cache (
$wp_object_cache). Holds every post object and its meta. Never evicts within the same request. - Term counting. Each new post that's assigned a category or tag triggers
wp_update_term_count_now, which counts ALL posts per term via SQL on every assignment. save_posthook ecosystem. Yoast SEO, WPML, ElasticPress, ACF, custom plugins: all listen onsave_postand each can register additional state per insert.- Post revisions. Every wp_insert_post writes a revision row by default.
- Comment counting. Each post pulls its comment count even if there are none.
The good news: each of these has an off switch.
Fix 1: Chunk in batches of 100 to 500
Don't loop 50,000 calls in a single PHP process. Wrap the import in a batched loop and break it into smaller transactions:
$batch_size = 200;
$total = count($rows);
for ($offset = 0; $offset < $total; $offset += $batch_size) {
$batch = array_slice($rows, $offset, $batch_size);
foreach ($batch as $row) {
wp_insert_post($row);
}
// Cleanup between batches: covered in the next sections
wp_cache_flush();
gc_collect_cycles();
error_log(sprintf('Processed %d/%d', $offset + count($batch), $total));
}Why 100 to 500: small enough to keep per-batch memory under control, large enough to amortize the cache-flush cost. Below 50 the overhead of flushing dominates; above 1000 memory creeps back up on heavy plugins.
For WP-CLI imports, wp eval-file with the batch loop inline is the cleanest pattern. For browser-triggered imports, see also Action Scheduler (the library that drives WooCommerce's async tasks); for a manual implementation see the Bash While Loops reference.
Fix 2: Flush the object cache between batches
wp_cache_flush() clears the WordPress object cache. Combined with gc_collect_cycles() (which forces PHP's garbage collector to run a full cycle), it's what brings memory back down between batches:
foreach (array_chunk($rows, 200) as $batch) {
foreach ($batch as $row) {
wp_insert_post($row);
}
wp_cache_flush(); // drop everything in $wp_object_cache
gc_collect_cycles(); // force GC to reclaim circular references
}wp_cache_flush() is destructive in the literal sense: every cached query, term, user, and option is gone. That's fine inside an import script because nothing else is reading the cache, but DO NOT call wp_cache_flush() from a request that's serving a real user.
If you're on a persistent object cache backend (Redis/Memcached via the redis-cache or W3 Total Cache drop-ins), wp_cache_flush() clears the persistent store too. For imports on Redis-backed sites, use the in-memory variant: call $wp_object_cache->cache = [] directly (the internal property), or temporarily switch to the bundled non-persistent driver for the duration of the import.
Fix 3: Defer term and comment counting
Two WordPress functions exist for exactly this scenario:
wp_defer_term_counting(true);
wp_defer_comment_counting(true);
foreach ($rows as $row) {
wp_insert_post($row);
}
wp_defer_term_counting(false); // triggers a single recount at the end
wp_defer_comment_counting(false); // same for commentsWith wp_defer_term_counting(true), WordPress skips the per-insert term count and runs ONE batch recount at the end. Comment counting works the same way. On a 50,000-post import with categories and tags, deferring term counting cuts the work by 70 to 90 percent: every category/tag assignment goes from "run a SQL count" to "remember to recount later".
Order matters: call wp_defer_term_counting(false) AFTER the import loop is complete to trigger the final recount, otherwise term counts will be stale.
Fix 4: Suspend cache invalidation
wp_suspend_cache_invalidation(true);
foreach ($rows as $row) {
wp_insert_post($row);
}
wp_suspend_cache_invalidation(false);wp_suspend_cache_invalidation(true) tells WordPress to stop invalidating cached terms, posts, and meta when those entities change. Combined with wp_cache_flush() at the end (or per-batch), this dramatically reduces the per-insert overhead.
When NOT to use it: if your import depends on reading data you just wrote in the same loop (e.g., "create parent post, then create child posts that reference the parent ID by slug"), the lookups will read stale cache and fail. In that case, either turn cache invalidation back on for the dependent reads, or build a $slug_to_id mapping in PHP up-front and skip the lookups entirely.
Fix 5: Disable revisions and autosaves
Revisions double the row count: every insert writes the post AND a revision row. For a one-shot import that's pure overhead.
// Top of script, BEFORE wp-load.php is included, in wp-config.php, or via constant:
define('WP_POST_REVISIONS', false);
define('AUTOSAVE_INTERVAL', PHP_INT_MAX);If you can't modify wp-config.php (e.g., on managed hosts), filter at runtime:
add_filter('wp_revisions_to_keep', '__return_zero', 10, 0);Place this BEFORE the import loop. It's a stronger guarantee than the WP_POST_REVISIONS constant if other code has already overridden it.
Fix 6: Strip non-essential hooks during the import
Most performance penalties of wp_insert_post come from third-party plugins listening on save_post and friends. Audit and temporarily remove them:
// Check what's listening
global $wp_filter;
$listeners = isset($wp_filter['save_post']) ? array_keys((array) $wp_filter['save_post']->callbacks) : [];
error_log('save_post listeners: ' . print_r($listeners, true));
// Remove the heavy ones for the duration of the import
remove_action('save_post', ['WPSEO_Metabox', 'save_postdata']); // Yoast SEO
remove_action('save_post', 'wpml_save_post_actions'); // WPML
remove_action('save_post', ['EP_Sync_Manager', 'action_sync_on_update']); // ElasticPress
foreach ($rows as $row) {
wp_insert_post($row);
}
// Re-add the actions if the script continues running afterwardsIf ElasticPress is your search backend, see How to Use ElasticPress with WP_Query for the right way to reindex after the bulk import instead of letting ElasticPress sync on every insert.
For really aggressive imports, neutralize the entire hook system around the insert:
$saved_filter = $wp_filter['save_post'] ?? null;
unset($wp_filter['save_post']);
foreach ($rows as $row) {
wp_insert_post($row);
}
if ($saved_filter) {
$wp_filter['save_post'] = $saved_filter;
}This is the nuclear option: no plugin learns about the inserts during the loop. Trigger the indexers, search engines, and external syncs manually after the import is done. See How to Remove Empty Values from a PHP Array for cleanup patterns on the rows themselves before insertion.
Fix 7: Bypass with $wpdb->insert when safe
When the import data doesn't need every hook to fire (no SEO metadata, no caching layer, no search index update during the import), drop down to raw $wpdb->insert():
global $wpdb;
foreach (array_chunk($rows, 500) as $batch) {
$values = [];
$placeholders = [];
foreach ($batch as $row) {
$placeholders[] = '(%s, %s, %s, %s, %d, %s, %s)';
$values[] = $row['title'];
$values[] = $row['content'];
$values[] = $row['excerpt'];
$values[] = $row['date'];
$values[] = $row['author_id'];
$values[] = $row['status'];
$values[] = $row['post_type'];
}
$sql = "INSERT INTO {$wpdb->posts}
(post_title, post_content, post_excerpt, post_date, post_author, post_status, post_type)
VALUES " . implode(',', $placeholders);
$wpdb->query($wpdb->prepare($sql, $values));
}A single multi-row INSERT is 10 to 100 times faster than 500 individual $wpdb->insert() calls because of round-trip latency. For 50,000 rows, this drops insert time from ~25 minutes (single-row) to under 30 seconds.
Tradeoffs of bypassing wp_insert_post:
- No GUID generation: you'll need
wp_update_post()after to set GUIDs, or build them manually. - No
save_posthook: indexers don't learn about the new posts. Reindex manually. - No revision row: usually desirable for imports.
- No term assignment: you'll need separate
$wpdb->insert()calls intowp_term_relationships. - No post-meta: the
wp_postmetarows must be inserted separately, also batched.
For more on direct SQL patterns, see MySQL Field Types and Sizes and the Export or Backup All MySQL Databases reference.
Before/after memory profile
Test setup: 10,000 posts, each with 5 meta keys and 3 taxonomy terms, on PHP 8.4 with the default object cache.
| Approach | Peak memory | Total time | Status |
|---|---|---|---|
Plain wp_insert_post loop | OOM at ~2,400 posts | n/a | crashed with Allowed memory size of 134217728 bytes exhausted |
Plain loop + memory_limit = 1G | 940 MB | 8m 12s | finished but greedy |
| Batched (200) + flush + GC | 188 MB | 6m 45s | clean |
| Batched + flush + deferred counts + cache suspension | 142 MB | 5m 30s | clean |
Batched + bypass + raw $wpdb | 96 MB | 38s | clean (manual reindex required) |
The order is consistent across machines: the batched-and-flushed approach is the right default; the raw $wpdb approach is the right answer when the hook ecosystem is not required.
For the underlying PHP knob, see PHP Memory Limit: How to Fix 'Allowed Memory Size Exhausted'. Raising the limit alone is rarely the right fix here: it just delays the OOM by a few thousand inserts and burns server RAM unnecessarily.
Common pitfalls
Calling wp_cache_flush() on a live site. It blows away every cached query for every visitor. Imports should run from WP-CLI on an isolated worker, NOT from a request that serves real users.
Forgetting to re-enable deferred counts. If the script exits abnormally before wp_defer_term_counting(false), term counts stay stale until you manually trigger a recount. Wrap the import in try/finally:
wp_defer_term_counting(true);
try {
// import
} finally {
wp_defer_term_counting(false);
}Bypassing wp_insert_post and forgetting wp_postmeta. Raw $wpdb->insert() only writes the wp_posts row. Meta keys, term assignments, and ElasticPress index updates all have to be done separately.
Batches too small. Batches of 1 are pointless: the cache-flush cost dominates. Stay in the 100 to 500 range.
Running concurrent imports. Two parallel imports each call wp_cache_flush(), fighting over the cache. Run imports serially.
Skipping gc_collect_cycles(). PHP's GC catches circular references lazily. On long loops with lots of object creation, an explicit call between batches keeps the heap walkable.
Persistent object cache (Redis) gotcha. wp_cache_flush() flushes Redis too. If you're running an import on a production-shared Redis instance, that's a cache stampede for every other user on the same Redis. Run imports against an isolated WP install or a separate Redis prefix.
The WP_IMPORTING constant. Setting define('WP_IMPORTING', true) BEFORE running the import disables some auto-pings, RSS rebuilds, and other one-off side effects that fire on save_post. It's not a silver bullet but it's free.
What to do next
If you're working on WordPress at scale:
- PHP Memory Limit: How to Fix 'Allowed Memory Size Exhausted': the underlying PHP-level knob and when raising it is (and isn't) the right answer.
- How to Use ElasticPress with WP_Query: move search load off MySQL after the import lands; also explains how to bulk-reindex after a
wp_insert_postloop. - How to Remove Empty Values from a PHP Array: sanitize CSV rows before passing them to wp_insert_post.
- Export or Backup All MySQL Databases: back up before any bulk import; back up after too if the import touches production data.
- How to Change a WordPress Password: adjacent admin-recovery reference, especially if a botched import locks you out.
- WPScan Usage and Man Page: security baseline if the import is part of a migration.





