Reading a CSV File in PHP With fgetcsv and SplFileObject
Read CSV in PHP 8.5 with fgetcsv and SplFileObject: the $escape deprecation, a backslash that swallows the file, str_getcsv traps and the BOM.

PHP reads CSV with a function that has been in the language for twenty years, fgetcsv, and for most of those years nobody had to think about its fifth parameter. That parameter, $escape, defaults to a backslash that RFC 4180 does not have, and PHP 8.4 started deprecating the reliance on it. This article uses PHP 8.5, shows what the backslash does to real data, reads the series' test files with fgetcsv, SplFileObject and str_getcsv, and times 2,000,000 rows. The comparison with other languages is in the guide.
The five arguments
fgetcsv reads one record from an open file handle and returns an array, or false at the end. All five parameters, spelled out:
<?php
// Print each record fgetcsv returns, with the escape argument spelled out.
$path = $argv[1];
$escape = $argv[2] ?? '';
$h = fopen($path, 'r');
$n = 0;
while (($row = fgetcsv($h, null, ',', '"', $escape)) !== false) {
$n++;
echo json_encode($row, JSON_UNESCAPED_UNICODE), "\n";
}
fclose($h);
echo "records: $n\n";php -v | head -n 1PHP 8.5.12 (cli) (built: Oct 21 2026 15:53:40) (NTS)php people.php ../data/people.csv["id","name","city","note"]
["1","Ana Silva","Lisbon","likes tea"]
["2","Smith, Jane","Boston","said \"hello\""]
["3","Bo Chen","Oslo","line one\nline two"]
["4","Cy Dubois","Lyon",""]
records: 5The arguments are the handle, a maximum line length (null for none), the delimiter, the enclosure and the escape. With '' as the escape, the output is what you would want: "Smith, Jane" is one field, said "hello" has its quotes resolved, and "line one\nline two" holds the line break. The last record has an empty note, "". There are 5 records, header included.
If you leave the arguments off, the way most existing code does, PHP 8.5 complains:
php noescape.php ../data/people.csv
Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change in /b/lab/lang/php/noescape.php on line 4
["id","name","city","note"]Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change. The notice is aimed at code that calls fgetcsv($h) or fgetcsv($h, null, ','), and it will appear in logs for as long as the code does. The fix is to pass the escape explicitly, and the question it forces is which value you mean.
The backslash escape
In PHP's legacy behavior, a backslash inside a quoted field protects the following quote from ending the field. That is not how CSV works, and it has consequences when data contains backslashes, such as Windows paths, regular expressions and file names. A file with a field "C:\temp\":
<?php
// A field holding a backslash before a closing quote: the legacy escape character changes the parse.
$data = "id,path\n1,\"C:\\temp\\\"\n2,plain\n";
file_put_contents('/tmp/esc.csv', $data);
foreach (['\\', ''] as $esc) {
$h = fopen('/tmp/esc.csv', 'r');
$rows = [];
while (($r = fgetcsv($h, null, ',', '"', $esc)) !== false) { $rows[] = $r; }
echo 'escape=' . var_export($esc, true) . ' -> ' . json_encode($rows) . "\n";
}
echo "file:\n$data";php escape.phpescape='\\' -> [["id","path"],["1","C:\\temp\\\"\n2,plain\n"]]
escape='' -> [["id","path"],["1","C:\\temp\\"],["2","plain"]]
file:
id,path
1,"C:\temp\"
2,plainWith the escape set to a backslash (the legacy default), the second row came back as ["1", "C:\\temp\\\"\n2,plain\n"]: the closing quote was treated as escaped, so the field ran on to the end of the file, taking the next record with it. There was no error, and the second record was gone, inside the first. With an empty escape the same bytes parsed as ["1","C:\\temp\\"] and ["2","plain"]. In RFC 4180 CSV, '' is correct, and I use it everywhere. If you read files written by a tool that does use backslash escapes, such as a MySQL export, keep the backslash for those and know why.
Blank lines, SplFileObject and the last row
SplFileObject wraps the same reader in an iterator, which makes foreach possible:
<?php
$f = new SplFileObject($argv[1]);
$f->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$f->setCsvControl(',', '"', '');
foreach ($f as $i => $row) { echo $i, ': ', json_encode($row, JSON_UNESCAPED_UNICODE), "\n"; }
$g = new SplFileObject($argv[1]);
$g->setFlags(SplFileObject::READ_CSV);
$g->setCsvControl(',', '"', '');
$n = 0; $last = null;
foreach ($g as $row) { $n++; $last = $row; }
echo "without SKIP_EMPTY: $n iterations, last row: ", json_encode($last), "\n";php spl.php ../data/people.csv0: ["id","name","city","note"]
1: ["1","Ana Silva","Lisbon","likes tea"]
2: ["2","Smith, Jane","Boston","said \"hello\""]
3: ["3","Bo Chen","Oslo","line one\nline two"]
4: ["4","Cy Dubois","Lyon",""]
without SKIP_EMPTY: 6 iterations, last row: [null]The first loop uses READ_CSV | READ_AHEAD | SKIP_EMPTY | DROP_NEW_LINE and printed the 5 records. The second uses only READ_CSV, and printed 6 iterations, last row: [null]. The extra iteration is the empty string after the final newline, which fgetcsv reports as [null], an array with one null element. A loop that reads $row[0] of the last element gets null and goes on to insert a blank record. The flag combination in the first loop is the one to use. Pass the escape through setCsvControl(',', '"', '') as well, since the iterator has the same default.
The same applies to blank lines in the middle of a file: fgetcsv returns [null] for each, and array_filter on the row does not tell a blank line from a row of one empty field. Check $row === [null].
str_getcsv, and why it is not a file reader
str_getcsv parses one string. It is tempting to read a file with explode("\n", $text) and call it on each line, and that is the multi-line bug:
<?php
$text = file_get_contents($argv[1]);
echo "split lines then str_getcsv:\n";
foreach (explode("\n", trim($text)) as $line) { echo json_encode(str_getcsv($line, ',', '"', '')), "\n"; }
echo "str_getcsv on the whole text: ", json_encode(str_getcsv($text, ',', '"', '')), "\n";php strgetcsv.php ../data/people.csvsplit lines then str_getcsv:
["id","name","city","note"]
["1","Ana Silva","Lisbon","likes tea"]
["2","Smith, Jane","Boston","said \"hello\""]
["3","Bo Chen","Oslo","line one"]
["line two\""]
["4","Cy Dubois","Lyon",""]
str_getcsv on the whole text: ["id","name","city","note\n1","Ana Silva","Lisbon","likes tea\n2","Smith, Jane","Boston","said \"hello\"\n3","Bo Chen","Oslo","line one\nline two\n4","Cy Dubois","Lyon",""]Splitting on newlines first cut the record for Bo Chen into ["3","Bo Chen","Oslo","line one"] and ["line two\""]. And calling str_getcsv on the whole text did not parse the records at all: it returned one array of 16 values, with the line breaks inside the first field of each following row ("note\n1", "likes tea\n2"). The function treats its argument as a single record. Use fgetcsv on a stream. For a string in memory, open it with php://memory or php://temp and read it back through fgetcsv.
The BOM and other oddities
PHP does not skip a byte order mark. The first header for bom.csv came back as "\ufeffid":
php people.php ../data/bom.csv | head -n 1; php -r '$h=fopen("../data/bom.csv","r"); if (fread($h,3)==="\xEF\xBB\xBF") {echo "BOM skipped, header: "; echo json_encode(fgetcsv($h, null, ",", "\"", ""))."\n";}'["id","name"]
BOM skipped, header: ["id","name"]The first line is the header from a plain read, with the invisible character in front of id. The second reads the first three bytes, checks them against EF BB BF, and then continues with fgetcsv; the header was ["id","name"]. Do this in the function that opens files, so that no caller has to remember. A file in another encoding needs mb_convert_encoding or stream_filter_append with convert.iconv.* before you parse it.
Field counts are not checked either:
php people.php ../data/ragged2.csv["id","name","city","note"]
["1","Ana","Lisbon","likes tea"]
["2","Bo","Oslo","likes coffee"," no sugar"]
["3","Cy","Lyon",""]
["4","Di","Rome","fine",""]
["5","Ed","Bern"]
["6","Flo","Kyiv","first line"]
["second line of the note"]
["7","Gus","Riga","ok"]
records: 9The ragged file came back as 9 records of varying width, ["5","Ed","Bern"] with three fields and ["second line of the note"] with one. If a column count matters, compare count($row) with the header's, and keep your own line counter. An unclosed quote is silent as well:
php people.php ../data/unterminated.csv | awk '{print "line", NR ":", length($0), "bytes"}' | head -n 4line 1: 20 bytes
line 2: 18 bytes
line 3: 231599 bytes
line 4: 10 bytesThe third record of the output is one line of 231,599 bytes: the field that started at the stray quote and ran to the end of the file. As with Rust and C#, PHP reads the invalid file leniently, so add a check for a field longer than anything your data should contain.
Speed and memory
Three ways to sum the amount column of the 2,000,000 row file:
<?php
$path = $argv[1];
$mode = $argv[2];
$t = microtime(true);
$total = 0.0; $rows = 0;
if ($mode === 'stream') {
$h = fopen($path, 'r');
fgetcsv($h, null, ',', '"', '');
while (($r = fgetcsv($h, null, ',', '"', '')) !== false) { $total += (float)$r[3]; $rows++; }
} elseif ($mode === 'generator') {
$gen = (function () use ($path) {
$h = fopen($path, 'r'); $head = fgetcsv($h, null, ',', '"', '');
while (($r = fgetcsv($h, null, ',', '"', '')) !== false) { yield array_combine($head, $r); }
})();
foreach ($gen as $r) { $total += (float)$r['amount']; $rows++; }
} elseif ($mode === 'file') {
$lines = file($path, FILE_IGNORE_NEW_LINES);
array_shift($lines);
foreach ($lines as $l) { $r = str_getcsv($l, ',', '"', ''); $total += (float)$r[3]; $rows++; }
}
printf("%-9s rows=%d total=%.2f time=%.2fs peak_mem=%dMB\n", $mode, $rows, $total, microtime(true) - $t, memory_get_peak_usage(true) / 1048576);for m in stream generator file; do php -d memory_limit=-1 sum.php ../data/orders-2m.csv $m; donestream rows=2000000 total=160174646.36 time=5.49s peak_mem=2MB
generator rows=2000000 total=160174646.36 time=5.82s peak_mem=2MB
file rows=2000000 total=160174646.36 time=5.83s peak_mem=341MBStreaming with fgetcsv took 5.49 s with a peak of 2 MB. The generator version, which yields array_combine($head, $r) for each row so the code can use names, took 5.82 s at 2 MB: the convenience costs 6%. Reading the whole file with file() and calling str_getcsv on each line took 5.83 s and 341 MB. All gave 160174646.36. The last one only works because this file has no embedded newlines, and it keeps the whole file in memory. PHP is slower than the compiled languages on this task (Go 0.98 s, Rust 0.24 to 0.42 s, Java 1.13 s),; for a large import, the database's own loader (LOAD DATA or COPY) removes the loop, as the PostgreSQL article shows.
Writing with fputcsv
fputcsv takes the same arguments, in the same order, plus a line ending:
<?php
$h = fopen('php://output', 'w');
fputcsv($h, ['id', 'name', 'note'], ',', '"', '');
fputcsv($h, [1, 'Smith, Jane', 'said "hi"'], ',', '"', '');
fputcsv($h, [2, 'Zoë', "line one\nline two"], ',', '"', '');
fputcsv($h, [3, ' padded', null], ',', '"', '');
fputcsv($h, [4, '=SUM(A1)', 0.1 + 0.2], ',', '"', '');
fputcsv($h, [5, true, false], ',', '"', '', "\n");php write.phpid,name,note
1,"Smith, Jane","said ""hi"""
2,Zoë,"line one
line two"
3," padded",
4,=SUM(A1),0.3
5,1,Smith, Jane and said ""hi"" are quoted and the line break forces quotes. " padded" was quoted because of the leading space. null was written as an empty field, 0.1 + 0.2 as 0.3 (PHP prints floats with 14 significant digits by default, via the precision setting, so the output depends on an ini value), true as 1 and false as nothing. =SUM(A1) was written as it is, with no formula guard. Pass the escape and the line ending explicitly: the last call uses "\n" as the sixth argument, and the default is "\n" as well, not "\r\n".
How to tell it worked
Run with error_reporting(E_ALL) and fix every deprecation. Compare the record count with another tool. Read a file with a backslash in a field, a blank line, a short record and a BOM. Check the longest field. And for imports, wrap the reader in a function that takes the path and returns a generator, with the escape, the BOM and the width check inside, so that every caller gets the same behavior.
What PHP's CSV functions hide
The legacy escape, the [null] for a blank line, the single-record str_getcsv, the lack of a BOM check and the silent ragged rows. All of them are fine once you know. If the import is big or the file is hostile, consider DuckDB or the database's loader and keep PHP for the application. The format reference is RFC 4180.


