2 September 2026
Reading a huge CSV in PHP without exhausting memory
Reading a huge CSV in PHP without exhausting memory
file() returns every line as an array element, so a 400MB CSV needs
400MB of memory before you touch a row - and rather more once PHP's array
overhead is counted. fgetcsv reads one row at a time and never holds
more than that.
<?php
declare(strict_types=1);
/**
* @return Generator<int, array<string, string>>
*/
function csvRows(string $path): Generator
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException("Cannot open {$path}");
}
try {
$header = fgetcsv($handle);
if ($header === false) {
return;
}
// A UTF-8 BOM lands on the first header cell and turns "id" into
// "\u{FEFF}id", so every lookup by that key silently misses.
// Exports out of Excel have one more often than not.
$header[0] = preg_replace('/^\x{FEFF}/u', '', $header[0]);
$line = 1;
while (($row = fgetcsv($handle)) !== false) {
$line++;
// A blank line reads as [null]; skip rather than yielding a
// row of nulls that the caller has to defend against.
if ($row === [null]) {
continue;
}
if (count($row) !== count($header)) {
throw new RuntimeException(
"Line {$line}: expected " . count($header) . ' columns, got ' . count($row)
);
}
yield array_combine($header, $row);
}
} finally {
fclose($handle);
}
}
// Memory stays flat whether the file is 4KB or 4GB
foreach (csvRows('export.csv') as $row) {
echo $row['email'], PHP_EOL;
}
The three things that bite
fgetcsv handles quoted newlines; splitting on \n does not. A CSV
field may legally contain a line break inside quotes. Any solution built
on explode("\n", ...) or a line-by-line read will cut that record in
half, and it will happen on the one row containing an address.
The BOM. Excel writes a UTF-8 byte order mark, it attaches to the
first header cell, and $row['id'] then misses for every row while
$row looks correct in a dump. Strip it once, on the header.
A generator, not an array. Returning array from this function undoes
the whole point - the caller ends up with every row in memory anyway.
yield keeps exactly one row alive at a time, and foreach over it reads
identically at the call site.
finally closes the handle even if the caller breaks out of the loop
early or an exception is thrown mid-file.