CSV in depth: quoting, delimiters and Excel traps
RFC 4180 in practice — quoted fields, doubled quotes, embedded newlines — plus the BOM, delimiter and locale quirks that break real spreadsheet exports.
What the format actually allows
CSV looks trivial until a value contains a comma. The informal standard is RFC 4180, and almost every bug comes from ignoring one of its four rules.
- Records are separated by CRLF (
\r\n), though most parsers also accept a bare LF. - Fields are separated by a comma, but any field may be enclosed in double quotes.
- Inside a quoted field, a literal double quote is written twice:
"". - A quoted field may contain commas, newlines and quotes — so a record is not the same thing as a line.
name,note,price
Widget,"ships in 2-3 days",9.99
Gadget,"he said ""hello""",19.99
Cable,"multi-line
description",4.50That third record spans two physical lines. Any reader that splits on \n first and parses second will corrupt the file.
The Excel traps
| Symptom | Cause | Fix |
|---|---|---|
First header cell shows as \ufeffid | Excel writes a UTF-8 BOM | Strip the BOM on read, or open with encoding='utf-8-sig' |
Leading zeros lost (007 becomes 7) | Spreadsheet infers a number | Quote the field in the file; treat all input as text on import |
| Semicolons instead of commas | Locale list separator (de, fr, es) | Sniff the delimiter, or force sep=; on the first line |
Long numbers become 1.23E+15 | Double precision inference | Store IDs as strings, never as CSV numbers |
| Dates reorder (03/04 ambiguity) | Locale date parsing | Use ISO 8601 YYYY-MM-DD in exports |
A leading sep=, line is a Microsoft extension understood by Excel but rejected by strict parsers, so it is a one-way door: use it only for human-facing exports.
Parsing and writing safely
import csv
with open("data.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f, dialect="excel")
rows = [r for r in reader]
# writing: the writer quotes only when it must
with open("out.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f, quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
w.writerow(["id", "note"])
w.writerow(["1", 'he said "hi", loudly'])Two arguments are easy to forget: newline="" stops Python from translating line endings and producing \r\r\n, and encoding="utf-8-sig" transparently eats the BOM that Excel writes.
FAQ
Should I quote every field?
How do I detect the delimiter?
csv.Sniffer or a small heuristic both work; never assume a comma.Related
Choosing a format: size, speed, readability and tooling Converting between formats with jq, yq and csvkit
Last refreshed 2026-09-18.