Importing a 1,000,000-row XLSX without a 2 GB memory_limit: streaming reads in PHP
If you have ever imported a large Excel file in PHP, you have almost certainly seen this message:
Fatal error: Allowed memory size of 134217728 bytes exhausted
The classic scenario: a client sends an export of 300,000–500,000 rows, you open it with PhpSpreadsheet —
and the script dies. You raise memory_limit to 512M — it still dies. To 2G — it works, but slowly, and
setting such a limit in production is frightening. Sound familiar?
In this article we look at why that happens and how to read files of practically any size with flat memory use, using avadim/fast-excel-reader.
Everything below was verified against version 4.4.3.
Why Excel files eat memory
An XLSX file is a ZIP archive with XML files inside. A 10 MB file on disk can unpack into 100+ MB of XML. What happens next depends entirely on how the library reads that XML.
General-purpose libraries like PhpSpreadsheet build a full object model of the workbook in memory: every cell is an object with a value, a style, coordinates, and references to neighboring structures. That is necessary when you want to edit a file. But for importing data it is pure overhead: even a small file of a few hundred kilobytes can take up to 200 MB in memory, and a million-row file will not fit into any reasonable limit.
Hence the usual workarounds people recommend to each other on Stack Overflow:
setReadDataOnly(true)— helps partly, the object model is still built;- split the file into pieces before importing — it works, but that is a separate process and one more point of failure;
ini_set('memory_limit', '-1')— good until the first genuinely large file.
All of them fight the symptom. The cause is the “load everything, then process” architecture.
A different approach: a stream instead of an object model
FastExcelReader solves the problem differently. It does not build a workbook model at all:
- the sheet XML is read node by node through
XMLReader— at any moment memory holds only the current row; - iteration over rows is implemented with PHP generators — the next row is parsed only when you ask for it;
- the library only reads data and drags along no infrastructure for writing and editing.
Installation:
composer require avadim/fast-excel-reader
A minimal streaming example:
use avadim\FastExcelReader\Excel;
$excel = Excel::open('huge-report.xlsx');
$sheet = $excel->sheet();
foreach ($sheet->nextRow() as $rowNum => $rowData) {
// $rowData is an array like ['A' => ..., 'B' => ..., ...]
// only THIS row is in memory right now
processRow($rowData);
}
nextRow() returns a generator. However many rows the file holds — a thousand or a million — peak memory
stays practically constant: it is defined by the size of one row and the shared structures (shared strings,
styles), not by the size of the file.
Measuring: how much memory is actually needed
It is easy to check — a simple script with memory_get_peak_usage():
use avadim\FastExcelReader\Excel;
$start = microtime(true);
$excel = Excel::open('huge-report.xlsx'); // a file of 1,000,000 rows
$count = 0;
foreach ($excel->sheet()->nextRow() as $rowData) {
$count++;
}
printf(
"Rows: %d\nTime: %.1f sec\nPeak memory: %.1f MB\n",
$count,
microtime(true) - $start,
memory_get_peak_usage(true) / 1024 / 1024
);
Our measurement on PHP 8.4: a file of 1,000,000 rows by four columns, 20 MB on disk — reading took 491 seconds, with a peak memory use of 2 MB. Not hundreds, not tens — two. The exact numbers depend on the file (the count of unique strings in shared strings matters most), but the main point is that memory use does not grow with the number of rows. A file of two million rows needs memory of the same order as a file of a hundred thousand.
For comparison, run the same file through “load everything into an array”:
$rows = $excel->readRows(); // DO NOT do this with large files
readRows() is convenient for small files, but it collects the whole result into an array — on a million
rows you hit the memory wall again, now on the result side rather than the parser side.
Practical techniques for large files
1. Process rows one at a time and do not hoard them
Bad:
$all = [];
foreach ($sheet->nextRow() as $rowData) {
$all[] = transform($rowData); // memory grows with every row
}
save($all);
Good:
$batch = [];
foreach ($sheet->nextRow() as $rowData) {
$batch[] = transform($rowData);
if (count($batch) >= 1000) {
save($batch); // saved a portion
$batch = []; // and freed the memory
}
}
if ($batch) {
save($batch);
}
Memory is bounded by the batch size, not by the file size.
2. Skip empty rows and junk at the reader level
By default the library already skips completely empty rows. You can tighten the rules — for instance, treat cells holding empty strings and whitespace as empty:
foreach ($sheet->nextRow([], Excel::TRIM_STRINGS | Excel::TREAT_EMPTY_STRING_AS_EMPTY_CELL) as $rowData) {
if (!$rowData) {
continue; // a row of nothing but spaces arrives as an empty array
}
// from here on — real data only
}
An important detail: the flags act at the level of cells, not rows. Completely empty rows are skipped by
the reader even without the flags, whereas a row holding spaces and empty strings arrives as an empty array
with these flags on — which is why the loop carries if (!$rowData) continue;. Still cheaper than cleaning
values by hand after reading.
3. Read only the area you need
If the data occupies a specific range, there is no point in scanning the whole sheet:
$sheet->setReadArea('B4:F100000');
foreach ($sheet->nextRow() as $rowData) {
// only columns B..F, starting from row 4
}
4. Stop reading when the data ends
readCallback() gives you full control over the process — return true and reading stops:
$excel->readCallback(function ($row, $col, $val) {
// process the cell
if ($row > 500000) {
return true; // stop — read no further
}
return false;
});
5. Step-by-step reading instead of foreach
If the rows must be read outside a single loop (interleaved with other logic, say), there is the pair
reset() + readNextRow():
$sheet->reset();
$header = $sheet->readNextRow(); // the first row
while ($rowData = $sheet->readNextRow()) {
// the following rows — on demand
}
What about CSV and legacy XLS?
The same streaming approach works for every supported format:
// CSV — row-by-row reading through a generator
$csv = Excel::openCsv('huge.csv');
foreach ($csv->nextRow() as $rowData) { /* ... */ }
// Legacy XLS (Excel 97-2003) opens with the same Excel::open() —
// the format is detected from the file signature, the API is identical
$excel = Excel::open('legacy.xls');
One API, three formats, and flat memory use everywhere.
When FastExcelReader is not the right fit
In fairness: the library only reads files. If you need to open an XLSX, change a couple of cells, and save — that is a job for FastExcelWriter or PhpSpreadsheet. If you need formulas evaluated — also not here: the reader returns the formula text and the last calculated value, but does not recompute it.
But if your task is “read data out of Excel and do something with it” — and that is 90% of real import work — a streaming reader does it an order of magnitude faster and far more frugally with memory.
Summary
- An XLSX of hundreds of thousands or millions of rows is no reason to raise
memory_limitand build file splitters. - The key to flat memory is streaming:
XMLReaderplus generators instead of an object model. nextRow()is your main tool;readRows()is for small files only.- Batches, read areas, and early exit let you squeeze out speed as well.
composer require avadim/fast-excel-reader
Documentation and examples are in the GitHub repository.