Data Formats (JSON, YAML, TOML, XML, CSV) Module 9 – CSV and tabular text
CSV by the book: RFC 4180 and dialects
How RFC 4180 quotes commas, quotes and line breaks in CSV, how to set a dialect's delimiter, quote and escape, and what to do with ragged rows.
What you will learn
- Write and read CSV that follows RFC 4180, including quotes and line breaks inside fields
- Trace a CSV reader through its four states on a record with quoted fields
- Configure the delimiter, quote character, escape character and line terminator of a dialect
- Handle ragged rows and malformed quotes with a rule you state and test
Before you start
What you’ll learn · Before you start
What you will learn
- Write and read CSV that follows RFC 4180, including quotes and line breaks inside fields
- Trace a CSV reader through its four states on a record with quoted fields
- Configure the delimiter, quote character, escape character and line terminator of a dialect
- Handle ragged rows and malformed quotes with a rule you state and test
Before you start
On this page
The name Rao, Vikram holds a comma, so a program that cuts the line 7,"Rao, Vikram",Pune at every comma finds 4
fields in a record of 3, and every column after the name moves one place. RFC 4180 settles how such a value travels:
between double quotes, with every quote inside it doubled, so a reader that follows its rules gets Rao, Vikram back
in one piece.
You will write and read CSV by those rules, trace a reader through the four states that make quoting work, set the delimiter, quote, escape and line terminator of a dialect (CSV): The writing choices that a CSV reader must share with the writer: the delimiter, the quote character, how a quote inside a field is escaped, the line ending and when fields are quoted. Python names them in a csv.Dialect., and choose what an import does with the rows that break the format: ragged row: A CSV record with more or fewer fields than the header. RFC 4180 says every line should have the same number of fields; readers differ in whether they refuse such a row, pad it or keep it., stray quotes and fields that never close.
RFC 4180 has seven rules, and quoting carries the hard ones
RFC 4180 is short. Fields are separated by commas, records end with CR LF, and a field that holds a comma, a double quote or a line break is written as a quoted field: A CSV field written between double quotes, so that commas, line breaks and quotes can be part of its value. RFC 4180 writes a quote inside it twice: "" stands for one quote of data., between double quotes, with every quote inside it doubled. Its seven rules, and where the program below meets each one:
| Rule | What RFC 4180 says | Where you see it |
|---|---|---|
| 1 | Each record is on its own line, ended by CR LF | every line ends with \r\n |
| 2 | The last record may end without a line break | a reader accepts both endings |
| 3 | An optional first line names the fields, with as many fields as the records | id,name,address |
| 4 | Commas separate fields, as many on every line; spaces belong to the field; no comma at the end | Ravi keeps its space |
| 5 | Any field may be quoted; an unquoted field may not contain a quote | 7 and Pune stay bare |
| 6 | A field with a comma, a quote or a line break should be quoted | "Rao, Vikram" |
| 7 | A quote inside a quoted field is written twice | "Asha ""AJ"" Iyer" |
This program first shows what cutting at commas does to the line from the start of the lesson. Then it writes a header
and four records that need every rule with csv.writer and reads them back with csv.reader. The JavaScript tab writes
the same text with two functions of one line each:
Python · rules/round_trip.py
"""Records that need every quoting rule of RFC 4180, written by csv.writer and read back by csv.reader."""
import csv
import io
ROWS = [
["id", "name", "address"],
["7", "Rao, Vikram", "Pune"],
["8", 'Asha "AJ" Iyer', "Kochi"],
["9", "Meena", "12 FC Road\r\nPune"],
["10", " Ravi", ""],
]
def visible(text):
"""The text with every line break written out, so that CR LF shows as \\r\\n."""
return text.replace("\r", "\\r").replace("\n", "\\n\n")
line = '7,"Rao, Vikram",Pune'
print("split at commas:", line.split(","))
print("csv.reader: ", next(csv.reader([line])))
print()
out = io.StringIO()
csv.writer(out).writerows(ROWS)
text = out.getvalue()
print(visible(text), end="")
print()
back = list(csv.reader(io.StringIO(text, newline="")))
print("read back equal:", back == ROWS)
print("Meena's record:", back[3]) Output
split at commas: ['7', '"Rao', ' Vikram"', 'Pune'] csv.reader: ['7', 'Rao, Vikram', 'Pune'] id,name,address\r\n 7,"Rao, Vikram",Pune\r\n 8,"Asha ""AJ"" Iyer",Kochi\r\n 9,Meena,"12 FC Road\r\n Pune"\r\n 10, Ravi,\r\n read back equal: True Meena's record: ['9', 'Meena', '12 FC Road\r\nPune']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 round_trip.py
JavaScript · rules/round_trip.mjs
// RFC 4180 by hand: quote a field that holds a comma, a quote, CR or LF, and double the quotes inside it.
const field = (value) => (/[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
const record = (fields) => fields.map(field).join(",") + "\r\n";
const rows = [
["id", "name", "address"],
["7", "Rao, Vikram", "Pune"],
["8", 'Asha "AJ" Iyer', "Kochi"],
["9", "Meena", "12 FC Road\r\nPune"],
["10", " Ravi", ""],
];
// The text with every line break written out, so that CR LF shows as \r\n.
const visible = (text) => text.replaceAll("\r", "\\r").replaceAll("\n", "\\n\n").replace(/\n$/, "");
console.log("split at commas:", JSON.stringify('7,"Rao, Vikram",Pune'.split(",")));
console.log();
console.log(visible(rows.map(record).join(""))); Output
split at commas: ["7","\"Rao"," Vikram\"","Pune"] id,name,address\r\n 7,"Rao, Vikram",Pune\r\n 8,"Asha ""AJ"" Iyer",Kochi\r\n 9,Meena,"12 FC Road\r\n Pune"\r\n 10, Ravi,\r\n
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node round_trip.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The cut gave four pieces, two of them with a stray quote; the reader gave the three fields. The writer quoted only the
fields that needed it, which is Python’s default, QUOTE_MINIMAL. Meena’s address spans two lines of the file,
because the CR LF inside its quotes is data, and the reader gave every record back exactly: read back equal: True.
The JavaScript field() is the whole writing rule in one line: quote a value that holds ", ,, CR or LF, and double
its quotes.
Joining fields with commas
Wrong: ",".join(fields) or fields.join(","), which writes Rao, Vikram without quotes, so the next reader splits
it in two. Right: csv.writer, or a function that quotes as field() does, for every value you write.
RFC 4180 is an Informational RFC, and its status section says plainly that it sets no Internet standard. What it
does is write down the format most programs were already producing and register the media type text/csv, with two
optional parameters: charset for the encoding, and header=present or header=absent. A revision draft, known as
4180bis, expired without becoming an RFC, so RFC 4180 is still the text to cite; RFC 7111 adds addresses for parts of
a file, such as #row=4 and #cell=4,1.
Key idea
Quote a field when it holds the delimiter, a quote or a line break, double every quote inside it, and read with a real CSV reader: cutting lines at commas breaks the first quoted field it meets.
Checkpoint
Check: The quoting rules of RFC 4180
A reader needs four states to follow the quotes
A comma means one thing in 7, and another in "Rao, . A reader tells them apart by remembering where it is: at the
start of a field, inside an unquoted field, inside a quoted field, or just after a quote inside a quoted field, where
it cannot yet tell a closing quote from the first half of a doubled one. That memory makes the reader a
A program that is always in one of a fixed set of states and moves between them by rules, one input at a time: here, one character of the text.
with four states. Step through it on the record 7,"Rao, ""Vik""",Pune:
The drawing shows the whole scene; its 12 steps are listed below.
Every step as text
- An unquoted field. The reader starts at the start of a field and reads 7. It is not a quote, so the field is unquoted, and 7 is its first character.
- A comma ends it. The comma after 7 ends the field. The reader stores 7 as field 1 and goes back to the start of a field.
- A quote opens a field. The next character is a quote at the start of a field, so this field is quoted. The quote itself is not part of the value.
- Commas inside quotes. R, a, o, the comma and the space are all data now. Inside quotes the comma ends nothing: that is what the quotes are for.
- A quote, or half of one. Then a quote. Inside a quoted field the reader cannot tell yet whether it closes the field or starts a doubled quote, so it moves to a state that waits for the next character.
- A doubled quote. The next character is another quote. Two quotes in a row stand for one quote in the value, so the reader adds " and goes back inside the quoted field.
- The same once more. V, i and k are data, and the two quotes after them are again a doubled quote. The value is now Rao, "Vik", with one quote on each side of the name.
- The closing quote. A third quote in a row moves the reader to wait once more. This time the character after it decides the matter: a comma.
- Field 2 is complete. A comma after a quote closes the quoted field. Field 2 is Rao, "Vik", without its outer quotes, and the reader is at the start of field 3.
- The last field. P starts an unquoted field, u, n and e follow, and CR LF ends the record: three fields, 7, Rao, "Vik" and Pune.
- Where broken quotes show. Two states catch broken quotes. Waiting after a quote, anything but a quote, a comma or a line break is an error, as in "b"c. In an unquoted field, a quote breaks rule 5.
- A quote that never closes. If the text ends while the reader is still inside a quoted field, the field never closed. A good error names the line and column where its quote opened.
Every character either keeps the state or moves the reader along one arrow, and three moves are errors. This table is every rule the figure followed:
| In this state | It reads | And then |
|---|---|---|
| Field start | " |
the field is quoted |
| Field start or unquoted field | any character but ", a comma or a line break |
it is added to the value |
| Quoted field | any character but ", line breaks too |
it is added to the value |
| Quoted field | " |
it waits for the next character |
| Quote inside quotes | " |
one quote is added, back inside the field |
| Any state but the quoted field | a comma, or a line break | the field ends, and at a line break the record too |
| Unquoted field | " |
error: a quote inside an unquoted field |
| Quote inside quotes | anything else | error: text after a closing quote |
The third error has no row: the text ends while the reader is still inside a quoted field. By then the reader has gone past many line breaks, all of them data, so a useful message names the line and column where that field’s quote opened, not the end of the text. The exercise at the end of this lesson builds exactly this reader.
Checkpoint
Check: The four states of a CSV reader
A dialect names every choice the RFC leaves open
RFC 4180 fixes the comma, the double quote, doubled quotes and CR LF, and real files differ on all four: some separate fields with semicolons, tabs or pipes, some escape a quote with a backslash instead of doubling it, and many end their lines with LF alone. A dialect names those choices, and a reader must use the dialect of the writer. This program writes the same rows in six dialects and reads each back with the dialect that wrote it, then reads two of them with the wrong one:
"""The same rows in six dialects, each read back with the dialect that wrote it, then two mismatches."""
import csv
import io
ROWS = [
["id", "name", "amount"],
["7", "Rao, Vikram", "1250.50"],
["8", "Asha|AJ", ""],
["9", 'Ravi "R" Nair', None],
]
DIALECTS = {
"comma (excel)": {},
"semicolon": {"delimiter": ";"},
"tab (excel-tab)": {"dialect": "excel-tab"},
"pipe": {"delimiter": "|"},
"unix": {"dialect": "unix"},
"backslash escapes": {"doublequote": False, "escapechar": "\\"},
}
def visible(text):
"""Tabs and line breaks written out: \\t, \\r\\n and \\n."""
return text.replace("\t", "\\t").replace("\r", "\\r").replace("\n", "\\n\n")
def write(rows, **dialect):
out = io.StringIO()
csv.writer(out, **dialect).writerows(rows)
return out.getvalue()
def read(text, **dialect):
return list(csv.reader(io.StringIO(text, newline=""), **dialect))
expected = [["" if v is None else v for v in row] for row in ROWS]
for name, dialect in DIALECTS.items():
text = write(ROWS, **dialect)
print(f"{name}:")
print(visible(text), end="")
print(" read back equal:", read(text, **dialect) == expected)
print("QUOTE_NOTNULL keeps None and '' apart:")
text = write(ROWS, quoting=csv.QUOTE_NOTNULL)
print(visible(text), end="")
print(" read back:", read(text, quoting=csv.QUOTE_NOTNULL)[2:])
print("semicolon file read as comma:", read(write(ROWS, delimiter=";"))[1])
print("escaped file read as excel: ", read(write(ROWS, **DIALECTS["backslash escapes"]))[3]) Output
comma (excel): id,name,amount\r\n 7,"Rao, Vikram",1250.50\r\n 8,Asha|AJ,\r\n 9,"Ravi ""R"" Nair",\r\n read back equal: True semicolon: id;name;amount\r\n 7;Rao, Vikram;1250.50\r\n 8;Asha|AJ;\r\n 9;"Ravi ""R"" Nair";\r\n read back equal: True tab (excel-tab): id\tname\tamount\r\n 7\tRao, Vikram\t1250.50\r\n 8\tAsha|AJ\t\r\n 9\t"Ravi ""R"" Nair"\t\r\n read back equal: True pipe: id|name|amount\r\n 7|Rao, Vikram|1250.50\r\n 8|"Asha|AJ"|\r\n 9|"Ravi ""R"" Nair"|\r\n read back equal: True unix: "id","name","amount"\n "7","Rao, Vikram","1250.50"\n "8","Asha|AJ",""\n "9","Ravi ""R"" Nair",""\n read back equal: True backslash escapes: id,name,amount\r\n 7,"Rao, Vikram",1250.50\r\n 8,Asha|AJ,\r\n 9,Ravi \"R\" Nair,\r\n read back equal: True QUOTE_NOTNULL keeps None and '' apart: "id","name","amount"\r\n "7","Rao, Vikram","1250.50"\r\n "8","Asha|AJ",""\r\n "9","Ravi ""R"" Nair",\r\n read back: [['8', 'Asha|AJ', ''], ['9', 'Ravi "R" Nair', None]] semicolon file read as comma: ['7;Rao', ' Vikram;1250.50'] escaped file read as excel: ['9', 'Ravi \\"R\\" Nair', '']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dialects.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every dialect read back equal, and the quoting followed the delimiter: Rao, Vikram needed quotes only where the comma
separates fields, and Asha|AJ only in the pipe file. The unix dialect quotes every field and ends lines with LF.
The backslash dialect wrote Ravi \"R\" Nair with no quotes around it at all: with doublequote=False and an
escapechar, Python escapes the quote instead of quoting the field. The last two lines are what a mismatch does. The
semicolon file read as commas split at the comma inside the name, and the escaped file read as excel kept its
backslashes, and neither raised an error.
The same choices have names in every library. Python takes them as arguments or as a named dialect, excel,
excel-tab or unix:
| Choice | Python csv |
JavaScript libraries |
|---|---|---|
| Field separator | delimiter="," |
delimiter in Papa Parse (guessed when left empty) and csv-parse |
| Quote character | quotechar='"' |
quoteChar in Papa Parse, quote in csv-parse |
| A quote inside quotes | doublequote=True, or escapechar |
escapeChar and escape, the quote itself by default |
| Line ending | lineterminator="\r\n", for writing |
newline and record_delimiter, guessed when left out |
| When to quote | quoting=csv.QUOTE_MINIMAL and the other QUOTE_ values |
quotes in Papa Parse’s writer |
Two of Python’s reading rules surprise people. The reader ignores lineterminator and ends a record at \r or \n
either way. And RFC 4180 has no way to write a missing value: an empty field is all it has. QUOTE_NOTNULL keeps the
two apart, as the program showed: it writes None as an empty field and an empty string as "", and reads them back
the same way.
Version note
csv.QUOTE_NOTNULL and csv.QUOTE_STRINGS are new in Python 3.12. With QUOTE_STRINGS the reader turns every
unquoted field into a float and an empty one into None, so use it only for files whose text fields are all
quoted.
Tab-separated values have a registration of their own, text/tab-separated-values, and it solves the quoting problem
by forbidding tabs inside fields: TSV has no quote character at all. Python’s excel-tab dialect is Excel’s tab
format, which quotes fields as the comma dialect does, so a file it writes is not always TSV by that registration.
Checkpoint
Check: Dialects and their options
Ragged rows and broken quotes need a rule you choose
Rule 4 asks for the same number of fields on every line, and the export below breaks it twice. Python’s DictReader
does not refuse such a file. It fills missing fields with None and puts extra ones in a list under the key None:
"""An export with a short row, a long row and a blank line: what DictReader does, then two policies."""
import csv
import io
TEXT = (
"id,name,city\r\n"
"1,Asha,Pune\r\n"
"2,Ravi\r\n"
"3,Meena,Kochi,Ernakulam\r\n"
"\r\n"
"4,Kiran,Indore\r\n"
)
print("DictReader with its defaults:")
reader = csv.DictReader(io.StringIO(TEXT, newline=""))
for row in reader:
print(f" line {reader.line_num}: {row}")
def split_rows(text):
"""The header, the records that match it and the line numbers of those that do not."""
reader = csv.reader(io.StringIO(text, newline=""))
header = next(reader)
good, bad = [], []
for fields in reader:
if not fields: # a blank line
continue
if len(fields) == len(header):
good.append(fields)
else:
bad.append((reader.line_num, len(fields)))
return header, good, bad
header, good, bad = split_rows(TEXT)
line, count = bad[0]
print(f"Reject the file: line {line} has {count} fields, the header has {len(header)}")
print(f"Quarantine: load {len(good)} rows, set aside {[f'line {n} ({c} fields)' for n, c in bad]}") Output
DictReader with its defaults:
line 2: {'id': '1', 'name': 'Asha', 'city': 'Pune'}
line 3: {'id': '2', 'name': 'Ravi', 'city': None}
line 4: {'id': '3', 'name': 'Meena', 'city': 'Kochi', None: ['Ernakulam']}
line 6: {'id': '4', 'name': 'Kiran', 'city': 'Indore'}
Reject the file: line 3 has 2 fields, the header has 3
Quarantine: load 2 rows, set aside ['line 3 (2 fields)', 'line 4 (4 fields)']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ragged.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Ravi’s city became None, Meena’s extra field went under the key None, the blank line vanished, and the loop carried
on. A ragged row becomes wrong data unless the import counts fields against the header. The program’s split_rows()
does that count, then shows two policies: reject the whole file at line 3, or load the 2 rows that fit and set lines 3
and 4 aside.
Repair the short row
Press Edit, change Ravi’s line in TEXT to "2,Ravi,Pune\r\n" and run it again. Line 3 now fits, the rejection
names line 4 instead, and the quarantine loads 3 rows and sets aside only line 4.
The right policy depends on what the data is for, so decide it for an import of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Policy | Nothing lost silently | The load goes on | The sender hears of it |
|---|---|---|---|
| Reject the file What it does: stops at the first bad row and names its line | Good nothing loads wrong: the import stops at line 3 | Poor nothing loads until a fixed file arrives | Good line 3 has 2 fields: the sender knows what to fix |
| Quarantine the rows What it does: loads the good rows, keeps the bad ones aside | Good lines 3 and 4 are kept aside, with their field counts | Good 2 of the 4 rows load at once | Mixed someone has to read the quarantine and ask for a resend |
| Pad or cut by contract What it does: fills missing trailing fields, as agreed | Mixed safe only for fields the agreement says may be left out | Good every row loads | Poor a broken export passes unnoticed |
| Accept as read What it does: DictReader defaults: None, and extras under None | Poor Ravi's city becomes None and Ernakulam sits under the key None | Good every row loads | Poor nobody hears that the export changed |
How the answers decide
- “Must the rows load together or not at all, as in a ledger or a payroll file?” Yes, all or nothing: rules out Quarantine the rows, Pad or cut by contract and Accept as read. A ledger loaded in part has totals that look right and are wrong; only the whole file, checked, may load.
- “Must the rows load together or not at all, as in a ledger or a payroll file?” No, each row stands alone: rules out Accept as read. Rows that do not match the header load with wrong values and no error, as Ravi's missing city did.
- “Can the sender send a fixed file within the time you have?” No, or not quickly: adds a caveat to Reject the file. Stopping the load waits for a file that will not come soon; quarantine lets the good rows through and keeps the evidence.
- “Does your agreement with the sender say which trailing fields may be left out?” No: rules out Pad or cut by contract. Without a written rule, padding guesses what the missing fields were.
- “Does your agreement with the sender say which trailing fields may be left out?” Yes, in writing: adds a caveat to Pad or cut by contract. Pad only the fields the agreement names, and count the rows you padded, so a broken export still shows up.
Quotes break in more ways. This program gives csv.reader four broken quotes, once with its defaults and once with
strict=True:
"""Four broken quotes, read by csv.reader with its defaults and with strict=True, then the field size limit."""
import csv
import io
CASES = {
"text after a closing quote": 'a,"b"c,d\r\n',
"a space before an opening quote": 'a, "b,c"\r\n',
"a quote inside an unquoted field": 'a,5" disk,d\r\n',
"a quote that never closes": 'a,"b\r\nc,d\r\ne,f\r\n',
}
for name, text in CASES.items():
print(name)
for strict in (False, True):
reader = csv.reader(io.StringIO(text, newline=""), strict=strict)
try:
print(f" strict={strict}: {list(reader)}")
except csv.Error as e:
print(f" strict={strict}: csv.Error at line {reader.line_num}: {e}")
print("skipinitialspace=True:", next(csv.reader(['a, "b,c"'], skipinitialspace=True)))
print("field_size_limit():", csv.field_size_limit())
reader = csv.reader(io.StringIO('id,note\r\n1,"' + "x" * 200_000 + '"\r\n', newline=""))
try:
for row in reader:
pass
except csv.Error as e:
print(f"csv.Error at line {reader.line_num}: {e}") Output
text after a closing quote strict=False: [['a', 'bc', 'd']] strict=True: csv.Error at line 1: ',' expected after '"' a space before an opening quote strict=False: [['a', ' "b', 'c"']] strict=True: [['a', ' "b', 'c"']] a quote inside an unquoted field strict=False: [['a', '5" disk', 'd']] strict=True: [['a', '5" disk', 'd']] a quote that never closes strict=False: [['a', 'b\r\nc,d\r\ne,f\r\n']] strict=True: csv.Error at line 3: unexpected end of data skipinitialspace=True: ['a', 'b,c'] field_size_limit(): 131072 csv.Error at line 2: field larger than field limit (131072)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 quotes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
By default the reader repairs what it can without a word: "b"c became bc, and a quote that never closed swallowed
the rest of the text, so 3 records came back as one. With strict=True both raise csv.Error, but for the unclosed
quote reader.line_num says line 3, where the text ended, not line 1, where the quote opened. Strict mode still lets
two breaks of RFC 4180 through: a quote inside an unquoted field, 5" disk, and a space before an opening quote, which
turns "b,c" into two broken fields unless you pass skipinitialspace=True.
The last lines show a guard the module gives you: a field longer than field_size_limit(), 131,072 characters by
default, raises field larger than field limit (131072). In a file of short notes, that error is usually an unclosed
quote that has started to swallow the rows after it.
Raising the field limit to silence the error
Wrong: csv.field_size_limit(sys.maxsize) as soon as the error appears, which lets an unclosed quote swallow the
rest of the file without a word. Right: find the line where the long field starts, and raise the limit only for a
column that really holds long text, to a size you have measured.
Whichever policy you choose, write it down beside the import and test it with a file that breaks each rule once: a short row, a long row, text after a closing quote and a quote that never closes.
Key idea
Decide what a ragged row or a broken quote does to your import before the first one arrives: count fields against
the header, read with strict=True, and report the line instead of repairing in silence.
Checkpoint
Check: Ragged rows and broken quotes
Practise: a reader that says where a quote went wrong
The exercise builds the four-state reader of this lesson, and makes it stricter than Python’s: it refuses a quote inside an unquoted field, and it reports each error with its line and column, for an unclosed quote the place where the quote opened:
Exercise · Medium · Python
A CSV reader that says where a quote went wrong
Write read_csv(text) in reader.py. It returns the records of text as lists of strings, by the rules of RFC 4180, and raises CsvError (already defined, a kind of ValueError) when the text breaks them:
- Fields are separated by commas. A record ends at CR LF, or at LF alone, which many files use; the last record may end without a line break. An empty
texthas no records, and a blank line is a record of one empty field. - A field that starts with
"is quoted: commas, line breaks and doubled quotes ("", which stand for one") are part of its value, and the surrounding quotes are not. - Line and column numbers start at 1, and a line break inside a quoted field starts a new line.
The three errors, each with the line and column of the character that breaks the rule:
line 2, column 8: a closing quote must be followed by a comma or a line break, for7,"Rao"x,Puneon line 2.line 1, column 4: a quote inside an unquoted field, fora,5" disk.line 2, column 3: the quoted field that starts here never closes, giving the position of the opening quote of a quoted field that is still open when the text ends.
Starter code · reader.py
class CsvError(ValueError):
"""A break of the rules of RFC 4180, with its line and column."""
def read_csv(text):
"""The records of `text` as lists of strings, by the rules of RFC 4180."""
return [line.split(",") for line in text.splitlines()] The sample tests · test_reader.py
from reader import CsvError, read_csv
def error_of(text):
"""The message of the CsvError read_csv(text) raises, or None."""
try:
read_csv(text)
except CsvError as e:
return str(e)
return None
def test_plain_records():
"""reads plain records ended by CR LF"""
assert read_csv("id,name\r\n7,Asha\r\n") == [["id", "name"], ["7", "Asha"]]
def test_quoted_comma_and_doubled_quotes():
"""keeps a comma inside quotes and turns "" into one quote"""
assert read_csv('7,"Rao, ""Vik""",Pune\r\n') == [["7", 'Rao, "Vik"', "Pune"]]
def test_line_break_inside_quotes():
"""keeps a CR LF inside a quoted field as data"""
assert read_csv('9,"12 FC Road\r\nPune"\r\n10,Ravi\r\n') == [["9", "12 FC Road\r\nPune"], ["10", "Ravi"]]
def test_lf_and_no_final_break():
"""accepts LF alone and a last record without a line break"""
assert read_csv("a,b\nc,d") == [["a", "b"], ["c", "d"]]
def test_empty_fields_and_lines():
"""keeps empty fields, quoted or not, and a blank line as one empty field"""
assert read_csv('a,,""\r\n,\r\n\r\nb\r\n') == [["a", "", ""], ["", ""], [""], ["b"]]
assert read_csv("") == []
def test_text_after_closing_quote():
"""reports text after a closing quote at its line and column"""
message = error_of('id,name,city\r\n7,"Rao"x,Pune\r\n')
assert message == "line 2, column 8: a closing quote must be followed by a comma or a line break"
def test_quote_inside_unquoted_field():
"""reports a quote in the middle of an unquoted field"""
assert error_of('a,5" disk\r\n') == "line 1, column 4: a quote inside an unquoted field"
def test_quote_that_never_closes():
"""reports the opening quote of a field still open at the end"""
message = error_of('id,note\r\n1,"never closed\r\n2,x\r\n')
assert message == "line 2, column 3: the quoted field that starts here never closes"
def test_lines_inside_quotes_count():
"""counts the line breaks inside quoted fields"""
message = error_of('a,"x\r\ny"\r\nb,"c"d\r\n')
assert message == "line 3, column 6: a closing quote must be followed by a comma or a line break" A hint
Keep the reader in one of four states: at the start of a field, inside an unquoted field, inside a quoted field, and just after a quote inside a quoted field. Read one character at a time and let the state decide what it means: in the last state a second " is data, a comma or a line break ends the field, and anything else is the first error. Add 1 to the line on every LF, inside quotes too, and remember where each quoted field opened.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the rules, the reader’s states, dialects and broken input. The session opens with whichever of the four you are least sure of, and each choice you make is explained, right or wrong:
Practice that adapts to you
Practise this lesson
13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how does RFC 4180 write a value that contains a double quote, and why?
The whole field goes between double quotes, and each quote inside the value is written twice: Asha "AJ" Iyer
becomes "Asha ""AJ"" Iyer". RFC 4180 needs both halves of that rule. A quote inside an unquoted field is not allowed
at all, so the field must be quoted, and inside a quoted field a single quote would end the field, so the reader needs
a way to tell a closing quote from a quote that belongs to the value. After a quote, it looks at the next character:
another quote means one quote of data, and a comma or a line break means the field has ended. Libraries that use a
backslash instead, such as Python with doublequote=False and an escapechar, write another dialect, and the reader
must be told to use it.
Two harder questions follow, about columns that moved after a new supplier joined and about an import that reported success with rows missing. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A nightly import splits each line of a supplier's CSV file at commas. Since a new supplier joined, some rows put a city name in the amount column, and the load still reports success. What happened, and how do you change the import so that it cannot happen again?
-
Mid to senior
After a colleague raised csv.field_size_limit() to stop a "field larger than field limit" error, the nightly import of 50,000 rows reports success, but the table holds 31,207 rows and one note is about 6 MB long. What happened, how do you prove it, and what do you change?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does RFC 4180 separate CSV fields and records?
Commas between fields and CR LF after each record. The last record may lack its line break, and an optional first line names the fields.
-
When must a CSV field be quoted, and how is a quote inside it written?
When it contains the delimiter, a quote character or a line break. A quote inside is doubled, so Asha "AJ" Iyer is written "Asha ""AJ"" Iyer".
-
Why does splitting CSV lines at commas fail?
Quoted fields can hold commas and line breaks, so the first one such field is cut into pieces and every later column shifts.
-
What four states does a CSV reader need?
Field start, unquoted field, quoted field, and quote inside quotes, where the next character tells a doubled quote from the end of the field.
-
What does a CSV dialect fix?
The delimiter, the quote character, how a quote inside a field is escaped, the line ending and when fields are quoted. A reader with the wrong dialect gets wrong fields and no error.
-
What does Python's csv module do with ragged rows and an unclosed quote by default?
DictReader fills missing fields with None, and an unclosed quote swallows the rows after it, both silently. Count fields against the header, read with strict=True and report the line.
Key takeaways
- RFC 4180 separates fields with commas, ends records with CR LF and quotes a field that holds a comma, a quote or a line break, doubling each quote inside it; cutting lines at commas breaks the first quoted field.
- A reader follows quotes with four states: field start, unquoted field, quoted field and quote inside quotes, where the next character decides between a doubled quote and the end of the field.
- A dialect names the delimiter, quote character, escape rule, line ending and quoting policy; the reader must use the writer’s, because a mismatch gives wrong fields without an error.
- Python’s
csvmodule repairs broken input in silence by default:DictReaderfills ragged rows withNone, and an unclosed quote swallows the rows after it, so count fields, read withstrict=Trueand report the line.
Terms in this lesson
- dialect (CSV)
- The writing choices that a CSV reader must share with the writer: the delimiter, the quote character, how a quote inside a field is escaped, the line ending and when fields are quoted. Python names them in a csv.Dialect.
- quoted field
- A CSV field written between double quotes, so that commas, line breaks and quotes can be part of its value. RFC 4180 writes a quote inside it twice: "" stands for one quote of data.
- ragged row
- A CSV record with more or fewer fields than the header. RFC 4180 says every line should have the same number of fields; readers differ in whether they refuse such a row, pad it or keep it.
References
- RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files (IETF)
- Media type registration: text/csv (IANA)
- RFC 7111: URI Fragment Identifiers for the text/csv Media Type (IETF)
- draft-shafranovich-rfc4180-bis: Common Format and MIME Type for CSV Files (expired Internet-Draft) (IETF)
- Media type registration: text/tab-separated-values (IANA)
- csv: CSV File Reading and Writing (Python 3.14) (Python Software Foundation)
- Papa Parse documentation (Papa Parse)
- CSV Parse: options (Adaltas (Node.js CSV project))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress