Using Batch API for Bulk Entity Updatesfor Drupal 11 , 10 , 9 , and 8

Last updated :  

Updating 5,000 nodes in a single PHP request is a recipe for a timeout, a memory exhaustion error, or both — even if the update logic for one node is trivially fast. The Batch API exists exactly for this: it splits a large operation into many small chunks, each running in its own HTTP request, with a visible progress bar so the person who triggered it isn't left staring at a blank screen wondering if the site has crashed. This lesson applies that pattern specifically to bulk entity operations.

What you'll learn in this lesson

  • Why looping over thousands of entities in one request is the wrong shape, even for fast per-entity logic
  • How to structure a batch operation that updates entities in bounded chunks
  • How $context['sandbox'] lets a batch resume exactly where it left off across requests
  • Why you should load-modify-save one entity at a time inside a batch, not accumulate a giant array first

Defining the batch

$batch = [
  'title' => t('Updating articles...'),
  'operations' => [
    ['\Drupal\mymodule\BatchOperations::updateArticles', []],
  ],
  'finished' => '\Drupal\mymodule\BatchOperations::batchFinished',
];
batch_set($batch);

Notice there's exactly one entry in operations, not one per entity. A batch operation callback isn't "run once per item" — it's "run once per HTTP request," and it's the callback's own job to track its progress across those requests and process a bounded chunk each time.

The operation callback: process a chunk, track progress in $context['sandbox']

public static function updateArticles(array &$context) {
  $storage = \Drupal::entityTypeManager()->getStorage('node');

  if (!isset($context['sandbox']['progress'])) {
    $ids = $storage->getQuery()
      ->condition('type', 'article')
      ->accessCheck(FALSE)
      ->execute();
    $context['sandbox']['ids'] = array_values($ids);
    $context['sandbox']['progress'] = 0;
    $context['sandbox']['max'] = count($ids);
  }

  $chunk = array_slice($context['sandbox']['ids'], $context['sandbox']['progress'], 25);

  foreach ($chunk as $nid) {
    $node = $storage->load($nid);
    $node->set('field_needs_review', FALSE);
    $node->save();
    $context['sandbox']['progress']++;
    $context['results'][] = $nid;
  }

  $context['message'] = t('Updated @count of @total articles.', [
    '@count' => $context['sandbox']['progress'],
    '@total' => $context['sandbox']['max'],
  ]);

  if ($context['sandbox']['progress'] < $context['sandbox']['max']) {
    $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
  }
}

The first call populates $context['sandbox'] with the full list of IDs to process and a progress counter — this only happens once, because $context['sandbox'] survives across every subsequent call to this same operation. Each call after that processes one bounded chunk (25 nodes here) and updates the counter. Setting $context['finished'] to a fraction below 1 tells the batch engine "call me again" — leaving it unset (or at 1) tells it this operation is done.

Load, modify, and save one entity at a time inside the chunk loop — don't accumulate a big array of loaded entities first. Loading 25 entities, then saving all 25 afterward, holds all 25 fully-hydrated objects in memory simultaneously for no benefit. Loading, modifying, and saving each one before moving to the next keeps peak memory bounded to roughly one entity's footprint at a time, which is what actually lets this pattern scale to hundreds of thousands of rows without exhausting PHP's memory limit.

The finished callback

public static function batchFinished($success, $results, $operations) {
  if ($success) {
    \Drupal::messenger()->addMessage(t('Updated @count articles.', [
      '@count' => count($results),
    ]));
  }
  else {
    \Drupal::messenger()->addError(t('Batch finished with errors.'));
  }
}

This runs exactly once, after every chunk across every request has completed. $results is the accumulated array you pushed onto in the operation callback — here, every node ID that was actually updated — giving you a real count to report rather than guessing.

Quick check: why does the chunk size matter (25 here, not 1 and not 1,000)? (Answer: too small, and you pay the overhead of a full HTTP request/bootstrap cycle for almost no work each time, making the whole batch slower overall; too large, and a single chunk risks hitting PHP's execution time limit before it finishes, defeating the entire point of chunking. 25-100 is a reasonable starting range for typical entity saves — tune it based on how expensive your per-entity logic actually is.)

Key takeaways

  • Never loop over thousands of entities in a single request — use the Batch API to split the work across many bounded requests instead.
  • A batch operation callback runs once per HTTP request, not once per item; it's responsible for tracking its own progress and processing a fixed-size chunk each time.
  • $context['sandbox'] persists across calls to the same operation — use it to store the full ID list and a progress counter on the first call.
  • Load, modify, and save entities one at a time inside the chunk loop to keep peak memory bounded, rather than accumulating loaded entities in an array first.
  • Choose a chunk size that balances per-request overhead against the risk of hitting PHP's execution time limit — 25-100 entities per chunk is a reasonable starting point for typical saves.

Coming up next

You've now covered the full "Entity and Field Optimization" arc: loading only what you need, tagging it so it caches correctly, rendering it efficiently, querying it fast, and batching bulk changes safely. Next, we shift focus to the server itself — configuring PHP OpCache so all of this application-level work runs on already-compiled code instead of re-parsing PHP on every single request.