ZetCode

Perl Text::CSV

last modified November 7, 2026

CSV, comma-separated values, is the most common format for exchanging tabular data, and it looks deceptively simple. Real CSV files contain fields with commas, quotes, and line breaks, which are protected by double quotes as described in RFC 4180. Text::CSV parses and produces such files correctly. It is a front end that uses the fast C implementation Text::CSV_XS when it is installed and falls back to the pure-Perl Text::CSV_PP otherwise; both have the same interface.

The modules are installed with cpanm Text::CSV Text::CSV_XS. The examples were run with Perl 5.44, Text::CSV 2.06, and Text::CSV_XS 1.65 on Linux. Most of them read the following file.

customers.csv
id,name,city,note,amount
1,Jana Kovac,Bratislava,regular customer,120.50
2,"Novak, Peter",Kosice,"says ""hi"" every time",75.00
3,Eva Horvath,Zilina,"two-line
note",310.25
4,Tomas Balaz,Nitra,,0

The second record has a name with a comma and a note with quotes, which are doubled inside the quoted field. The note of the third record spans two lines, and the fourth record has an empty note.

Why split is not enough

The first idea is often to read the file line by line and split each line on commas.

split.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

open my $fh, '<', 'customers.csv' or die "customers.csv: $!";

while (my $line = <$fh>) {
    chomp $line;
    my @fields = split /,/, $line;
    say scalar(@fields), ' fields: ', join ' | ', @fields;
}

The simple records are fine, but the second record falls apart into six fields with the quotes left in, and the multi-line record is split into two broken lines. Handling quoting with regular expressions quickly becomes complicated and is easy to get wrong; a real parser is the safer choice.

$ perl split.pl
5 fields: id | name | city | note | amount
5 fields: 1 | Jana Kovac | Bratislava | regular customer | 120.50
6 fields: 2 | "Novak |  Peter" | Kosice | "says ""hi"" every time" | 75.00
4 fields: 3 | Eva Horvath | Zilina | "two-line
2 fields: note" | 310.25
5 fields: 4 | Tomas Balaz | Nitra |  | 0

Reading records with getline

The getline method reads one record from a file handle and returns a reference to an array of fields, or undef at the end of the file.

read.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;

my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 });

open my $fh, '<:encoding(UTF-8)', 'customers.csv' or die "customers.csv: $!";

while (my $row = $csv->getline($fh)) {
    say scalar(@$row), ' fields: ', join ' | ', map { s/\n/\\n/gr } @$row;
}

close $fh;

The binary option allows non-ASCII characters and line breaks inside quoted fields; it should always be set. With auto_diag, the parser reports errors as warnings, so problems do not go unnoticed. Every record now has five fields, the quotes are removed, doubled quotes are restored to single ones, and the two-line note is a single field; we print its newline as \n. The file is opened with an encoding layer, so the fields are decoded text.

$ perl read.pl
5 fields: id | name | city | note | amount
5 fields: 1 | Jana Kovac | Bratislava | regular customer | 120.50
5 fields: 2 | Novak, Peter | Kosice | says "hi" every time | 75.00
5 fields: 3 | Eva Horvath | Zilina | two-line\nnote | 310.25
5 fields: 4 | Tomas Balaz | Nitra |  | 0

Headers and getline_hr

Most CSV files start with a header line. The header method reads it and stores the column names, after which getline_hr returns each record as a hash reference.

header.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;

my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 });

open my $fh, '<:encoding(UTF-8)', 'customers.csv' or die "customers.csv: $!";

my @cols = $csv->header($fh);
say "columns: @cols";

my $total = 0;

while (my $r = $csv->getline_hr($fh)) {
    printf "%-12s %-11s %8.2f\n", $r->{name}, $r->{city}, $r->{amount};
    $total += $r->{amount};
}

printf "%-24s %8.2f\n", 'total', $total;

The header method returns the column names; by default it lowercases them. It also detects the separator from a set of candidates and skips a byte order mark at the start of the file. Accessing fields by name makes the code independent of the column order and much easier to read than numeric indexes.

$ perl header.pl
columns: id name city note amount
Jana Kovac   Bratislava    120.50
Novak, Peter Kosice         75.00
Eva Horvath  Zilina        310.25
Tomas Balaz  Nitra           0.00
total                      505.75

Writing CSV

The print method writes an array reference as one record. It quotes and escapes fields only where necessary.

write.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use utf8;
use open qw(:std :encoding(UTF-8));
use Text::CSV;

my @rows = (
    ['product', 'size', 'price', 'comment'],
    ['T-shirt', 'M', 12.9, 'cotton, organic'],
    ['Mug', '0.3 l', 6.5, 'the "classic" one'],
    ['Poster', 'A2', 9, ''],
    ['Café sign', undef, 24, "hand-made\nin Brno"],
);

my $csv = Text::CSV->new({ binary => 1, auto_diag => 1, eol => "\n" });

open my $fh, '>:encoding(UTF-8)', 'products.csv' or die "products.csv: $!";
$csv->print($fh, $_) for @rows;
close $fh or die "products.csv: $!";

