Skip to content
VLSI Mentor

Perl Part II · Module 24 · Coverage and Regression Reporting

Writing CSV Correctly

Writing CSV is four lines, and the four lines are easy to get wrong in ways that produce a file every tool opens and one tool misreads. This chapter builds the writer, then builds the test that proves it — and the test is the interesting part, because the first version of it passed two of the four deliberately broken writers it was pointed at, and the fourth broken writer loses no data at all.

Intermediate12 min readPerlPart IIRegression Reporting

Part II · Module 24 · Chapter 24.8 · Writing CSV Correctly

1. The Writer, And A Round Trip

The field writer is the registry's own sketch, and it is as short as it looks:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sub csv_field {
    my ($v) = @_;
    $v = '' unless defined $v;
    return $v =~ /[",\n]/ ? '"' . ($v =~ s/"/""/gr) . '"' : $v;
}

Three decisions in four lines: an undefined value becomes empty, a field containing a quote, a comma or a newline gets wrapped in quotes, and a quote inside becomes two quotes. The /r modifier (11.14) returns the modified copy without touching $v.

Azvya Education Pvt. Ltd.VLSI Mentor
csvout.pl
use strict;
use warnings;

# ---- the field writer from the registry's sketch
sub csv_field {
    my ($v) = @_;
    $v = '' unless defined $v;
    return $v =~ /[",\n]/ ? '"' . ($v =~ s/"/""/gr) . '"' : $v;
}
sub csv_row { return join(",", map { csv_field($_) } @_) . "\n" }

# ---- a correct reader, so the writer can be checked by round trip.
# \G with /gc walks the line field by field, which is what makes a TRAILING
# empty field survive -- the first version of this dropped it.
sub csv_parse {
    my ($line) = @_;
    $line =~ s/\r?\n\z//;
    my @f;
    pos($line) = 0;
    while (1) {
        if ($line =~ /\G"((?:[^"]|"")*)"/gc) {
            my $v = $1;
            $v =~ s/""/"/g;
            push @f, $v;
        } elsif ($line =~ /\G([^,]*)/gc) {
            push @f, $1;
        }
        last unless $line =~ /\G,/gc;
    }
    return @f;
}

# ---- 1. the hostile field set. These are TEST VECTORS, not log data:
# a writer is only trustworthy if it survives the values a real message,
# path or tool banner can contain.
my @VECTORS = (
    ["plain",            "arb_smoke"],
    ["a comma",          "granted 2, expected 0"],
    ["a double quote",   'expected "GNT_VEC"'],
    ["both",             'got "2,0" not "0,2"'],
    ["a newline",        "line one\nline two"],
    ["leading space",    "  indented"],
    ["trailing space",   "trailing  "],
    ["empty string",     ""],
    ["undef",            undef],
    ["a lone quote",     '"'],
    ["only a comma",     ","],
    ["looks like maths", "=1+1"],
    ["leading zero",     "007"],
    ["a big number",     "12345678901234567890"],
    ["a CR",             "has\rcarriage"],
);
# A STRICT reader: RFC 4180 forbids a bare quote inside an unquoted field,
# so a lenient reader hides a writer that forgot to quote one.
sub csv_parse_strict {
    my ($line) = @_;
    $line =~ s/\r?\n\z//;
    my (@f, @err);
    pos($line) = 0;
    while (1) {
        if ($line =~ /\G"((?:[^"]|"")*)"/gc) {
            my $v = $1;
            $v =~ s/""/"/g;
            push @f, $v;
        } elsif ($line =~ /\G([^,]*)/gc) {
            push @err, "bare quote in an unquoted field" if $1 =~ /"/;
            push @f, $1;
        }
        last unless $line =~ /\G,/gc;
    }
    return (\@f, \@err);
}

