Array Functions Performance Comparison: array_map, foreach, Generators
AI generated
<?php
8.4
PHP · Array Functions · Generators · Performance
Array Functions Performance Comparison
array_map, foreach and generators put to the test

Switching randomly between array_map, foreach and generators on large datasets risks unnecessary memory usage and hard to predict runtimes. This article compares the most important array functions in PHP 8.4 with real benchmarks, shows when generators with yield make the decisive difference, and provides clear decision rules for everyday project work.

13 min read array_map · foreach · generators · yield PHP 8.4

1. Why choosing the right array function affects performance and memory

In PHP, there are usually several ways to iterate over a dataset: the functional array functions such as array_map, the classic foreach loop, or a generator using yield. All three produce the same business result in the end, but they differ fundamentally in how much memory they occupy at which point in time and how quickly the first result becomes available. Anyone unaware of these differences often picks the most convenient array function instead of the most suitable one, which is irrelevant for small datasets but leads to noticeable problems with large imports, exports, or streaming workloads.

The central difference lies in whether an array function works eagerly or lazily. array_map and related functions are eager: they process the entire input immediately and store the complete result array in memory before the function returns. A generator, on the other hand, is lazy: it computes each element only when it is actually requested, and never holds the complete result set in memory at any point in time. This property makes generators the most important tool whenever datasets could reach or exceed the amount of available memory.

This article walks through the three central array functions in detail, shows with concrete benchmarks how to measure runtime and memory usage yourself, and ends with practical decision rules for when each array function is the right choice. The focus is deliberately on framework-independent, plain PHP 8.4, the patterns shown work regardless of whether the code runs in a CLI script, a batch job, or a web application.

2. array_map, array_filter, array_reduce: functional array functions in detail

The three most important functional array functions in PHP are array_map, array_filter, and array_reduce. array_map applies a callback to every element of an array and returns a new array with the results, keeping the keys intact when a single input array is used. array_filter keeps only the elements for which the callback returns true, preserving the original keys by default, which often requires an additional array_values() call to re-index after filtering. array_reduce reduces an array step by step to a single value by threading an accumulator through all elements.

The key advantage of these array functions lies in readability and avoiding state variables: instead of declaring an empty result variable and manually filling it in a loop, the code directly describes the transformation. The downside is that all three functions work eagerly. array_map creates a complete new array even if only a fraction of the results is actually needed. With several chained calls like array_map(fn, array_filter(array_map(...))), multiple complete intermediate arrays are also created in memory, which unnecessarily ties up memory for large datasets.

Another point often overlooked with array functions: array_map with multiple input arrays behaves differently than with a single array. When multiple arrays are passed, the keys of the result array are reassigned numerically, regardless of the original keys of the inputs. This detail occasionally causes surprises when you expect associative keys to be preserved automatically.


<?php

declare(strict_types=1);

/** @var list<array{id: int, price: float, active: bool}> $products */
$products = [
    ['id' => 1, 'price' => 19.99, 'active' => true],
    ['id' => 2, 'price' => 49.50, 'active' => false],
    ['id' => 3, 'price' => 9.00, 'active' => true],
];

// array_filter: keep only active products, then re-index with array_values
$activeProducts = array_values(array_filter(
    $products,
    static fn (array $product): bool => $product['active'],
));

// array_map: transform each product into a formatted price string
$priceLabels = array_map(
    static fn (array $product): string => sprintf('#%d: %.2f EUR', $product['id'], $product['price']),
    $activeProducts,
);

// array_reduce: fold all prices into a single total
$totalPrice = array_reduce(
    $products,
    static fn (float $carry, array $product): float => $carry + $product['price'],
    0.0,
);

foreach ($priceLabels as $label) {
    echo $label . PHP_EOL;
}

echo sprintf('Total: %.2f EUR', $totalPrice) . PHP_EOL;

3. foreach compared: references, copies and memory usage

