Every CSV parser starts the same way: split each line on commas, split the header the same way, zip them together into a record. It works perfectly on the sample file. It works in the demo. Then a real export from a client's accounting system shows up with a comma inside a quoted address field, a stray byte-order mark at the top, and a currency column that's sometimes "1,204.50" and sometimes "1204.50" and sometimes just blank, and the naive parser either crashes or, worse, silently produces wrong data that nobody notices until a report is off by a few thousand dollars.
CSV looks like the simplest data format in common use, which is exactly why so many hand-rolled parsers get it wrong. There's no single canonical spec that every tool agrees on, quoting rules vary by the program that generated the file, and the format has no native concept of types at all. Below are the patterns and snippets we reach for when a CSV import needs to survive contact with real-world files instead of just the fixtures in a test suite.
Photo by Patrick Martin on Unsplash
Why splitting on commas breaks first
The naive approach, line.split(','), fails the moment a field itself contains a comma, which is common in addresses, product descriptions, and free-text notes. The CSV format's closest thing to a spec handles this by allowing fields to be wrapped in double quotes, with commas and newlines inside the quotes treated as literal characters rather than delimiters. A quoted field can even contain the quote character itself, escaped by doubling it ("").
None of that logic is optional if you're parsing real files, and reimplementing it correctly is more work than it looks. This is the single best argument for never hand-rolling a CSV parser: reach for your language's standard library or a well-tested package instead.
import csv
with open("export.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
process(row)
Python's built-in csv module handles quoted fields, embedded delimiters, and embedded newlines correctly out of the box. The newline="" argument matters more than it looks like it should. Without it, the module's own universal-newline translation can interact badly with quoted newlines inside fields on some platforms, producing subtly wrong row boundaries that only show up on specific files.
The byte-order mark nobody warns you about
Files exported from Excel and many Windows tools are frequently saved as UTF-8 with a byte-order mark (BOM), three invisible bytes at the start of the file that some tools use to signal encoding. If your parser doesn't strip it, the BOM ends up glued to the first character of your first header name, so a column you expect to be named id actually comes through as id, and every lookup against row["id"] fails silently while the data is right there in the row.
with open("export.csv", newline="", encoding="utf-8-sig") as f:
...
The utf-8-sig encoding in the snippet above tells Python to strip a leading BOM if one is present and do nothing if it isn't, which makes it safe to use unconditionally rather than trying to detect the BOM yourself. In JavaScript, the equivalent fix when reading a file with TextDecoder is passing { ignoreBOM: false } (the default), which strips it automatically; the MDN TextDecoder reference covers the encoding detection options in more depth if your files come from a wider variety of sources than a single known export tool.
Deciding what to do with malformed rows
Real CSV exports contain rows with the wrong number of columns more often than most teams expect: a trailing comma got added by a spreadsheet macro, a row got truncated by a network hiccup during export, or a field that should have been quoted wasn't, spilling a comma into what looks like an extra column. The question that matters isn't how to detect this, most parsers will raise or produce a short row automatically, it's what your import should do when it happens.
import csv
expected_columns = 12
skipped = []
with open("export.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f)
header = next(reader)
for line_number, row in enumerate(reader, start=2):
if len(row) != expected_columns:
skipped.append((line_number, row))
continue
process(dict(zip(header, row)))
if skipped:
log_skipped_rows(skipped)
The pattern worth internalizing here: don't let one malformed row silently kill the whole import, and don't silently drop it either. Collect skipped rows with their line numbers, log or surface them somewhere a human will actually look, and let the rest of the file process normally. A CSV import that fails hard on row 4,000 out of 50,000 wastes far more time than one that finishes and reports "these twelve rows need a human."

Photo by Lukas Blazek on Pexels
Type coercion is where the real bugs hide
Every value in a raw CSV is a string, full stop. Whether "04/12/2026" means April 12th or December 4th, whether "N/A", empty string, and a literal null text all mean the same missing value, and whether "1,204.50" should parse as one thousand two hundred four dollars and fifty cents, none of that is encoded in the format. It's encoded in whatever assumptions the exporting system made, and those assumptions are rarely documented anywhere you can check them in advance.
def coerce_amount(raw: str) -> float | None:
if raw is None:
return None
cleaned = raw.strip().replace(",", "")
if cleaned in ("", "N/A", "NULL", "-"):
return None
try:
return float(cleaned)
except ValueError:
return None
Writing an explicit coercion function per column, rather than trusting a generic "infer the type" library function, is worth the extra code in anything that touches money, dates, or IDs. Generic type inference is a reasonable starting point for exploratory analysis; it's a liability in a pipeline that's expected to run unattended and produce the same result every time on files that vary slightly from run to run.
For dates specifically, resist the temptation to guess the format from the data. If the source system's export format is known and stable, parse against that exact format string explicitly rather than a permissive "guess it" parser, since a permissive parser will happily accept 04/12/2026 under whichever interpretation it defaults to, and that default silently changes the meaning of every date in the file if the source system ever switches locales.
"The bug that actually costs a client money is never the parser crashing on a bad file. It's the parser succeeding on a bad file and producing a number that looks plausible enough that nobody double-checks it." - Dennis Traina, founder of 137Foundry
Streaming large files instead of loading them whole
A CSV import that works fine on a 5,000-row test file can fall over on a client's real 2-million-row export if it loads the whole thing into memory with something like list(csv.reader(f)) or a full DataFrame read before processing starts. The fix is streaming: process one row at a time and never materialize the full file as a Python list or JavaScript array.
import csv
def stream_process(path, batch_size=1000):
batch = []
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
batch.append(row)
if len(batch) >= batch_size:
yield batch
batch = []
if batch:
yield batch
Batching rows into groups of a few hundred or a thousand before handing them off to a database insert or an API call is usually the right middle ground: small enough that memory stays flat regardless of file size, large enough that you're not paying per-row overhead on every single insert. Libraries like pandas support chunked reading through the chunksize parameter for the same reason, if you're already using it for downstream analysis rather than writing the loop by hand.

Photo by Sergei Starostin on Pexels
Testing a CSV parser against the files that actually break it
A CSV parser's test suite should look nothing like its happy-path fixtures. The cases worth writing explicit tests for: a field containing a comma inside quotes, a field containing an escaped quote character, a row with a trailing comma producing an extra empty column, a file with a BOM and one without, a numeric column containing a thousands separator, and a date column containing at least one genuinely ambiguous value. Each of these has caused a real production bug somewhere; none of them show up if your only fixture is a clean five-row sample file you wrote by hand.
It's also worth keeping a small library of anonymized real-world CSVs that have broken your parser in the past, stripped of any sensitive data, as a permanent regression suite. The bugs that got through once tend to be exactly the shape of bug that gets through again on a different file from a different source system, since the underlying cause, an undocumented assumption about the export format, tends to recur across systems built by different teams for different reasons.
Photo by Mimicry Hu on Unsplash
Bringing it together
None of the individual patterns here are exotic. Use a real CSV parser instead of splitting on commas, strip the BOM unconditionally rather than trying to detect it, decide explicitly what happens to malformed rows instead of letting the exception bubble up or the bad row silently vanish, write per-column coercion functions instead of trusting generic type inference, and stream large files instead of loading them whole. Individually each one prevents a specific, boring class of bug. Together they're the difference between a CSV import that works in the demo and one that survives whatever a client's actual export tool decides to hand you six months from now.
If your team is dealing with recurring CSV or file-based data ingestion as part of a larger integration effort, 137Foundry's data integration service builds and hardens exactly this kind of pipeline work, and our broader web development service covers the application layer these imports usually feed into. You can see the full range of what we work on or read more about how we approach engineering work.
For further reading, the Wikipedia entry on comma-separated values is a good primer on why the format never got a single strict standard, and the Wikipedia entry on the byte-order mark explains the encoding history behind the BOM issue in more detail than most parsing guides bother to cover. Start at 137foundry.com for more of our writing on production data and application engineering.