Programming Tutorials

Parsing a Messy Real-World CSV File Without a Library

A step-by-step walkthrough of writing a CSV parser by hand, and the four real edge cases — quoted commas, embedded newlines, stray quotes, and inconsistent encoding — that break a naive split(",") in the first ten minutes.

By Aissam Ait Ahmed Programming Tutorials 0 comments

"Just split on commas" is the first instinct everyone has for reading a CSV file, and it survives almost exactly until the first row contains a comma inside a quoted field, which in real-world exported data is not a rare event — it's closer to guaranteed the moment addresses, product descriptions, or free-text notes are involved. This post builds a hand-written CSV parser one edge case at a time, starting from the naive version, breaking it deliberately, and fixing it, so the final result isn't a black box you copied from somewhere but a parser whose every rule you can explain.

The naive version, and the row that breaks it immediately

function naiveParseCsvLine(string $line): array
{
    return explode(',', $line);
}

$row = naiveParseCsvLine('Acme Inc,"123 Main St, Suite 4",contact@acme.com');
print_r($row);

Running that gives four fields instead of the intended three — Acme Inc, "123 Main St, Suite 4", and contact@acme.com — because explode(',') has no concept of quoting. It splits on every comma, including the one deliberately wrapped in quotes to keep the address together as a single field. This is the single most common real-world CSV bug, and it's the reason every serious CSV format spec includes quoting rules in the first place.

Step 1: respect quotes when deciding where fields end

The fix is to track whether we're currently "inside" a quoted field while scanning character by character, and only treat a comma as a field separator when we're not inside quotes:

function parseCsvLine(string $line): array
{
    $fields = [];
    $current = '';
    $insideQuotes = false;
    $length = strlen($line);

    for ($i = 0; $i < $length; $i++) {
        $char = $line[$i];

        if ($char === '"') {
            $insideQuotes = ! $insideQuotes;
            continue;
        }

        if ($char === ',' && ! $insideQuotes) {
            $fields[] = $current;
            $current = '';
            continue;
        }

        $current .= $char;
    }

    $fields[] = $current;

    return $fields;
}

Testing this against the same problem row now correctly returns three fields, with the address's internal comma preserved as part of a single field rather than splitting it. That's a real improvement, but it's not done yet — this version has three more bugs waiting in slightly less common but still very real rows.

Step 2: escaped quotes inside a quoted field

CSV's quoting convention represents a literal quote character inside a quoted field as two consecutive quotes: "She said ""hello"" to me" should parse to the single value She said "hello" to me. The version above toggles insideQuotes on every quote character, which means two consecutive quotes toggle it off and immediately back on — treating the pair as an empty quoted segment rather than an escaped literal quote, and corrupting the field.

function parseCsvLine(string $line): array
{
    $fields = [];
    $current = '';
    $insideQuotes = false;
    $length = strlen($line);

    for ($i = 0; $i < $length; $i++) {
        $char = $line[$i];

        if ($char === '"') {
            // Two consecutive quotes inside a quoted field = one literal quote
            if ($insideQuotes && ($line[$i + 1] ?? null) === '"') {
                $current .= '"';
                $i++; // skip the second quote in the pair
                continue;
            }

            $insideQuotes = ! $insideQuotes;
            continue;
        }

        if ($char === ',' && ! $insideQuotes) {
            $fields[] = $current;
            $current = '';
            continue;
        }

        $current .= $char;
    }

    $fields[] = $current;

    return $fields;
}

The lookahead ($line[$i + 1] ?? null) is what distinguishes "the quote that ends this field" from "an escaped literal quote inside this field," and it's the detail that's easy to miss if you're writing this from memory rather than checking against the actual format rules.

Step 3: newlines inside a quoted field mean the row isn't done yet

This is the edge case that breaks parsers built around "read one line, parse it" as an assumption, because a quoted field is explicitly allowed to contain a literal newline — a multi-paragraph notes field being the most common real-world source. If you read input line by line with fgets() and parse each line independently, a quoted field spanning two physical lines gets split into two separate, broken rows.

function readCsvRows($handle): array
{
    $rows = [];
    $buffer = '';
    $insideQuotes = false;

    while (($chunk = fgets($handle)) !== false) {
        $buffer .= $chunk;

        // Count quotes in the buffer so far to know if we're
        // still inside an unterminated quoted field
        $insideQuotes = (substr_count($buffer, '"') % 2) !== 0;

        if (! $insideQuotes) {
            $rows[] = parseCsvLine(rtrim($buffer, "\r\n"));
            $buffer = '';
        }
        // If still inside quotes, keep reading more lines into
        // the same buffer before attempting to parse a full row
    }

    return $rows;
}

