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.

Filed under