foreach is the most direct and in many cases the most memory-efficient way to iterate over an existing array, because it does not create an additional result array unless one is explicitly built. PHP's copy-on-write mechanism ensures that an array is not physically copied immediately when passed to foreach, only a reference count is incremented. An actual copy only happens once the array is genuinely modified inside the loop, which normally makes foreach a very cheap operation, even for large arrays.

An important distinction among array functions with foreach is the use of references via foreach ($array as &$item). The ampersand allows modifying elements directly in the original array without returning a new array, which saves memory for in-place transformations. The well-known pitfall: after a reference loop, the variable $item remains a reference to the last element. A subsequent foreach loop using the same variable without a fresh & can accidentally overwrite the last element, a classic and hard to spot bug.

Compared directly with array_map, foreach is often marginally faster for simple transformations, because there is no extra function-call overhead for each callback invocation. The difference becomes measurable with very large arrays and simple operations, but tends to disappear into the noise with more complex callbacks. The more important difference remains memory usage under chaining: a foreach loop that processes each element directly can discard intermediate results immediately, while chained array_map calls keep several complete arrays in memory at the same time.


<?php

declare(strict_types=1);

/** @var list<array{id: int, price: float, active: bool}> $products */
$products = [
    ['id' => 1, 'price' => 19.99, 'active' => true],
    ['id' => 2, 'price' => 49.50, 'active' => false],
    ['id' => 3, 'price' => 9.00, 'active' => true],
];

// Reference-based foreach: modify the array in place, no new array is allocated
foreach ($products as &$product) {
    if ($product['active']) {
        $product['price'] *= 0.9; // apply a 10 percent discount
    }
}
unset($product); // break the reference to avoid the classic last-element bug

// Combined filter + transform in a single pass, no intermediate array
$discountedLabels = [];

foreach ($products as $product) {
    if (!$product['active']) {
        continue;
    }

    $discountedLabels[] = sprintf('#%d: %.2f EUR', $product['id'], $product['price']);
}

foreach ($discountedLabels as $label) {
    echo $label . PHP_EOL;
}

4. Generators with yield: lazy evaluation instead of full arrays

A generator is a function that uses the yield keyword instead of return, and therefore returns a Generator object on every call that conforms to the Iterator interface. The decisive difference from the array functions shown so far: the function body is not executed completely right away, it pauses after every yield and only continues once the next value is actually requested, for example by a foreach loop over the generator. This behavior is called lazy evaluation, and it is the reason generators are indispensable for large or even unbounded datasets.

The memory advantage is fundamental: while array_map over a million rows keeps a complete array with a million results in memory, a generator holds only the currently produced element in memory at any point in time. That makes generators the natural choice for processing large CSV files, for streaming database results via PDOStatement::fetch() in a loop, or for producing potentially infinite sequences that could not even be represented technically with classic array functions.

Generators can also yield keys by writing yield $key => $value, and they can delegate to other generators or iterable structures via yield from, which allows composing multiple generators into a pipeline without a complete array ever being created anywhere. A generator's return value, set via a regular return at the end of the function, is accessible through Generator::getReturn(), but only after the generator has been fully iterated.


<?php

declare(strict_types=1);

/**
 * Lazily read a large CSV file line by line, never holding the full file in memory.
 *
 * @return Generator<int, array{id: int, price: float, active: bool}>
 */
function readProductsFromCsv(string $path): Generator
{
    $handle = fopen($path, 'rb');

    if ($handle === false) {
        throw new RuntimeException(sprintf('Cannot open CSV file "%s"', $path));
    }

    try {
        while (($row = fgetcsv($handle)) !== false) {
            // Yield one parsed row at a time, only ever holding this single row in memory
            yield [
                'id' => (int) $row[0],
                'price' => (float) $row[1],
                'active' => $row[2] === '1',
            ];
        }
    } finally {
        fclose($handle);
    }
}

$totalActivePrice = 0.0;
$activeCount = 0;

// Each row is processed and discarded immediately, memory stays constant
foreach (readProductsFromCsv('products.csv') as $product) {
    if (!$product['active']) {
        continue;
    }

    $totalActivePrice += $product['price'];
    $activeCount++;
}