The odd-quote-count check is a genuinely simple but effective signal: if the accumulated buffer has an odd number of quote characters, we're mid-way through a quoted field that hasn't closed yet, and the "row" isn't actually complete regardless of how many newline characters have gone by. It's a heuristic rather than a full state machine, and it works reliably here specifically because quotes in a well-formed CSV file always appear in balanced pairs around and inside quoted fields.

Step 4: inconsistent encoding, the bug that shows up last and confuses everyone

The final failure mode isn't a parsing-logic bug at all — it's an encoding mismatch. A CSV exported from Excel on Windows is frequently encoded in Windows-1252 or comes with a UTF-8 byte-order-mark (BOM) prefix, while your PHP code almost certainly assumes UTF-8 without a BOM. The visible symptom is bizarre: a field that looks fine when you var_dump it, but the very first field of the very first row has a few invisible extra bytes at the start, which breaks an exact string comparison against that field's expected value (like a header name) in a way that's maddening to debug because the string prints as if it matches.

function stripUtf8Bom(string $line): string
{
    $bom = "\xEF\xBB\xBF";

    return str_starts_with($line, $bom) ? substr($line, strlen($bom)) : $line;
}

// Apply once, to the very first line read from the file
$firstLine = stripUtf8Bom(fgets($handle));

This bug is worth naming explicitly because of how it presents: everything works in testing with a file you created yourself, and then breaks specifically on files exported from a client's or a partner's spreadsheet software, because that's the source most likely to prepend a BOM or use a different encoding than what you assumed. Testing against a file exported from real spreadsheet software early on, not just files your own code generated for a test fixture, is the single easiest way to surface this category of bug before it ever reaches production.

Sanity-checking parsed output before trusting it

Once a row parses without an obvious error, it's tempting to assume it parsed correctly — but a subtly wrong quote-matching rule can silently merge two fields or truncate one without throwing anything. A cheap sanity check that's caught real bugs for me: compare the field count of every parsed row against the header row's field count, and flag any mismatch instead of silently accepting it.

function validateRowShape(array $header, array $row, int $lineNumber): void
{
    if (count($row) !== count($header)) {
        throw new \RuntimeException(
            "Row {$lineNumber} has " . count($row) . " fields, expected " . count($header)
        );
    }
}

On a real import job, this check caught a file where a stray unescaped quote in a product description field silently merged two rows into one, which the parser above happily accepted as a single row with an unusually long description — nothing about it looked like an error until the field count check flagged that row as having one field too many. For a long free-text field specifically, running the parsed value through something like a word counter during a manual spot-check is a quick way to notice when a "description" field is suspiciously long compared to its neighbors — often a sign that a parsing edge case merged content that should have stayed in two separate rows. Catching that kind of thing with an automated shape check, rather than relying on a human noticing an oddly long cell while scrolling through a spreadsheet, is what actually makes an import pipeline trustworthy enough to run unattended on files nobody's manually eyeballed first.

What to actually use once you understand this

PHP's built-in str_getcsv() and fgetcsv() already implement quote handling, escaped-quote handling, and multi-line quoted fields correctly, and for the overwhelming majority of real projects, calling those instead of maintaining a hand-rolled parser is the right call — this walkthrough exists so that when fgetcsv() does something surprising with a specific file, you understand which of these four edge cases is actually happening instead of guessing at it.

  • Commas inside quoted fields need dedicated quote-tracking, not a plain split.
  • Escaped quotes ("") inside quoted fields need lookahead to distinguish from a field-ending quote.
  • Newlines inside quoted fields mean a "row" can legitimately span multiple physical lines.
  • BOM and encoding mismatches are invisible in casual debugging and only show up on files from an external source.
  • Field-count mismatches against the header row are a cheap, reliable smoke test that a subtler quoting bug didn't silently merge or split a row.

None of these four are exotic — they're the specific, predictable shapes real CSV files break in, which is exactly why a mature library implementation is worth trusting once you understand what it's actually protecting you from. The value in building the naive version first and breaking it on purpose isn't the parser you end up with; it's no longer being surprised the first time a client's export file does something your first instinct wouldn't have accounted for, and knowing immediately which of the four categories a new failure belongs to instead of starting the investigation from zero.

If you're debugging a parsing failure and the field values you're printing look correct but comparisons against them keep failing, that's usually the encoding issue from step four rather than a logic bug — a debugging instinct that pairs well with the general approach in decoding "cannot read properties of undefined", where the fix also turned out to be about what the data actually contained rather than what the code assumed about it.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools