Wira Ciputra

← writing

phpsymfonydoctrine

Doctrine select to iterable with batch

Process millions of rows from a Doctrine query with toIterable() and batched flushing, updated for Symfony 5.4 and Doctrine ORM 2.14.


I was looking for examples on how to do processing on possibly a lot of result (millions) from a doctrine select operation. Then I stumbled upon a stackoverflow question, a blog page, and doctrine docs. While it helped a lot, the stackoverflow answer and the blog page is using functions from older doctrine version. And I decide to post my own ‘updated’ version.

I am using pinned version of each library for my projects. So here is my current composer.json version at the time of this writing: (symfony = v5.4.*, doctrine/orm = 2.14.1, doctrine/doctrine-bundle = 2.9.0).

printf("Starting with memory usage: %d MB\n", \memory_get_usage(true) / 1024 / 1024);

$batchSize = 1000; // flush for every batch-size
$numberOfRecordsPerPage = 5000; // number of records for each SQL query
$totalRecordsProcessed = 0;

while (true) {
    $myQuery = $this->entityManager
        // ORDER BY matters: setFirstResult() paging is only stable with a
        // deterministic sort, otherwise rows can be skipped or repeated.
        ->createQuery('SELECT u FROM App\Entity\User u ORDER BY u.id ASC')
        ->setMaxResults($numberOfRecordsPerPage)
        ->setFirstResult($totalRecordsProcessed)
    ;

    $recordsInThisPage = 0;

    // toIterable() yields the entity itself, not a [0 => $entity] array
    // like the old iterate() did.
    foreach ($myQuery->toIterable() as $user) {

        // do stuff with the data in $user

        $recordsInThisPage++;
        $totalRecordsProcessed++;

        // clear() detaches everything so the identity map stays flat.
        // Do not detach $user before this, or your changes never get flushed.
        if (($totalRecordsProcessed % $batchSize) === 0) {
            $this->entityManager->flush();
            $this->entityManager->clear();
        }
    }

    // short page means we reached the end
    if ($recordsInThisPage < $numberOfRecordsPerPage) {
        break;
    }
}

$this->entityManager->flush();
$this->entityManager->clear();

printf("Ending with memory usage: %d MB\n", \memory_get_usage(true) / 1024 / 1024);

Two things that trip people up when copying the older answers across:

toIterable() gives you the entity directly. The old iterate() wrapped it in an array, which is why so many snippets say $row[0]. That indexing will just break now.

Do not detach() each row as you go. Those answers were written for read-only passes. If you are modifying entities, detaching one before the flush() means the change is silently dropped. The clear() after each flush is enough to keep memory flat.