print "==== round trip, one ROW at a time ====\n";
print "(a field on its own cannot show a newline breaking the record, so each\n";
print " value is written into a three-field row and the row is read back)\n";
printf "%-18s %-24s %-6s %-7s %s\n", "CASE", "VALUE", "LINES", "FIELDS", "VERDICT";
my ($ok, $bad, $multi) = (0, 0, 0);
for my $vec (@VECTORS) {
    my ($name, $in) = @$vec;
    my $row = csv_row("start", $in, "end");
    my @rowlines = split /\n/, $row, -1;
    pop @rowlines if @rowlines && $rowlines[-1] eq "";
    my ($f, $err) = csv_parse_strict($rowlines[0]);
    my $want = defined $in ? $in : "";
    # three outcomes, not two. A quoted newline makes the RECORD span two
    # lines, which is valid CSV that a line-oriented reader cannot consume --
    # that is a reader limitation, not the writer losing data.
    my ($verdict, @why);
    if (@rowlines > 1) {
        $verdict = "multi-line record";
        $multi++;
    } else {
        push @why, @$err;
        push @why, sprintf("%d fields, expected 3", scalar @$f) if @$f != 3;
        push @why, "value changed"
            if @$f == 3 && (!defined $f->[1] || $f->[1] ne $want);
        $verdict = @why ? "LOST: " . join("; ", @why) : "ok";
        @why ? $bad++ : $ok++;
    }
    my $show = defined $in ? $in : "(undef)";
    $show =~ s/\n/\\n/g;
    $show =~ s/\r/\\r/g;
    printf "%-18s %-24s %-6s %-7s %s\n", $name, $show,
        scalar @rowlines, scalar @$f, $verdict;
}
printf "\n%d read back intact, %d LOST by the writer, %d valid multi-line record(s)\n",
    $ok, $bad, $multi;
print "a value containing a newline is written correctly and still cannot be\n";
print "read by anything that works a line at a time, so a pipeline with a\n";
print "line-based consumer should replace newlines before writing, not after\n"
    if $multi;


