parseCsv function

CsvParseResult parseCsv(
  1. String input, {
  2. String delimiter = ',',
  3. bool hasHeader = false,
  4. int? expectedColumns,
})

Parses a multi-line CSV input, collecting per-row errors instead of throwing on the first bad row — the shape a user-facing import needs so it can surface every problem at once.

Rows are split on \n (a trailing \r is stripped, so CRLF files work) and each non-blank line is parsed with parseCsvLine. Blank lines are skipped silently (not an error). Two structural problems send a row to CsvParseResult.errors rather than CsvParseResult.rows:

  • Unterminated quote — an odd number of " characters on the line (a valid line always has an even count: wrapping quotes and doubled escapes both come in pairs). This parser is line-oriented and does not support a quoted field that spans physical newlines.
  • Column-count mismatch — when an expected column count is known, a row with a different field count is rejected. The expected count is expectedColumns when given, otherwise the first non-blank row's count when hasHeader is true. With neither, only quote validation runs.

When hasHeader is true the header row is still returned as the first entry of CsvParseResult.rows (the caller knows it is the header). Audited: 2026-06-12 11:26 EDT

Implementation

CsvParseResult parseCsv(
  String input, {
  String delimiter = ',',
  bool hasHeader = false,
  int? expectedColumns,
}) {
  final List<List<String>> rows = <List<String>>[];
  final List<CsvRowError> errors = <CsvRowError>[];
  int? expected = expectedColumns;

  final List<String> lines = input.split('\n');
  for (int i = 0; i < lines.length; i++) {
    // Strip a trailing CR so Windows CRLF input does not leave '\r' on the last
    // field of every row.
    final String line = lines[i].endsWith('\r')
        ? lines[i].substring(0, lines[i].length - 1)
        : lines[i];

    // Skip blank lines silently — they are padding, not malformed data.
    if (line.isEmpty) {
      continue;
    }

    // An odd quote count means a quoted field was never closed on this line.
    if ('"'.allMatches(line).length.isOdd) {
      errors.add(CsvRowError(i + 1, line, 'unterminated quote'));
      continue;
    }

    final List<String> fields = parseCsvLine(line, delimiter: delimiter);

    // The first surviving row sets the expected width when a header is declared
    // and no explicit count was given.
    expected ??= hasHeader ? fields.length : null;

    if (expected != null && fields.length != expected) {
      errors.add(
        CsvRowError(
          i + 1,
          line,
          'expected $expected columns, found ${fields.length}',
        ),
      );
      continue;
    }

    rows.add(fields);
  }

  return CsvParseResult(rows, errors);
}