CSV in PHP: fgetcsv/fputcsv, Streaming Large Files, and Malformed Data

Read and write CSV correctly with fgetcsv()/fputcsv(), stream arbitrarily large CSV files a row at a time instead of loading them fully into memory, and handle the real-world messiness of CSV files -- byte-order marks, inconsistent delimiters, embedded quotes/newlines inside fields, and mixed character encodings.

CSV looks trivial -- split each line on commas -- until a field itself contains a comma, a quote, or a newline, at which point naive string-splitting silently produces corrupted data instead of an error. PHP's fgetcsv() and fputcsv() correctly implement the actual CSV quoting rules (RFC 4180-style, with the usual real-world deviations), and used with a file handle rather than a fully-loaded string, they let a PHP script process a CSV file of any size -- a million-row export, a gigabyte-scale data dump -- using a small, constant amount of memory no matter how large the file is. The genuinely hard part of CSV work in practice is rarely the happy path; it's the malformed row, the stray byte-order mark, and the file that quietly isn't UTF-8.

Reading with fgetcsv(): correct quoting, one row at a time

fgetcsv($handle, 0, ',', '"', '\\') reads exactly one line's worth of CSV from an open file handle (opened with fopen($path, 'r')) and returns it already parsed into an array of field values, correctly handling a quoted field that contains the delimiter itself ("Smith, John"), an escaped quote inside a quoted field ("She said ""hello""" becomes the single field She said "hello"), and -- critically -- a quoted field that spans multiple physical lines because it contains a literal newline character inside the quotes. That last case is exactly why fgetcsv() must be used rather than reading the file line-by-line with fgets() and splitting each line on commas: a naive line-based split would see a newline inside a quoted field as the end of a row and corrupt the data, while fgetcsv() correctly continues reading from the handle until the quoted field actually closes.