open $fh, '<:encoding(UTF-8)', 'products.csv' or die "products.csv: $!";
print while <$fh>;

The eol option adds a line ending after each record; without it, print writes no newline. The field with a comma is quoted, and the quotes in the "classic" one are doubled. By default Text::CSV_XS also quotes fields that contain spaces, such as 0.3 l and Café sign, which is valid and harmless; the quote_space option turns this off. Both the empty string and undef are written as empty fields, and the field with a newline is quoted and keeps its line break. The source contains the non-ASCII character é, so the program declares use utf8, and the open pragma sets UTF-8 on the standard streams. Closing the handle is checked, because a full disk is often reported only at that point.

$ perl write.pl
product,size,price,comment
T-shirt,M,12.9,"cotton, organic"
Mug,"0.3 l",6.5,"the ""classic"" one"
Poster,A2,9,
"Café sign",,24,"hand-made
in Brno"

Separators and quoting options

Not every CSV file uses commas. Spreadsheets in many European locales write semicolons, and tab-separated files are common too. The combine method builds a single record in memory, which is handy for comparing the options.

options.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;

my @row = ('Kosice', 3.14, 'a;b', 'say "hi"', '', undef);

my %variants = (
    '1 default'      => {},
    '2 semicolon'    => { sep_char => ';' },
    '3 tab'          => { sep_char => "\t" },
    '4 always_quote' => { always_quote => 1 },
    '5 quote_empty'  => { quote_empty => 1 },
    '6 escape'       => { escape_char => '\\' },
);

for my $name (sort keys %variants) {
    my $csv = Text::CSV->new({ binary => 1, %{ $variants{$name} } });
    $csv->combine(@row) or die $csv->error_diag;
    printf "%-15s %s\n", $name, $csv->string =~ s/\t/<TAB>/gr;
}

The combine method joins the fields and string returns the result; we show tabs as <TAB>. The sep_char option sets the separator, and a field containing it is quoted, so a;b is quoted only in the semicolon variant. always_quote quotes every defined field, and quote_empty quotes empty strings, which makes it possible to tell an empty string from undef in the output. The escape_char option switches from doubled quotes to backslash escapes, a style that some databases produce.

$ perl options.pl
1 default       Kosice,3.14,a;b,"say ""hi""",,
2 semicolon     Kosice;3.14;"a;b";"say ""hi""";;
3 tab           Kosice<TAB>3.14<TAB>a;b<TAB>"say ""hi"""<TAB><TAB>
4 always_quote  "Kosice","3.14","a;b","say ""hi""","",
5 quote_empty   Kosice,3.14,a;b,"say ""hi""","",
6 escape        Kosice,3.14,a;b,"say \"hi\"",,

The csv function

For small and medium files, the csv function reads or writes a whole file in a single call.

func.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV qw(csv);

my $customers = csv(in => 'customers.csv', headers => 'auto', encoding => 'UTF-8');

say scalar(@$customers), ' records';
say "$_->{id}: $_->{name} ($_->{city})" for @$customers;

my $rich = csv(
    in       => 'customers.csv',
    headers  => 'auto',
    encoding => 'UTF-8',
    filter   => { amount => sub { $_ > 100 } },
);

csv(in => $rich, out => 'rich.csv', headers => [qw(id name amount)], encoding => 'UTF-8');

open my $fh, '<', 'rich.csv' or die "rich.csv: $!";
print while <$fh>;

With headers => 'auto', csv returns a reference to an array of hashes keyed by the header line. The filter option keeps only the records for which the callback returns true; inside it, $_ is the value of the named column. Given an array reference as in and a file name as out, the function writes CSV; the headers list selects the columns and their order.

$ perl func.pl
4 records
1: Jana Kovac (Bratislava)
2: Novak, Peter (Kosice)
3: Eva Horvath (Zilina)
4: Tomas Balaz (Nitra)
id,name,amount
1,"Jana Kovac",120.50
3,"Eva Horvath",310.25

Handling errors

A parse error stops getline, which then returns undef just as at the end of the file. The eof method tells the two cases apart. The sample file has a stray quote in an unquoted field.

bad.csv
id,name,score
1,Anna,90
2,Boris "Bo" Black,85
3,Clara,77

The program reads the file twice: once checking the error by hand, once letting the parser die.

errors.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;

my $csv = Text::CSV->new({ binary => 1 });

open my $fh, '<', 'bad.csv' or die "bad.csv: $!";

while (my $row = $csv->getline($fh)) {
    say join ' | ', @$row;
}

unless ($csv->eof) {
    my ($code, $message, $pos, undef, $field) = $csv->error_diag;
    say "error $code in field $field at position $pos: $message";
}

my $strict = Text::CSV->new({ binary => 1, auto_diag => 2 });
seek $fh, 0, 0;
eval { 1 while $strict->getline($fh) };
print "died: $@";

The first loop ends at the bad record. Because eof is false, the program asks error_diag for the details: in list context it returns the error code, the message, the position in the record, the record number, and the field number. With auto_diag => 2, the parser dies instead of warning, which suits batch jobs that must not continue with partial data. The message mentions CSV_XS, the backend doing the work. Loose quotes can be accepted with the allow_loose_quotes option, but it is usually better to fix the producer of the file.