echo sprintf('Processed %d active products, total: %.2f EUR', $activeCount, $totalActivePrice) . PHP_EOL;

5. Memory profile: measuring memory_get_usage() on large datasets

Claims about the memory usage of array functions can be verified directly in PHP without needing external profiling tools. The function memory_get_usage(true) returns the memory currently allocated by the PHP process, including blocks reserved by the system but not yet used, while memory_get_usage(false) returns only the memory actually occupied by PHP values. For comparing different array functions, memory_get_peak_usage() is usually the more meaningful metric, since it returns the highest memory level ever reached since script start, regardless of whether memory was released again in between.

When measuring, it is important to account for the garbage collector: calling gc_collect_cycles() before the measurement ensures that no circular references from previous test runs skew the measured value. Likewise, the code section to be measured should be placed in its own function, so local variables automatically fall out of scope after the function ends and their memory can be released before the next variant is measured.

A typical measurement result for an array with a million integers: array_map with a simple transformation easily occupies several dozen megabytes combined for input and output array, while an equivalent generator with yield only needs a few kilobytes of constant memory, regardless of the total number of elements. This difference grows linearly with the dataset size and quickly becomes the limiting factor with several million records, especially when memory_limit in php.ini is configured conservatively.


<?php

declare(strict_types=1);

/**
 * Measure peak memory usage of a callable in bytes.
 */
function measurePeakMemory(callable $work): int
{
    gc_collect_cycles();
    $before = memory_get_peak_usage(true);

    $work();

    $after = memory_get_peak_usage(true);

    return $after - $before;
}

function buildWithArrayMap(int $count): array
{
    $numbers = range(1, $count);

    return array_map(static fn (int $n): int => $n * $n, $numbers);
}

function buildWithGenerator(int $count): Generator
{
    for ($i = 1; $i <= $count; $i++) {
        yield $i * $i;
    }
}

$eagerBytes = measurePeakMemory(static function () {
    $result = buildWithArrayMap(1_000_000);
    unset($result);
});

$lazyBytes = measurePeakMemory(static function () {
    $sum = 0;

    foreach (buildWithGenerator(1_000_000) as $square) {
        $sum += $square;
    }
});

echo sprintf('array_map peak delta: %d bytes', $eagerBytes) . PHP_EOL;
echo sprintf('generator peak delta: %d bytes', $lazyBytes) . PHP_EOL;

6. Benchmark methodology: hrtime(), repetitions and measurement pitfalls

For runtime comparisons between array functions, hrtime(true) is the right choice, since the function returns a monotonic timestamp in nanoseconds that cannot be skewed by system clock adjustments such as NTP synchronization, unlike microtime(true). A single measurement run is almost never meaningful, however, since JIT warmup, opcache state and operating system scheduling can cause significant variance. The correct approach is to repeat the same operation multiple times, look at the median instead of the average, and discard the first runs as warmup.

A common methodological mistake is comparing array functions using tiny test data of ten elements. With such small datasets, the overhead of the function call itself dominates the result, and the measured differences say nothing about behavior with realistic datasets of tens of thousands or millions of elements. Equally important: a benchmark should actually use the result of the computation, for example by summing or printing it, since the opcache and occasionally the engine itself can optimize away purely "dead code" computations with no visible effect.

When comparing eager and lazily working array functions, it must also be checked whether all produced values are really consumed. A generator that only delivers the first ten of a million possible values because the consuming loop breaks early with break is naturally extremely fast, but that is not a fair comparison to an array_map call that necessarily computes all values. A clean benchmark ensures that both variants perform the same actual amount of work.

Approach Memory behavior Readability Suitable use case
array_map / array_filter Eager, complete result array in memory Very high, declarative Small to medium, finite datasets
foreach (direct iteration) No extra array, copy-on-write usable Medium, explicit Complex logic, in-place changes via reference
Generator with yield Constant, independent of dataset size High with simple logic Large files, DB streaming, unbounded sequences
array_reduce Eager, but no result array (only accumulator) High for aggregations Sums, summaries from finite arrays
iterator_to_array(Generator) Materializes a full array again Medium Only when an API strictly requires an array