# ---- 1b. the test has to be able to fail, so run it against writers that
# are deliberately wrong. A __WARN__ handler counts warnings in-process,
# because a writer can lose nothing and still be broken.
print "\n==== the same test against four broken writers ====\n";
my %WRITERS = (
    "correct"            => \&csv_field,
    "no quote doubling"  => sub { my ($v) = @_; $v = '' unless defined $v;
                                  return $v =~ /[",\n]/ ? '"' . $v . '"' : $v },
    "quotes only on ','" => sub { my ($v) = @_; $v = '' unless defined $v;
                                  return $v =~ /[,]/
                                      ? '"' . ($v =~ s/"/""/gr) . '"' : $v },
    "no quoting at all"  => sub { my ($v) = @_; $v = '' unless defined $v;
                                  return $v },
    "no undef guard"     => sub { my ($v) = @_;
                                  return $v =~ /[",\n]/
                                      ? '"' . ($v =~ s/"/""/gr) . '"' : $v },
);
printf "%-20s %-7s %-7s %-7s %-10s %s\n",
    "WRITER", "INTACT", "LOST", "MULTI", "WARNINGS", "CAUGHT BY";
for my $name ("correct", "no quote doubling", "quotes only on ','",
              "no quoting at all", "no undef guard") {
    my $w = $WRITERS{$name};
    my ($i, $l, $m, $warn) = (0, 0, 0, 0);
    local $SIG{__WARN__} = sub { $warn++ };
    for my $vec (@VECTORS) {
        my (undef, $in) = @$vec;
        my $row = join(",", map { $w->($_) } ("start", $in, "end")) . "\n";
        my @rl = split /\n/, $row, -1;
        pop @rl if @rl && $rl[-1] eq "";
        if (@rl > 1) { $m++; next }
        my ($f, $err) = csv_parse_strict($rl[0]);
        my $want = defined $in ? $in : "";
        (@$err || @$f != 3 || !defined $f->[1] || $f->[1] ne $want)
            ? $l++ : $i++;
    }
    my @by;
    push @by, "lost data"  if $l;
    push @by, "warnings"   if $warn;
    printf "%-20s %-7d %-7d %-7d %-10d %s\n", $name, $i, $l, $m, $warn,
        $name eq "correct" ? "-- the golden writer" :
        (@by ? join(" + ", @by) : "NOTHING -- a survivor");
}
print "\nthree of the four are caught by data loss. The fourth loses nothing at\n";
print "all -- its only signal is the warnings -- so a round-trip check that\n";
print "ignores stderr passes it unchanged.\n";
print "The gate needs BOTH: no lost fields AND no warnings.\n";

# ---- 2. a naive split cannot read a correct file
print "\n==== what a naive split does to a correct row ====\n";
my @fields = ("arb_random", 7, "FAIL", 'got "2,0" not "0,2"', "");
my $row = csv_row(@fields);
printf "fields written : %d\n", scalar @fields;
print  "the row        : $row";
my @naive = split /,/, $row;
chomp @naive;
printf "split /,/ gives: %d field(s)  %s\n", scalar @naive,
    join " | ", @naive;
my @proper = csv_parse($row);
printf "a real parser  : %d field(s)  %s\n", scalar @proper,
    join " | ", map { defined $_ ? $_ : "(undef)" } @proper;
printf "so the file is correct and the reader is wrong -- %d against %d fields\n",
    scalar @naive, scalar @fields;

# ---- 3. empty against missing
print "\n==== an empty value and a missing one ====\n";
my $r_empty   = csv_row("arb_trunc", 7, "UNKNOWN", 8, "");
my $r_undef   = csv_row("arb_trunc", 7, "UNKNOWN", 8, undef);
printf "simtime = ''    -> %s", $r_empty;
printf "simtime = undef -> %s", $r_undef;
printf "the two rows are %s\n", $r_empty eq $r_undef ? "IDENTICAL" : "different";
print "so CSV cannot carry the difference between 'measured as nothing' and\n";
print "'never measured'. A spreadsheet reads both as blank, and a chart reads\n";
print "blank as zero.\n";
my $r_marked = csv_row("arb_trunc", 7, "UNKNOWN", 8, "NA");
printf "with an explicit marker -> %s", $r_marked;

# ---- 4. the column order has to be fixed, not taken from a hash
print "\n==== the column order ====\n";
my %rec = (test => "arb_light", seed => 7, verdict => "FAIL",
           errors => 1, simtime => 325);
# the actual order is masked here, because printing it would make this page
# different on every run -- which is exactly the problem being measured
printf "keys %%rec in this process : %d key(s) in an order chosen per process\n",
    scalar keys %rec;
my @COLS = qw(test seed verdict errors simtime);
printf "the declared column order : %s\n", join ",", @COLS;
if (!$ENV{CSV_CHILD}) {
    my %orders;
    for (1 .. 5) {
        local $ENV{CSV_CHILD} = 1;
        my $out = `perl "$0" 2>/dev/null`;
        $orders{$1}++ if $out =~ /^CHILD KEY ORDER: (.*)$/m;
    }
    printf "5 separate processes: %s\n", keys(%orders) > 1
        ? "MORE THAN ONE distinct key order" : "one key order";
    print "the declared order    : one, in every process, by construction\n";
    print "so a CSV header taken from keys %hash can change between two runs\n";
    print "of the same script, and every consumer keyed on column position\n";
    print "silently reads the wrong column\n";
} else {
    printf "CHILD KEY ORDER: %s\n", join ",", keys %rec;
    exit 0;
}

# ---- 5. the real file
print "\n==== the regression as CSV ====\n";
my %RANK = (FAIL => 0, TIMEOUT => 1, UNKNOWN => 2, PASS => 3);
sub read_log {
    my ($path) = @_;
    my %r = (verdict => "UNKNOWN", errors => 0, complete => 0, budget => 0);
    open my $fh, "<", $path or return \%r;
    while (my $l = <$fh>) {
        $r{test}  = $1 if $l =~ /^TEST:\s+(\S+)/;
        $r{seed}  = $1 if $l =~ /^SEED:\s+(\d+)/;
        $r{simtime} = $1 if $l =~ /^SIMTIME:\s+(\d+)/;
        $r{budget} = 1 if $l =~ /^UVM_FATAL\s.*\[BUDGET\]/;
        $r{msg}++      if $l =~ /^UVM_ERROR\s+\S+\(\d+\)/;
        $r{complete} = 1 if $l =~ /^\*\* Report counts by severity/;
        $r{sum}   = $1 if $l =~ /^UVM_ERROR\s*:\s*(\d+)/;
        $r{verdict} = "PASS" if $l =~ /^TEST PASSED/;
        $r{verdict} = "FAIL" if $l =~ /^TEST FAILED/;
    }
    close $fh;
    $r{errors}  = $r{complete} ? $r{sum} : ($r{msg} || 0);
    $r{verdict} = "TIMEOUT" if $r{budget} && $r{verdict} eq "UNKNOWN";
    return \%r;
}
my @recs = grep { defined $_->{test} } map { read_log($_) } sort glob("logs/*.log");
open my $out, ">", "results.csv" or die $!;
print {$out} csv_row(@COLS);
for my $r (sort { $RANK{$a->{verdict}} <=> $RANK{$b->{verdict}}
                    or $a->{test} cmp $b->{test}
                    or $a->{seed} <=> $b->{seed} } @recs) {
    print {$out} csv_row(map { defined $r->{$_} ? $r->{$_} : "NA" } @COLS);
}
close $out;
printf "rows written: %d data + 1 header\n", scalar @recs;
open my $in, "<", "results.csv" or die $!;
my @all = <$in>; close $in;
print "the first four lines:\n";
print "  $_" for @all[0 .. 3];
# every row must parse back to the same number of fields as the header
my $want = scalar @COLS;
my @wrongwidth = grep { my @f = csv_parse($all[$_]); @f != $want } 0 .. $#all;
printf "rows whose field count differs from the header: %d of %d\n",
    scalar @wrongwidth, scalar @all;
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
==== round trip, one ROW at a time ====
(a field on its own cannot show a newline breaking the record, so each
 value is written into a three-field row and the row is read back)
CASE               VALUE                    LINES  FIELDS  VERDICT
plain              arb_smoke                1      3       ok
a comma            granted 2, expected 0    1      3       ok
a double quote     expected "GNT_VEC"       1      3       ok
both               got "2,0" not "0,2"      1      3       ok
a newline          line one\nline two       2      2       multi-line record
leading space        indented               1      3       ok
trailing space     trailing                 1      3       ok
empty string                                1      3       ok
undef              (undef)                  1      3       ok
a lone quote       "                        1      3       ok
only a comma       ,                        1      3       ok
looks like maths   =1+1                     1      3       ok
leading zero       007                      1      3       ok
a big number       12345678901234567890     1      3       ok
a CR               has\rcarriage            1      3       ok

14 read back intact, 0 LOST by the writer, 1 valid multi-line record(s)
a value containing a newline is written correctly and still cannot be
read by anything that works a line at a time, so a pipeline with a
line-based consumer should replace newlines before writing, not after

==== the same test against four broken writers ====
WRITER               INTACT  LOST    MULTI   WARNINGS   CAUGHT BY
correct              14      0       1       0          -- the golden writer
no quote doubling    11      3       1       0          lost data
quotes only on ','   12      2       1       0          lost data
no quoting at all    9       5       1       0          lost data
no undef guard       14      0       1       2          warnings

three of the four are caught by data loss. The fourth loses nothing at
all -- its only signal is the warnings -- so a round-trip check that
ignores stderr passes it unchanged.
The gate needs BOTH: no lost fields AND no warnings.

==== what a naive split does to a correct row ====
fields written : 5
the row        : arb_random,7,FAIL,"got ""2,0"" not ""0,2""",
split /,/ gives: 7 field(s)  arb_random | 7 | FAIL | "got ""2 | 0"" not ""0 | 2""" | 
a real parser  : 5 field(s)  arb_random | 7 | FAIL | got "2,0" not "0,2" | 
so the file is correct and the reader is wrong -- 7 against 5 fields

==== an empty value and a missing one ====
simtime = ''    -> arb_trunc,7,UNKNOWN,8,
simtime = undef -> arb_trunc,7,UNKNOWN,8,
the two rows are IDENTICAL
so CSV cannot carry the difference between 'measured as nothing' and
'never measured'. A spreadsheet reads both as blank, and a chart reads
blank as zero.
with an explicit marker -> arb_trunc,7,UNKNOWN,8,NA

==== the column order ====
keys %rec in this process : 5 key(s) in an order chosen per process
the declared column order : test,seed,verdict,errors,simtime
5 separate processes: MORE THAN ONE distinct key order
the declared order    : one, in every process, by construction
so a CSV header taken from keys %hash can change between two runs
of the same script, and every consumer keyed on column position
silently reads the wrong column

==== the regression as CSV ====
rows written: 36 data + 1 header
the first four lines:
  test,seed,verdict,errors,simtime
  arb_light,7,FAIL,1,325
  arb_random,1,FAIL,55,4025
  arb_random,7,FAIL,62,4025
rows whose field count differs from the header: 0 of 37

The test writes each value into a three-field row rather than testing the field alone, and that was not the first design. The first version round-tripped each field on its own — which cannot see a newline splitting the record in two, because there is no record. Section 2 shows what that cost.

One result in the table is not a failure:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a newline          line one\nline two       2      2       multi-line record

The writer is correct here. A quoted newline makes the CSV record span two lines, which RFC 4180 permits and every spreadsheet handles. What cannot handle it is anything reading a line at a time — which is most of what consumes a regression CSV. So if the consumer is line-based, replace newlines before writing, and say so in the header or the column name. Fixing it after the fact is impossible: by then the record boundary is gone.

2. The Test Against Four Broken Writers

A round-trip test that only ever sees a correct writer proves nothing, so the script points the same vectors at four writers that are deliberately wrong:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
WRITER               INTACT  LOST    MULTI   WARNINGS   CAUGHT BY
correct              14      0       1       0          -- the golden writer
no quote doubling    11      3       1       0          lost data
quotes only on ','   12      2       1       0          lost data
no quoting at all    9       5       1       0          lost data
no undef guard       14      0       1       2          warnings

Two things to take from this table.

Three writers are caught by losing data — three, two and five of the fifteen values respectively. That is what a round-trip test is for.

The fourth loses nothing. Dropping the $v = '' unless defined $v guard changes no output byte; it just makes Perl warn twice while producing it. A round-trip check that only compares values passes it, and the warnings go to stderr where a nightly cron job sends them nowhere.

So the gate is two conditions:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
no lost fields  AND  no warnings

And it is worth knowing how the warnings were counted, because 2>/dev/null is the usual habit:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
local $SIG{__WARN__} = sub { $warn++ };

A __WARN__ handler counts them in-process (14.2), which means the check works the same way inside a test harness as it does at a terminal.

There is also a reader-side lesson buried in the earlier version of this test. It used a lenient parser, which happily read a bare quote inside an unquoted field — so it reported success for the writer that never quotes anything. RFC 4180 forbids that, and once the reader enforced it, two more mutants fell. A lenient reader hides a broken writer.

3. A Correct File Defeats A Naive Split

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fields written : 5
the row        : arb_random,7,FAIL,"got ""2,0"" not ""0,2""",
split /,/ gives: 7 field(s)  arb_random | 7 | FAIL | "got ""2 | 0"" not ""0 | 2""" |
a real parser  : 5 field(s)  arb_random | 7 | FAIL | got "2,0" not "0,2" |

Seven fields from a five-field row. The file is correct; split /,/ is wrong. This is the harder direction to debug, because the producer is innocent and the symptom appears in somebody else's script — usually as a column shifted by two on a handful of rows.

A real parser walks the line field by field with \G and /gc (11.20):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if ($line =~ /\G"((?:[^"]|"")*)"/gc) { ... }
elsif ($line =~ /\G([^,]*)/gc)       { ... }
last unless $line =~ /\G,/gc;

That final last unless is what preserves a trailing empty field. My first version of this parser lost it — it returned four fields for the five-field row above — and the symptom was a silently short row rather than an error. For production, Text::CSV exists and is better than either version; the point of writing one here is that the round-trip test needs a reader it can trust, and a reader from CPAN cannot be mutated to prove the test discriminates.

4. Empty Against Missing

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
simtime = ''    -> arb_trunc,7,UNKNOWN,8,
simtime = undef -> arb_trunc,7,UNKNOWN,8,
the two rows are IDENTICAL
with an explicit marker -> arb_trunc,7,UNKNOWN,8,NA

CSV has one representation for both. A field that was measured as nothing and a field that was never measured come out as the same bytes, and the distinction matters in exactly the case this corpus contains: arb_trunc never reached the line that reports its simulated time, so it has no measurement — not a measurement of zero.

What happens downstream makes it worse. A spreadsheet shows a blank; a chart reads blank as zero; an average over the column quietly includes a run that produced no number. Chapter 24.3 found the same thing in coverage, where "never measured" must not merge in as zeros.

So write a marker — NA, or -, or anything the consumer is told about — and keep it out of the numeric columns' arithmetic. The real file in the script does exactly that:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
csv_row(map { defined $r->{$_} ? $r->{$_} : "NA" } @COLS);

5. The Column Order

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
keys %rec in this process : 5 key(s) in an order chosen per process
the declared column order : test,seed,verdict,errors,simtime
5 separate processes: MORE THAN ONE distinct key order
the declared order    : one, in every process, by construction

A header built from keys %hash can change between two runs of the same script (6.6). The file stays self-consistent — the header matches its rows — so nothing looks broken. Every consumer that reads by column position rather than by name then reads the wrong column, and a consumer that reads by name works fine, which is how this survives for months before anyone notices.

Declare the columns once, as a list, and use that list for both the header and every row:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my @COLS = qw(test seed verdict errors simtime);

The declared order is also the only thing that makes the file diffable night over night, which Chapter 24.11 depends on.

6. The Real File

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rows written: 36 data + 1 header
  test,seed,verdict,errors,simtime
  arb_light,7,FAIL,1,325
rows whose field count differs from the header: 0 of 37

That last line is the cheapest possible self-check and worth keeping in the writer: every row must parse back to the same number of fields as the header. It costs one pass, it catches an unquoted comma immediately, and it is the check that would have caught every one of the three data-losing writers in section 2.

7. What To Remember

  • csv_field: undef becomes empty, quote a field containing ", , or newline, and double an inner quote. /r returns the modified copy (11.14).
  • Round-trip a ROW, not a field. A field on its own cannot show a newline breaking the record — the field-level version passed two of four broken writers.
  • Measured: 14 of 15 vectors intact, 0 lost, 1 valid multi-line record.
  • A quoted newline is correct CSV that a line-oriented reader cannot consume — replace newlines before writing if the consumer reads lines.
  • Measured: three broken writers lost 3, 2 and 5 fields; the fourth lost nothing and warned twice.
  • The gate is two conditions: no lost fields AND no warnings.
  • Count warnings with local $SIG{__WARN__} = sub { $warn++ } (14.2) — 2>/dev/null is how the fourth writer survives.
  • A lenient reader hides a broken writer — enforce RFC 4180's ban on a bare quote in an unquoted field.
  • Measured: split /,/ returned 7 fields from a correct 5-field row. The file is right and the reader is wrong.
  • Walk the line with \G and /gc (11.20), and keep the last unless /\G,/gc that preserves a trailing empty field.
  • Use Text::CSV in production; write one here only so the test has a reader it can mutate.
  • Measured: '' and undef produce identical rows. CSV cannot carry "empty" against "missing".
  • Write an explicit marker (NA) and keep it out of the numeric arithmetic (24.3).
  • Measured: keys %hash gave more than one column order across 5 processes (6.6) — declare @COLS and use it for the header and every row.
  • Self-check the file: every row must parse back to the header's field count. One pass, and it catches every data-losing writer above.

Chapter 24.9 assembles the table a nightly regression actually publishes. The columns are decided, the verdicts are decided, and what remains is the part that makes a table worth reading at all: a row order that does not change when nothing changed.

Where this fits

Part of the Perl curriculum.