The length parameter (0 in modern PHP delegates to reading the whole line regardless of length, and is the value to use unless there's a specific reason to cap it) and the delimiter/enclosure/escape character parameters matter because not every CSV-like file uses a comma and a double quote -- semicolon-delimited exports are extremely common from European-locale spreadsheet software, and pipe-delimited (|) or tab-delimited (effectively TSV) exports show up regularly from database tools. Reading a file with the wrong delimiter doesn't usually error -- it just silently produces one giant field per row instead of many correctly-split fields, so a mismatched delimiter is a data-quality bug, not a crash, and worth actively checking for on unfamiliar input files.

fgetcsv() returns false when it hits end-of-file, and (since PHP 8.1, with empty string handling) can also return an array of a single null value for a genuinely blank line -- both cases need explicit handling in a read loop rather than assuming every non-false return is a real data row.

• fgetcsv($handle, 0, $delimiter, $enclosure, $escape) parses one row per call, correctly handling quoted commas, escaped quotes, and embedded newlines inside a quoted field. • Never split CSV lines by hand with explode(',', fgets($handle)) -- it corrupts any row containing a quoted comma or an embedded newline. • Delimiter mismatches (comma vs. semicolon vs. tab) don't error -- they silently produce one big field per row; inspect an unfamiliar file's first line before parsing at scale. • fgetcsv() returns false at end-of-file -- loop with while (($row = fgetcsv($handle)) !== false) rather than a for loop assuming a known row count.

Writing with fputcsv(): correct escaping on the way out

fputcsv($handle, $fields, ',', '"', '\\') is the write-side mirror of fgetcsv() -- given an array of field values, it writes one correctly-quoted CSV row to the file handle, automatically wrapping any field that itself contains the delimiter, the enclosure character, or a newline in quotes (and doubling any literal quote characters inside it), so the values round-trip correctly through fgetcsv() later without any manual escaping logic in application code. Building a CSV row by hand with implode(',', $fields) is the write-side version of the same mistake as parsing by hand on read -- it silently corrupts any field containing a comma, quote, or newline instead of quoting it correctly.

As of PHP 8.1, fputcsv() (and fgetcsv()) accept a fifth $escape parameter that changes what character is used to escape the enclosure character inside a field -- passing an empty string '' disables PHP's non-standard backslash-escaping behavior entirely and produces output that strictly follows RFC 4180's double-the-quote-character convention, which matters when the file needs to be read correctly by tools (Excel, other languages' CSV libraries) that don't recognize PHP's historical backslash-escape extension.

Streaming a large file: bounded memory regardless of file size

The single biggest CSV mistake at scale is loading an entire file into memory first -- file($path) into an array of lines, or reading the whole file into a string and splitting it -- before processing it, which means memory use scales directly with file size and a large enough export (or a maliciously oversized upload) can exhaust available memory and crash the process. The fix is to never load more than one row at a time: open the file with fopen(), loop calling fgetcsv() once per row, process that row immediately (write it to a database, transform and forward it, accumulate only a small running total), and let the row's memory be freed before the next iteration -- for a well-written streaming loop, a 50-row file and a 50-million-row file use essentially the same amount of memory.

The same principle applies symmetrically on the output side for generating a large CSV export: open a stream (a temp file, or directly php://output when streaming an HTTP response), loop over the data source (ideally itself a generator or a chunked database cursor rather than a fully-materialized array) calling fputcsv() once per row, and flush periodically -- this lets a PHP script generate and stream a multi-gigabyte CSV export to a browser download without ever holding the whole dataset in memory at once, exactly the same streaming discipline that applies to a large generated XML document.

• Never file($path) or file_get_contents($path) an entire CSV into memory before parsing -- read row-by-row with fopen() + fgetcsv() instead. • Streaming a CSV HTTP export: fopen('php://output', 'w'), loop fputcsv() per row pulled from a generator/cursor, never build the full dataset as one array first. • Memory use in a correctly streamed read or write loop is proportional to one row, not the file size. • Pull rows from a generator or a chunked/paginated database cursor feeding the write loop -- materializing the full result set as an array defeats the point of streaming the output.

Malformed data and encoding: the BOM, ragged rows, and non-UTF-8 bytes

A UTF-8 byte-order mark (BOM) -- three specific bytes (EF BB BF) that some tools, notably Excel on Windows, prepend to a CSV file to signal its encoding -- is invisible when the file is viewed as text but corrupts the very first field's value if left in place: a header row's first column name silently gets those three extra bytes glued onto the front of it, so a comparison like $row[0] === 'id' fails even though the value visually looks identical to 'id'. The fix is to strip a leading BOM explicitly right after opening the file, before the first fgetcsv() call, by checking whether the first three bytes read match the BOM sequence and discarding them if so.

Real-world CSV files routinely violate their own apparent structure: a row with fewer fields than the header (a trailing empty column silently dropped by whatever produced the file), a row with more fields than expected (an unescaped delimiter inside a field that should have been quoted but wasn't), or a completely blank line in the middle of the data. Defensive code checks count($row) against the expected column count on every row rather than assuming positional indices always line up, logs or skips a row that doesn't match rather than either crashing the whole import or silently misassigning values to the wrong columns, and treats a persistently malformed file as a data-quality problem to surface to whoever's responsible for the export, not something to paper over silently.

Encoding beyond the BOM is its own hazard: a file that isn't actually UTF-8 (a legacy export in Windows-1252/Latin-1, common from older Windows software) will pass through fgetcsv() as raw bytes without complaint, but those bytes will then break anything downstream that assumes valid UTF-8 -- a json_encode() call returning false, or a database insert into a UTF-8 column producing mangled characters. Detecting and converting encoding explicitly, with mb_detect_encoding() as an imperfect heuristic and mb_convert_encoding($value, 'UTF-8', 'Windows-1252') (or the specific source encoding, once known) applied per-field, is safer than assuming every CSV file that shows up is already correctly UTF-8 encoded.

• UTF-8 BOM (EF BB BF): strip it explicitly right after opening the file, or the first header/field on the first row silently carries 3 extra invisible bytes. • Ragged rows: check count($row) against the expected column count per row rather than trusting positional indices blindly -- log/skip a mismatched row instead of crashing or misassigning. • Non-UTF-8 source files (Windows-1252/Latin-1 are common): don't assume UTF-8 -- detect/convert explicitly with mb_convert_encoding() per field before using the values downstream. • A malformed CSV import should surface a clear error/skip-count, not fail silently or halt the entire import on the first bad row.

Streaming CSV read with BOM stripping and row-count validation

readCsvRows() is a generator -- 'yield $row' hands one row at a time to the caller's foreach loop without ever holding the rest of the file in memory. Passing '' as the fifth (escape) argument to fgetcsv() disables PHP's non-standard backslash-escape handling in favor of strict RFC 4180 double-quote escaping.

<?php
declare(strict_types=1);

function readCsvRows(string $path, int $expectedColumns): Generator
{
    $handle = fopen($path, 'r');
    if ($handle === false) {
        throw new RuntimeException("Cannot open $path");
    }

    // Strip a UTF-8 BOM if present, before the first fgetcsv() call.
    $bom = fread($handle, 3);
    if ($bom !== "\xEF\xBB\xBF") {
        rewind($handle);
    }

    $lineNumber = 0;
    while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) {
        $lineNumber++;
        if (count($row) !== $expectedColumns) {
            fwrite(STDERR, "Skipping malformed row $lineNumber: expected $expectedColumns columns, got " . count($row) . "\n");
            continue;
        }
        yield $row;
    }

    fclose($handle);
}

Code Examples

The first fgetcsv() call reads the header row into $header; each subsequent call reads one data row, and array_combine() pairs the header names with that row's values to build an associative array per product. This is the simple, small-file version -- fine for a file with a few hundred rows, but it still builds the whole $products array in memory, which the streaming example below avoids.

while (($row = fgetcsv($handle)) !== false) { Common mistake: Using explode(',', fgets($handle)) instead of fgetcsv() would corrupt any product name or description that legitimately contains a comma, since explode() has no concept of quoted fields.

fopen('php://output', 'w') opens the HTTP response body itself as a writable stream, so each fputcsv() call sends that row to the browser immediately rather than accumulating output in a PHP string or array first. Because $orders is a generator backed by PDO's own row-by-row fetch() cursor rather than a fetchAll(), the entire export -- however many orders there are -- streams with memory proportional to one row at a time on both the database and PHP side. Expected result: order_id,customer_name,total,placed_at 1001,"Smith, John",49.99,2026-03-01 10:15:00 $out = fopen('php://output', 'w');

This combines every defensive technique from the lesson in one streaming generator: BOM stripping before the first read, a blank-line check, a per-row column-count validation that skips (and counts) malformed rows instead of crashing the whole import, and an explicit encoding conversion for source files that aren't already UTF-8 -- all while still yielding one row at a time so memory use stays bounded regardless of file size. yield $lineNumber => $row (rather than a plain yield $row) preserves the original file line number as the generator's key, which is useful for error reporting further down the processing pipeline without threading a separate counter through every consumer.

<?php
declare(strict_types=1);

$handle = fopen('products.csv', 'r');
if ($handle === false) {
    throw new RuntimeException('Cannot open products.csv');
}

$header = fgetcsv($handle);
$products = [];
while (($row = fgetcsv($handle)) !== false) {
    $products[] = array_combine($header, $row);
}
fclose($handle);

foreach ($products as $product) {
    echo $product['name'] . ': $' . $product['price'] . PHP_EOL;
}
<?php
declare(strict_types=1);

function exportOrdersAsCsv(iterable $orders): void
{
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="orders.csv"');

    $out = fopen('php://output', 'w');
    fputcsv($out, ['order_id', 'customer_name', 'total', 'placed_at']);

    foreach ($orders as $order) {
        fputcsv($out, [
            $order->id,
            $order->customerName,
            number_format($order->total, 2),
            $order->placedAt->format('Y-m-d H:i:s'),
        ]);
    }

    fclose($out);
}

// $orders should itself be a generator backed by a chunked DB cursor, e.g.:
function fetchOrdersGenerator(PDO $pdo): Generator
{
    $stmt = $pdo->query('SELECT id, customer_name, total, placed_at FROM orders ORDER BY id');
    while ($row = $stmt->fetch(PDO::FETCH_OBJ)) {
        yield $row;
    }
}
<?php
declare(strict_types=1);

function readCsvRowsSafely(string $path, int $expectedColumns, string $sourceEncoding = 'UTF-8'): Generator
{
    $handle = fopen($path, 'r');
    if ($handle === false) {
        throw new RuntimeException("Cannot open $path");
    }

    $bom = fread($handle, 3);
    if ($bom !== "\xEF\xBB\xBF") {
        rewind($handle);
    }

    $lineNumber = 0;
    $skipped = 0;
    while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) {
        $lineNumber++;
        if ($row === [null]) {
            continue; // a genuinely blank line
        }
        if (count($row) !== $expectedColumns) {
            $skipped++;
            fwrite(STDERR, "Row $lineNumber: expected $expectedColumns fields, got " . count($row) . " -- skipped\n");
            continue;
        }
        if ($sourceEncoding !== 'UTF-8') {
            $row = array_map(
                fn (?string $field) => $field !== null ? mb_convert_encoding($field, 'UTF-8', $sourceEncoding) : null,
                $row,
            );
        }
        yield $lineNumber => $row;
    }

    fclose($handle);
    if ($skipped > 0) {
        fwrite(STDERR, "Import finished: $skipped malformed row(s) s
…

Streaming a large CSV without loading it into memory

A correctly streamed read loop never holds more than one row (plus small fixed bookkeeping) in memory, regardless of total file size.

products.csv (2 GB, 20M rows)
  +--------------------------------------------------+
  | header row                                        |
  | row 1  | row 2  | row 3  | ... | row 20,000,000    |
  +--------------------------------------------------+
         |
         v  fopen() + one fgetcsv() call per iteration
  +------------------+
  | current row only |  <-- memory use stays here, constant
  +------------------+
         |
         v  process immediately (insert, transform, sum)
     [ discarded before next row is read ]

Important Rules

Always parse CSV with fgetcsv(), never with explode() on a raw fgets() line -- only fgetcsv() correctly handles quoted delimiters, escaped quotes, and embedded newlines inside a field.

Always write CSV with fputcsv(), never with implode() -- only fputcsv() correctly quotes a field that itself contains the delimiter, enclosure character, or a newline.

Never load an entire CSV file into memory (file()/file_get_contents()) before processing it -- read and process one row per fgetcsv() call so memory use stays bounded regardless of file size.

Strip a leading UTF-8 BOM explicitly before the first fgetcsv() call, or the first field's value will silently carry three extra invisible bytes.

Validate count($row) against the expected column count on every row, and skip/log a mismatched row rather than trusting positional indices blindly or crashing the whole import.

Where this shows up

CSV import/export is one of the most common integration points in real applications -- bulk product catalog uploads, bank/payment reconciliation exports, customer data migrations between systems, and 'export my data' features are almost always CSV rather than JSON or XML, precisely because every spreadsheet tool and legacy system speaks it natively. The BOM-stripping and encoding-conversion issues in particular show up constantly with files produced by Excel on Windows, which is why production import code treats 'this file is valid UTF-8 with no BOM and a consistent delimiter' as an assumption to verify, not one to make silently.

$lines = file('big-export.csv'); foreach ($lines as $line) { $fields = explode(',', $line); // ... }

$handle = fopen('big-export.csv', 'r'); while (($fields = fgetcsv($handle)) !== false) { // ... } fclose($handle);

file() loads every line of the entire file into an array in memory before processing even begins -- for a multi-gigabyte export this can exhaust available memory outright, and explode(',', $line) additionally corrupts any field that legitimately contains a comma, quote, or embedded newline, which fgetcsv() handles correctly.

[multiple-choice] Why must fgetcsv() be used to parse a CSV file instead of reading lines with fgets() and splitting each one on commas with explode()? A. fgets() is slower than fgetcsv() for every file size B. explode() cannot process arrays at all C. A field enclosed in quotes can legitimately contain the delimiter itself or an embedded newline that spans multiple physical lines -- only fgetcsv() correctly recognizes the enclosing quotes and continues reading until the quoted field actually closes, rather than treating an embedded newline as the end of the row D. fgets() automatically converts all values to integers

C. A field enclosed in quotes can legitimately contain the delimiter itself or an embedded newline that spans multiple physical lines -- only fgetcsv() correctly recognizes the enclosing quotes and continues reading until the quoted field actually closes, rather than treating an embedded newline as the end of the row — CSV's quoting rules allow a single field to contain the delimiter character or even a real newline as long as the field is wrapped in quotes. A line-based split with fgets()+explode() has no concept of quoting and will incorrectly treat an embedded newline as a row boundary or an embedded comma as a field boundary, corrupting the data; fgetcsv() implements the quoting rules correctly.

[multiple-choice] What is the correct way to process a CSV file too large to fit comfortably in memory? A. Call file_get_contents() and process the resulting string in 64KB chunks B. Open the file with fopen(), call fgetcsv() once per row inside a loop, and process/discard each row immediately -- keeping memory use proportional to one row rather than the whole file C. Increase PHP's memory_limit until the file fits, regardless of its size D. Convert the CSV to XML first, since XML has no memory limitations

B. Open the file with fopen(), call fgetcsv() once per row inside a loop, and process/discard each row immediately -- keeping memory use proportional to one row rather than the whole file — Streaming with fopen() + a per-row fgetcsv() loop keeps memory use bounded to roughly one row at a time no matter how large the file is, which is the only approach that scales to arbitrarily large files; loading the whole file (or even the whole string) into memory first defeats that entirely, and raising memory_limit just delays the same failure at a larger file size.

[multiple-choice] A CSV file's first header value reads as "\xEF\xBB\xBFid" instead of "id" when inspected byte-by-byte, even though it displays as 'id' in a text editor. What is happening, and what should the code do? A. The file is corrupted beyond use and must be regenerated B. This is a UTF-8 byte-order mark (BOM) prepended to the file (common from Excel on Windows) -- the code should detect and strip those three leading bytes right after opening the file, before the first fgetcsv() call C. fgetcsv() is buggy and should be replaced with a manual parser D. The delimiter character was set incorrectly to a semicolon

B. This is a UTF-8 byte-order mark (BOM) prepended to the file (common from Excel on Windows) -- the code should detect and strip those three leading bytes right after opening the file, before the first fgetcsv() call — A UTF-8 BOM is three specific invisible bytes (EF BB BF) some tools prepend to signal encoding; it displays as nothing in a text editor but is a real prefix on the first field's raw bytes, which breaks exact string comparisons like $row[0] === 'id'. The fix is to check for and strip those three bytes immediately after opening the file, before parsing the first row.

Write a function sumColumn(string $path, string $columnName): float that streams a CSV file (first row is a header) using fgetcsv() and returns the sum of a named numeric column, without ever loading the whole file into memory. Skip (don't crash on) any row where the target column's value isn't numeric.

function sumColumn(string $path, string $columnName): float { $handle = fopen($path, 'r'); if ($handle === false) { throw new RuntimeException("Cannot open $path"); } $header = fgetcsv($handle); $index = array_search($columnName, $header, true); if ($index === false) { fclose($handle); throw new InvalidArgumentException("Column $columnName not found"); } $total = 0.0; while (($row = fgetcsv($handle)) !== false) { if (isset($row[$index]) && is_numeric($row[$index])) { $total += (float) $row[$index]; } } fclose($handle); return $total; }

Read the header row first to find the column's index with array_search(). Loop fgetcsv() calls, use is_numeric() to check the target field before adding it to a running total, and return the total after the loop ends and the handle is closed.