Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Every quoting rule, written and read back

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

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

  1. Question 1 of 2 How does RFC 4180 write the value Asha "AJ" Iyer as one field?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "Asha ""AJ"" Iyer"

    Rule 7: inside a quoted field a quote is written twice. The reader takes "" as one quote of data and a single quote followed by a comma or a line break as the end of the field.

  2. Question 2 of 2 A writer follows RFC 4180 and quotes only where it must. Which of these values does it put between quotes?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Rao, Vikram
    • Asha "AJ" Iyer
    • a value with CR LF inside it

    Rule 6 names the three characters that force quotes: a comma, a double quote and a line break. Anything else may be quoted, but need not be, which is Python's QUOTE_MINIMAL default.

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:

Interactive · step by step Reading 7,"Rao, ""Vik""",Pune one character at a time Step through the record. Each step says what the reader just read, which state it moves to and what the field holds.
Reading 7,"Rao, ""Vik""",Pune one character at a time, step 12 of 12. Showing Field start (fields: 7 | Rao, "Vik"), Unquoted field (field so far: Pune), Quoted field (value: Rao, "Vik"), Quote inside quotes. In focus: Quoted field.any other charactercomma ends the fielda quote opens ita quotea second quote: one "comma ends the fieldField startfields: 7 | Rao, "Vik"Unquoted fieldfield so far: PuneQuoted fieldvalue: Rao, "Vik"Quote inside quotesclosing, or doubled?text ends here: unclosed quote

The drawing shows the whole scene; its 12 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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

  1. Question 1 of 2 A reader at the start of a field reads the characters "Rao, (a quote, R, a, o, a comma and a space). Which state is it in now?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Quoted field, with the value "Rao, " so far

    The opening quote moves the reader into the quoted field, and every character but a quote stays there as data, the comma included. That is the step "Commas inside quotes" of the figure.

  2. Question 2 of 2 Inside a quoted field the reader has just read a quote. Which next character means the input is broken?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: x, a letter

    In the state "quote inside quotes" the next character decides: a quote is data, a comma or a line break ends the field, and anything else is text after a closing quote, which a strict reader reports.

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:

One table in six dialects Python · dialects/dialects.py
"""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

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.

CSV Editor & Viewer Open a real export: the editor detects its delimiter and encoding, and when you save it you choose the delimiter, quoting and line endings.

Checkpoint

Check: Dialects and their options

  1. Question 1 of 2 The record 7;Rao, Vikram;1250.50 was written with delimiter=";". What does csv.reader with its default dialect return for it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ['7;Rao', ' Vikram;1250.50']

    A dialect mismatch gives wrong fields without an error. The reader must be told the writer's delimiter, or detect it and check the result.

  2. Question 2 of 2 Which Python setting writes None and an empty string differently, and reads them back as they were?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: quoting=csv.QUOTE_NOTNULL

    RFC 4180 has only the empty field. QUOTE_NOTNULL, new in Python 3.12, uses quoting to keep None and '' apart, as the last block of dialects.py showed: ['8', 'Asha|AJ', ''] and ['9', 'Ravi "R" Nair', None].

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:

A short row, a long row and a blank line Python · broken/ragged.py
"""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

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:

Interactive · answer and compare What should an import do with a row that does not fit? Answer three questions about your import. The table marks the policies that fit and says why the others do not.
Must the rows load together or not at all, as in a ledger or a payroll file?
Can the sender send a fixed file within the time you have?
Does your agreement with the sender say which trailing fields may be left out?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four policies for a row with fewer or more fields than the header
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:

Broken quotes, read leniently and strictly Python · broken/quotes.py
"""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

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

  1. Question 1 of 2 The header is id,name,city and a record is 3,Meena,Kochi,Ernakulam. Where does DictReader put Ernakulam?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In a list under the key None

    With restkey and restval at their default None, DictReader fills missing fields with None and stores extra ones in a list under the key None, and carries on without an error.

  2. Question 2 of 2 Which of these inputs make csv.reader(..., strict=True) raise csv.Error?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • a,"b"c,d (text after a closing quote)
    • a,"b and then the end of the text (a quote that never closes)

    strict=True catches text after a closing quote and an unclosed quote, the two cases quotes.py showed failing. It still lets a quote inside an unquoted field and a space before a quote through, so an import that must follow RFC 4180 needs its own checks.

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 text has 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, for 7,"Rao"x,Pune on line 2.
  • line 1, column 4: a quote inside an unquoted field, for a,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.

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.

CSV to JSON Converter Paste CSV with quoted commas and line breaks and see the records a careful reader makes of it, before you write your own.

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.

  1. Question 1 of 13 What does RFC 4180 say ends each record of a CSV file?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: CR LF, the two characters \r\n

    RFC 4180 ends every record with CR LF and lets the last record end without one. The text/csv registration notes that some implementations use other line endings, which is why readers accept LF too.

  2. Question 2 of 13 What kind of document is RFC 4180?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: An Informational RFC that documents the common format and registers text/csv

    RFC 4180 is Informational. It is still the text everyone cites for CSV, and it registers the media type text/csv with the optional parameters charset and header.

  3. Question 3 of 13 What does this line print?

    Read the code, then choose one answer.

    print('7,"Rao, Vikram",Pune'.split(","))
    Show the answer: Question 3 of 13

    Answer: ['7', '"Rao', ' Vikram"', 'Pune']

    Splitting at commas gives four pieces for a record of three fields, and every column after the name moves one place. Only a reader that follows the quoting rules gets the three fields back.

  4. Question 4 of 13 A colleague shortens the JavaScript writer to this. Which value does it now write wrongly?

    Read the code, then choose one answer.

    const field = (v) => (/[",]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v);
    Show the answer: Question 4 of 13

    Answer: 12 FC Road, then CR LF, then Pune

    Rule 6 names three characters, and the line break is the one people forget. Without quotes, the CR LF inside the address becomes the end of the record, and the reader finds a short record and a broken one.

  5. Question 5 of 13 Why does the reader need the state "quote inside quotes" at all?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Because after a quote it cannot yet tell a closing quote from the first half of a doubled one

    A quote inside a quoted field is ambiguous until the next character arrives: a quote makes it data, a comma or a line break makes it the end of the field. The fourth state holds that undecided moment.

  6. Question 6 of 13 A file of 40 lines has an opening quote on line 3 that never closes. Where should a reader report the error?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: At line 3, the column of the opening quote

    A reader that remembers where each quoted field opened can name the real cause. The exercise asks for exactly that message: the quoted field that starts here never closes.

  7. Question 7 of 13 The reader is in an unquoted field holding 5 and reads a quote, as in a,5" disk. A reader that follows RFC 4180 strictly does what?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Reports a quote inside an unquoted field

    The exercise's reader is stricter than Python's: it refuses the quote with its line and column, so the sender learns that 5" disk must be written "5"" disk".

  8. Question 8 of 13 What is a CSV dialect?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: The writer's choices of delimiter, quote character, escape rule, line ending and quoting policy

    A dialect is the set of writing choices that RFC 4180 leaves open or that a file departs from. Python names three, excel, excel-tab and unix, and takes any choice as an argument.

  9. Question 9 of 13 csv.writer(out, doublequote=False, escapechar='\\') writes the value Ravi "R" Nair. What appears in the file?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Ravi \"R\" Nair, with no quotes around it

    The Python documentation says that with doublequote False the escapechar is written in front of the quotechar, and dialects.py showed that the writer then leaves the field unquoted. A reader with the excel dialect keeps the backslashes as data.

  10. Question 10 of 13 A partner asks for TSV as registered by text/tab-separated-values. One of your fields may contain a tab. What do you do?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Replace or refuse the tab before writing, because that format has no quoting at all

    Registered TSV keeps tabs out of its fields and has no quote character. Agree with the partner what replaces a tab, or refuse the value, before the file is written.

  11. Question 11 of 13 With its default settings, what does csv.reader do with a quote that never closes?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: It puts the rest of the text into that one field, without an error

    The default reader is lenient: an unclosed quote swallows every row after it. Read with strict=True, and check the record count against what the sender says the file holds.

  12. Question 12 of 13 How many characters may a field hold before Python's csv module raises "field larger than field limit", with the default settings?

    Type a number.

    Show the answer: Question 12 of 13

    Answer: 131072 characters

    csv.field_size_limit() returned 131072 on Python 3.14.8. A longer field in a file of short notes usually means an unclosed quote that has started to swallow the rows after it.

  13. Question 13 of 13 A payroll import of 1,200 rows finds 3 rows with too few fields. The payroll must run complete or not at all. Which policy fits?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Reject the file and report the 3 line numbers to the sender

    The all-or-nothing question decides it, as in the trade-off table: when rows depend on each other, reject and report; when they stand alone, quarantine with line numbers and keep loading.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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".

  3. 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.

  4. 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.

  5. 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.

  6. 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 csv module repairs broken input in silence by default: DictReader fills ragged rows with None, and an unclosed quote swallows the rows after it, so count fields, read with strict=True and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.