Split a CSV line with quoted fields
line.split(",") works right up until a product is called Mug, large. CSV has a small quoting
rule, and almost every broken import breaks on it. Write the parser.
Write parse_csv_line(line) that returns the fields as a list of strings:
- Fields are separated by commas. An empty line is one empty field:
[""]. - A field that starts with
"is quoted. Inside it, commas are plain text and""stands for one". The field ends at the next lone". - After a closing quote, the next character must be a comma or the end of the line. Anything else
raises
ValueError. - A quote that is never closed raises
ValueError. - In an unquoted field, a
"that isn’t the first character is just a character. - Don’t strip whitespace. Don’t use the
csvmodule.
>>> parse_csv_line('7,"Mug, large","say ""hi""",')
['7', 'Mug, large', 'say "hi"', '']
Hints get more specific as you go. Reveal one, try again, then come back.
Walk the string one character at a time with an index i, and build the current field in a list of
characters. Think of it as a small state machine: at the start of a field, inside quotes, or inside an
unquoted field.
At the start of a field, peek: if it’s ", you’re in quoted mode. In quoted mode, a " followed by
another " is an escaped quote (append one, skip two). A " followed by anything else closes the
field.
After a closing quote, look at the next character. Comma: start a new field. End of line: finish.
Anything else: raise ValueError. Reaching the end while still inside quotes is also a
ValueError.
Trailing comma means one more empty field. Handle it by appending the current field every time you see a separator, and once more at the very end.
Try one more hint first? The solution is always here, but working it out is what makes it stick.
Approach
Parse field by field. At the start of each field, decide its mode from the first character.
def parse_csv_line(line):
fields = []
i, n = 0, len(line)
while True:
buf = []
if i < n and line[i] == '"':
i += 1
while True:
if i >= n:
raise ValueError("unterminated quote")
ch = line[i]
if ch == '"':
if i + 1 < n and line[i + 1] == '"':
buf.append('"')
i += 2
continue
i += 1
break
buf.append(ch)
i += 1
if i < n and line[i] != ",":
raise ValueError(f"unexpected character after closing quote at {i}")
else:
j = line.find(",", i)
if j == -1:
j = n
buf.append(line[i:j])
i = j
fields.append("".join(buf))
if i >= n:
return fields
i += 1 # skip the comma
The loop always appends a field before it checks for the end. That one choice gives you [""] for
an empty line and a trailing empty field for a,, with no special cases.
Unquoted fields use str.find to jump straight to the next comma. That is faster than a
character loop, and correct, because quotes have no meaning inside an unquoted field.
Complexity
O(n) in the length of the line. Each character is looked at a constant number of times, and
fields are built with "".join rather than repeated +=.
Why this matters
CSV is still how a lot of data moves between systems, and the quoting rule is where imports fail.
Some writers emit \" instead of "". Some don’t quote at all. Each mistake shifts every column
after it by one. A strict parser that raises on bad input is better than a lenient one that
silently loads shifted columns into your warehouse. Real CSV also allows newlines inside quotes, so
a “line” is not always a line. That is why production readers work on a stream of characters, not
on str.splitlines().
Per-problem discussion threads are coming. Until then, questions and better solutions are welcome on LinkedIn or by email at hello@builtfrombits.com.
Press ⌘ ↵ or Run to check the visible tests. Submit runs the hidden ones too. Everything runs in your browser.