7. When generators pay off and when they are unnecessary

Generators are not a cure-all, and for small arrays that already reside completely in memory, they offer no advantage over the classic array functions. If an array with a hundred elements is already fully loaded into memory, for example because it was loaded from a configuration file, converting it into a generator only adds overhead from the generator machinery without actually saving any memory. Generators show their advantage only once the data source itself is lazy, or once the total amount of data is larger than what should reasonably be held completely in memory.

Typical scenarios where generators clearly pay off: reading large CSV or log files line by line, streaming database results with thousands or millions of rows without a full result-set buffer, generating combinatorial sequences such as permutations whose full materialization would be practically impossible, and producer-consumer pipelines where data should be processed step by step and passed along immediately instead of waiting for all data to be present.

A special case often overlooked with array functions: as soon as you need to access an array repeatedly, for example to sort it, determine its length via count(), or iterate backwards, a classic array is usually the better choice. A generator can generally only be iterated forward once, a second iteration attempt over the same generator object throws an Exception. Anyone needing repeated access to the same data should either use an array or create a new generator whenever needed.

8. Combinations: array_map with generators, iterator_to_array and pipelines

The different approaches are not mutually exclusive, they can be combined meaningfully. array_map does not directly support generator-based, iterable arguments, but a generator can be given its own transformation beforehand by transforming the generator itself with yield, instead of first converting it completely into an array. This technique is often called a "generator pipeline": one generator reads raw data, a second generator transforms the values of the first, a third filters them, and only at the end does a foreach loop consume the final result, without a complete intermediate array ever being created anywhere.

If an existing API strictly requires a real array, for example because a library function like array_slice or sort needs to be applied to it, a generator can be fully materialized using iterator_to_array(). You should be aware that this completely loses the generator's memory advantage at that point, the result array occupies just as much memory as an equivalent array_map call. iterator_to_array() is therefore not a substitute for a thoughtful decision, but a tool for the edge case where a lazy data source needs to be connected to an eager interface.

Another useful combination is merging multiple generators with yield from, which allows treating several data sources like multiple CSV files, one after another, as a single logical data stream, without one file needing to be completely processed before the next one begins. This technique not only reduces memory usage, it also considerably simplifies the calling code, because it does not need to know how many sources the data actually comes from.


<?php

declare(strict_types=1);

/**
 * @param iterable<int, array{id: int, price: float, active: bool}> $products
 * @return Generator<int, array{id: int, price: float, active: bool}>
 */
function filterActive(iterable $products): Generator
{
    foreach ($products as $product) {
        if ($product['active']) {
            yield $product;
        }
    }
}

/**
 * @param iterable<int, array{id: int, price: float, active: bool}> $products
 * @return Generator<int, string>
 */
function formatAsLabel(iterable $products): Generator
{
    foreach ($products as $product) {
        yield sprintf('#%d: %.2f EUR', $product['id'], $product['price']);
    }
}

/**
 * Merge multiple CSV sources into a single lazy stream.
 *
 * @param list<string> $paths
 * @return Generator<int, array{id: int, price: float, active: bool}>
 */
function mergeCsvSources(array $paths): Generator
{
    foreach ($paths as $path) {
        yield from readProductsFromCsv($path);
    }
}

// A three-stage generator pipeline: no intermediate array is ever fully materialized
$pipeline = formatAsLabel(filterActive(mergeCsvSources(['products_2025.csv', 'products_2026.csv'])));

foreach ($pipeline as $label) {
    echo $label . PHP_EOL;
}

9. Readability vs. performance: practical decision rules for array functions

Choosing between the different array functions should not be a gut decision, it should be based on the actual dataset size and access pattern. For small, finite arrays with a few thousand elements, readability usually matters more than the last millisecond of performance, here array_map, array_filter and array_reduce win thanks to their declarative style. Premature optimization with generators at this point only increases complexity without providing a measurable benefit.

