TechEarl

wp_insert_post Consuming Large Amounts of Memory: How I Actually Fix It

Fix wp_insert_post OOMs during bulk imports: chunk in batches, flush the object cache, defer term/comment counting, suspend cache invalidation, disable revisions, and (where appropriate) bypass the WordPress API with direct $wpdb writes.

Ishan KarunaratneIshan Karunaratne⏱️ 14 min readUpdated
Fix wp_insert_post OOMs during bulk imports. Chunk in batches, flush the object cache, defer term and comment counting, suspend cache invalidation, disable revisions, and bypass the WordPress API with direct $wpdb writes where safe.

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

Run any large wp_insert_post loop with PHP's memory introspection and you'll see the floor rising every iteration:

php
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:

  1. WordPress object cache ($wp_object_cache). Holds every post object and its meta. Never evicts within the same request.
  2. 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.
  3. save_post hook ecosystem. Yoast SEO, WPML, ElasticPress, ACF, custom plugins: all listen on save_post and each can register additional state per insert.
  4. Post revisions. Every wp_insert_post writes a revision row by default.
  5. 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:

php
$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:

php
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:

php
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 comments

With 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

php
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.

php
// 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:

php
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:

php
// 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 afterwards

If 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:

php
$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():

php
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_post hook: 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 into wp_term_relationships.
  • No post-meta: the wp_postmeta rows 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.

ApproachPeak memoryTotal timeStatus
Plain wp_insert_post loopOOM at ~2,400 postsn/acrashed with Allowed memory size of 134217728 bytes exhausted
Plain loop + memory_limit = 1G940 MB8m 12sfinished but greedy
Batched (200) + flush + GC188 MB6m 45sclean
Batched + flush + deferred counts + cache suspension142 MB5m 30sclean
Batched + bypass + raw $wpdb96 MB38sclean (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:

php
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:

FAQ

TagsWordPressPerformanceMemory Managementwp_insert_postwp-cliDatabasePHPHooks
Share
Ishan Karunaratne

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

A complete hardened wp-config.php template for WordPress with comments on every setting: DISALLOW_FILE_EDIT, FORCE_SSL_ADMIN, salt rotation, file permissions.

A Hardened wp-config.php Template (with Comments on Every Choice)

wp-config.php is the first PHP file WordPress loads. The defaults from the stock installation are minimal; the hardened defaults take five minutes to apply and close most of the attack surface that lives below the plugin layer. A complete annotated template covering disabled file editing, forced HTTPS, secure salt rotation, debug behavior, and the file permissions that matter.

Use find -size +100M to list files larger than 100 megabytes. Unit suffixes (c/k/M/G), +/- sign convention, combine with sort -rn to surface the biggest files on disk, and BSD vs GNU rendering differences.

How to Find Files Larger Than a Size with find -size

find . -size +100M lists every file larger than 100 megabytes. The unit suffixes (c, k, M, G), the +/- sign convention, how to combine with sort to find the biggest files on disk, the BSD vs GNU divergence for printing sizes, and the wc -c trick for byte-exact thresholds.