$ perl errors.pl
id | name | score
1 | Anna | 90
error 2034 in field 2 at position 9: EIF - Loose unescaped quote
died: # CSV_XS ERROR: 2034 - EIF - Loose unescaped quote @ rec 2 pos 9 field 2

Generating a large file

Because getline reads one record at a time, a file of any size can be processed with constant memory. To test this, we first generate a file with a million sales records.

gen.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;

my $rows = shift // 1_000_000;
my @cities = ('Bratislava', 'Kosice', 'Zilina', 'Nitra', 'Presov', 'Banska Bystrica');

srand 1;

my $csv = Text::CSV->new({ binary => 1, auto_diag => 1, eol => "\n" });

open my $fh, '>', 'sales.csv' or die "sales.csv: $!";
$csv->print($fh, [qw(id date city item amount)]);

for my $id (1 .. $rows) {
    $csv->print($fh, [
        $id,
        sprintf('2026-%02d-%02d', 1 + int rand 12, 1 + int rand 28),
        $cities[rand @cities],
        "item, size " . (1 + int rand 5),
        sprintf('%.2f', rand 500),
    ]);
}

close $fh or die "sales.csv: $!";
printf "wrote %d rows, %.1f MB\n", $rows, (-s 'sales.csv') / 1e6;

The generator writes random dates, cities, items, and amounts. The item field contains a comma, so every record needs quoting.

$ perl gen.pl
wrote 1000000 rows, 49.0 MB

Streaming large files

The streaming version reads the records one by one and keeps only the running totals per city.

stream.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;
use Time::HiRes qw(time);

sub peak_mb {
    open my $st, '<', '/proc/self/status' or return 0;
    while (<$st>) { return $1 / 1024 if /^VmHWM:\s+(\d+)/ }
    return 0;
}

my $start = time;
my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 });

open my $fh, '<', 'sales.csv' or die "sales.csv: $!";
$csv->header($fh);

my %by_city;
my $count = 0;

while (my $r = $csv->getline_hr($fh)) {
    $by_city{ $r->{city} } += $r->{amount};
    $count++;
}

close $fh;

printf "%-16s %14.2f\n", $_, $by_city{$_}
    for sort { $by_city{$b} <=> $by_city{$a} } keys %by_city;

printf "%d rows in %.1f s, peak memory %.0f MB\n", $count, time - $start, peak_mb();

The peak_mb function reads the peak resident memory of the process, VmHWM, from the Linux /proc file system. The million records are processed in about two seconds with a peak of a few megabytes, the memory of the interpreter itself.

$ perl stream.pl
Banska Bystrica     41798989.04
Nitra               41718723.80
Presov              41608325.96
Zilina              41605567.63
Bratislava          41586261.99
Kosice              41554585.74
1000000 rows in 2.3 s, peak memory 8 MB

Loading the whole file

For comparison, the same computation with the csv function, which loads the whole file before processing it.

slurp.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV qw(csv);
use Time::HiRes qw(time);

sub peak_mb {
    open my $st, '<', '/proc/self/status' or return 0;
    while (<$st>) { return $1 / 1024 if /^VmHWM:\s+(\d+)/ }
    return 0;
}

my $start = time;
my $rows = csv(in => 'sales.csv', headers => 'auto');

my %by_city;
$by_city{ $_->{city} } += $_->{amount} for @$rows;

printf "%d rows in %.1f s, peak memory %.0f MB\n", scalar @$rows, time - $start, peak_mb();

The run takes about the same time, but the peak memory exceeds a gigabyte for a 49 MB file, because every record becomes a Perl hash with five keys. For large inputs, streaming is the only approach that scales.

$ perl slurp.pl
1000000 rows in 2.5 s, peak memory 1247 MB

Faster parsing with bind_columns

Creating a new hash or array for every record has its own cost. The bind_columns method makes the parser store the fields directly in existing variables.

fast.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Text::CSV;
use Time::HiRes qw(time);

my $start = time;
my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 });

open my $fh, '<', 'sales.csv' or die "sales.csv: $!";
$csv->getline($fh);

my ($id, $date, $city, $item, $amount);
$csv->bind_columns(\($id, $date, $city, $item, $amount));

my %by_city;
my $count = 0;

while ($csv->getline($fh)) {
    $by_city{$city} += $amount;
    $count++;
}

printf "%d rows in %.1f s using %s\n", $count, time - $start, Text::CSV->backend;

The method takes one reference per column, and the number of references must match the number of fields. After binding, getline fills the variables and returns a true value, so the loop tests it directly. The first call reads and discards the header line. Skipping the per-record data structures makes the run more than twice as fast as the getline_hr version. The backend class method confirms that the XS implementation is in use.

$ perl fast.pl
1000000 rows in 1.0 s using Text::CSV_XS

Best practices

In this article we have read CSV files with getline and getline_hr, written them with print, changed separators and quoting, used the csv function, handled parse errors, and streamed a file of a million records with constant memory.

Source

Text::CSV on MetaCPAN · Text::CSV_XS · RFC 4180

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Perl tutorials.