Once datasets exceed several tens of thousands of elements, are loaded externally, for example from a database or file, or could theoretically be unbounded, generators should become the first choice. This is especially true for import and export processes, batch processing, and anything running in a cron job or a queue, where a memory-limit error aborts the entire process. A simple rule of thumb: as soon as you find yourself wondering whether a process "will have enough memory", that is already a strong signal to switch to a generator.

foreach remains the right choice when the per-element logic becomes too complex for a compact callback, when several conditions need to be checked at once, or when in-place modifications by reference are required. The three approaches are not mutually exclusive: a typical, robust pattern combines a generator as a memory-efficient data source with a clear foreach loop for the actual processing logic, instead of forcing everything into a single chain of array functions.

10. Summary

Choosing between the different array functions in PHP 8.4 directly determines memory usage and runtime once datasets grow. array_map, array_filter and array_reduce work eagerly and create complete intermediate and result arrays in memory, which is not a problem for small, finite datasets but adds up quickly for large ones. foreach stays memory-efficient as long as no additional array is built, and is especially suitable for complex logic and in-place changes by reference.

Generators with yield solve the memory problem fundamentally by computing values lazily instead of eagerly and holding only a single element in memory at any point in time, regardless of the total amount of data. Anyone who does not leave this decision to chance, but bases it on real benchmarks with hrtime() and memory_get_peak_usage(), replaces guesswork about array functions with solid numbers and picks the approach that actually fits the dataset for each use case.

Array Functions Performance Comparison: The Essentials at a Glance

Eager vs. lazy

array_map and foreach work eagerly with complete arrays in memory. Generators with yield compute lazily and hold constant, low memory.

Measure, don't guess

hrtime(true) for runtime, memory_get_peak_usage() for memory. Measure repeatedly, take the median, discard warmup.

Use generators for

Large files, database streaming, unbounded sequences and anywhere memory limits pose a risk.

Preserve readability

For small, finite arrays, the declarative style of array_map/array_filter wins over premature optimization.

11. FAQ: Array Functions Performance Comparison

1Is array_map always slower than foreach?
Not fundamentally, foreach is often only marginally faster for simple transformations. More decisive is memory usage when chaining calls.
2When to use a generator instead of array_map?
For large, external or potentially unbounded datasets. A generator holds only the current element in memory instead of a complete result array.
3Can I iterate a generator more than once?
No, only once forward. A second attempt throws an Exception, for repeated access use an array or a new generator.
4How do I measure memory usage correctly?
memory_get_peak_usage() before and after execution, with a prior gc_collect_cycles() to avoid skewed values from old references.
5Why hrtime() instead of microtime()?
hrtime(true) returns a monotonic nanosecond timestamp that cannot be skewed by system clock adjustments like NTP.
6array_filter with or without array_values?
array_filter preserves original keys, array_values() afterwards creates a re-indexed array starting at 0.
7Does yield from consume extra memory?
No, yield from delegates iteration without buffering values in an array, enabling lazy pipelines.
8When does iterator_to_array() make sense?
Only when an API strictly needs a real array, e.g. for sort(). The generator's memory advantage is lost there.
9Worth it for arrays with a hundred elements?
Usually not, generator overhead outweighs the barely existing memory advantage for such small arrays.
10How do I avoid unfair benchmarks?
Ensure both variants do the same amount of work and consume all values, no early break for only one variant.

Mironsoft

PHP performance analysis and memory optimization for data-intensive processes

Does your import script keep hitting the memory limit?

We analyze existing batch and import processes for inefficient array functions, introduce generators where they genuinely help, and measure the effect with real benchmarks instead of guesswork.

Performance audit

Measuring memory profile and runtime of existing data processing

Refactoring to generators

Replacing memory-heavy array_map chains with lazy pipelines

Benchmark setup

Repeatable performance tests for critical data processing paths