CSV With Quoted Commas: The 30-Line Parser
split(',') gets you fired on the first row that contains a comma inside quotes. Here is a state-machine CSV parser in 30 lines that handles quoted commas, escaped quotes, and CRLF endings.
By @gracechoi
December 14, 2025
·
Updated May 20, 2026
296 views
8
4.3 (11)
// CSV done right: a tiny state machine over the input string.
// Handles: quoted fields, commas inside quotes, embedded newlines, escaped
// double-quotes (RFC 4180 "" -> "), and CRLF or LF row endings.
// Does NOT handle: streaming over chunks (see accordion 3) or alternate delimiters.
function parseCsv(input) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < input.length) {
const c = input[i];
if (inQuotes) {
if (c === '"') {
if (input[i + 1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
}
field += c; i++; continue;
}
if (c === '"') { inQuotes = true; i++; continue; }
if (c === ',') { row.push(field); field = ''; i++; continue; }
if (c === '\n' || c === '\r') {
row.push(field); field = '';
rows.push(row); row = [];
if (c === '\r' && input[i + 1] === '\n') i += 2; else i++;
continue;
}
field += c; i++;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
const sample = `name,note,score
Alice,"Says, hello",95
Bob,"He said ""hi"" \nback",87
Carol,plain text,73\r\n`;
console.log(parseCsv(sample));A state machine is the only correct way to parse CSV. The two states are 'in quotes' and 'not in quotes', and every character either advances state or appends to the current field. The trickiest case is the doubled-quote escape ("" inside a quoted field becomes a literal "), which a regex-based parser usually botches. I deliberately do NOT support backslash escapes because RFC 4180 does not, and supporting both produces ambiguous parses. The CRLF handling at the bottom is required for any CSV that came from a Windows tool or from Excel.
// Most consumers want { name: 'Alice', score: 95 } records, not [['Alice','95']].
// Wrap the parser with a header pass.
function parseCsv(input) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < input.length) {
const c = input[i];
if (inQuotes) {
if (c === '"') {
if (input[i + 1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
}
field += c; i++; continue;
}
if (c === '"') { inQuotes = true; i++; continue; }
if (c === ',') { row.push(field); field = ''; i++; continue; }
if (c === '\n' || c === '\r') {
row.push(field); field = '';
rows.push(row); row = [];
if (c === '\r' && input[i + 1] === '\n') i += 2; else i++;
continue;
}
field += c; i++;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
function parseCsvAsRecords(input, { coerce = {} } = {}) {
const rows = parseCsv(input);
if (rows.length === 0) return [];
const [header, ...rest] = rows;
return rest.map((cells) => {
const obj = {};
for (let i = 0; i < header.length; i++) {
const key = header[i];
const raw = cells[i] ?? '';
obj[key] = coerce[key] ? coerce[key](raw) : raw;
}
return obj;
});
}
const sample = `name,score,active
Alice,95,true
Bob,87,false`;
const records = parseCsvAsRecords(sample, {
coerce: { score: Number, active: (s) => s === 'true' },
});
console.log(records);The wrapper splits responsibilities: low-level cell extraction stays a pure state machine, and the type-coercion layer is a per-column dictionary that the caller controls. Optional and explicit beats automagic type inference, which is the choice papaparse and PapaParse-likes make and which you regret the day a column with values '01' and '02' becomes numbers. The ?? '' for missing trailing cells matters: short rows happen all the time when an Excel user deletes the trailing comma; treating them as empty strings is friendlier than throwing.
// When the file is multi-GB you cannot read it as one string.
// Drive the same state machine from a chunk feeder; emit complete records.
class CsvStreamParser {
constructor() {
this.row = [];
this.field = '';
this.inQuotes = false;
this.prevWasCR = false; // CRLF spanning chunk boundaries
this.headers = null;
this.records = [];
}
feed(chunk) {
// The parser state itself (row, field, inQuotes, prevWasCR) IS the buffer.
// An unterminated tail lives in this.field until the next chunk arrives.
for (let i = 0; i < chunk.length; i++) {
const c = chunk[i];
// If the previous char was CR, swallow a leading LF as the same line ending.
if (this.prevWasCR) {
this.prevWasCR = false;
if (c === '\n') continue;
}
if (this.inQuotes) {
if (c === '"') {
if (chunk[i + 1] === '"') { this.field += '"'; i += 1; continue; }
this.inQuotes = false; continue;
}
this.field += c; continue;
}
if (c === '"') { this.inQuotes = true; continue; }
if (c === ',') { this.row.push(this.field); this.field = ''; continue; }
if (c === '\r') {
this.row.push(this.field); this.field = '';
this._commit();
this.prevWasCR = true;
continue;
}
if (c === '\n') {
this.row.push(this.field); this.field = '';
this._commit();
continue;
}
this.field += c;
}
}
end() {
if (this.field.length > 0 || this.row.length > 0) {
this.row.push(this.field); this.field = '';
this._commit();
}
const records = this.records; this.records = [];
return records;
}
_commit() {
if (this.headers === null) {
this.headers = this.row;
} else {
const obj = {};
for (let i = 0; i < this.headers.length; i++) obj[this.headers[i]] = this.row[i] ?? '';
this.records.push(obj);
}
this.row = [];
}
}
// Simulate a chunked feed; chunks split mid-field and mid-CRLF.
const parser = new CsvStreamParser();
parser.feed('name,note\nAlice,"sa');
parser.feed('ys, hello"\r');
parser.feed('\nBob,plain');
console.log(parser.end());The streaming version preserves the same state machine, but the parser state itself (row, field, inQuotes) IS the buffer: an unterminated tail just lives in this.field until the next chunk arrives. That removes the manual slice-the-buffer dance most handwritten streaming parsers fumble. I split mid-field and mid-CRLF in the demo because those are the two boundary cases that trip up handwritten parsers; if your tests cover only chunk-aligned-to-row-end inputs, the parser passes them and breaks in production. For files larger than ~1GB I do reach for a real CSV library, but knowing this state machine is what lets me audit which one to trust.
