Overview
A CSV export from a spreadsheet or a database is one of the most common inputs a Perl script ever has to deal with, and turning it into a grouped, human-readable report touches three separate skills at once: parsing rows into structured data, grouping and aggregating that data with a hash, and formatting the result into aligned columns with `printf`. This project builds a small sales report generator that reads `sales.csv`, groups every row by category, and prints a per-category summary sorted by total revenue.
By the end of this tutorial you will have a script that loads `Text::CSV`, a well-tested module from CPAN, to parse each row correctly even when a field itself contains a comma or a quote — something a naive `split(',', $line)` gets wrong. You will group the parsed rows into a hash keyed by category, compute totals per group, sort both the groups and the rows within each group, and print it all with `printf`'s field-width formatting so every column lines up regardless of how long each product name or amount is.
- A CSV row parser built on `Text::CSV`, with a fallback note on why manual `split(',', ...)` is fragile.
- A loop that reads every row of `sales.csv` into an array of hashes, one hash per row.
- A grouping step that buckets rows into a hash of arrays keyed by category.
- A totals step that sums the revenue for each category using its own small hash.
- A `sort` of categories by total revenue, descending, and of rows within each category by amount.
- A `printf`-formatted report with fixed-width, right-aligned currency columns.
Prerequisites
- Arrays and hashes — including an array of hashes and a hash of arrays.
- File handling — reading a file line by line with `open` and a `while` loop.
- Using a CPAN module — `use Text::CSV;` and calling methods on an object with `->`.
- Sorting — `sort` with a custom comparison block (`sort { ... } @list`).
- String formatting — `printf`/`sprintf` with field-width and precision specifiers like `%-15s` and `%10.2f`.
Project Structure
The project is one script, `csv_report.pl`, plus the input file it reads, `sales.csv`, with columns `date,category,product,amount`. The script runs in five stages in order: parse every CSV row into an array of hashes, group those hashes into a hash of arrays keyed by category, total each category's amounts into a separate hash, sort both the categories and the rows inside them, and finally print everything through `printf`. Each stage's output is exactly the input the next stage expects, so the script can be read top to bottom as a small data pipeline.
This script depends on `Text::CSV`, which ships with many Perl installations via ActiveState/Strawberry Perl's bundled module set but is not part of the Perl core language itself; if it is missing, `cpan Text::CSV` (or `cpanm Text::CSV`) installs it. It is used here deliberately instead of `split(',', $line)` — Step 1 explains exactly why a hand-rolled comma split is the wrong tool for real-world CSV.
Step 1: Why a Real CSV Module Beats split(',')
The tempting shortcut is `my @fields = split(',', $line)`, and it works fine right up until a field itself legitimately contains a comma — for example a product name written as `"Widget, Deluxe"` in a spreadsheet, which the CSV format quotes specifically so the comma inside it is not treated as a field separator. A naive `split` has no concept of quoting: it would cut `"Widget, Deluxe"` into two fields, silently corrupting the row and shifting every column after it.
use Text::CSV; # A CPAN module purpose-built for correctly parsing CSV, quotes and all
# Why NOT to do this for real CSV data:# my @fields = split(',', $line);# This breaks the moment any field contains a comma inside quotes, e.g.:# 2026-08-01,Electronics,"Widget, Deluxe",149.99# split(',', ...) would produce SIX fields here instead of four, because it# has no idea the comma inside the quotes is part of the data, not a separator.
# Text::CSV handles quoting, embedded commas, and embedded quotes correctly.my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 }) # binary => 1 allows UTF-8 and embedded newlines in fields or die "Cannot use Text::CSV: " . Text::CSV->error_diag();Step 2: Read and Parse the CSV File
The first line of `sales.csv` is a header row (`date,category,product,amount`) rather than data, so it is read once with `getline()` and discarded before the main loop starts. `getline($fh)` returns an array reference of parsed field values for one row at a time, which this script immediately turns into a hash with `date`/`category`/`product`/`amount` keys — trading a positional array (`$row->[1]` for category) for a self-documenting hash (`$row->{category}`) makes every later step easier to read.
my $csv_file = 'sales.csv';open(my $fh, '<', $csv_file) or die "Could not open '$csv_file': $!\n";
my $header = $csv->getline($fh); # Read and discard the header row: date,category,product,amount
my @rows; # Array of hashes; one hash per data row, with named fields instead of positional oneswhile (my $fields = $csv->getline($fh)) { # getline() returns an array ref of fields, or undef at EOF my ($date, $category, $product, $amount) = @$fields; # @$fields dereferences the array ref push @rows, { date => $date, category => $category, product => $product, amount => $amount + 0, # "+ 0" forces numeric context so later sums/comparisons work correctly };}close($fh);
print "Parsed " . scalar(@rows) . " row(s) from $csv_file\n";Click Run to see what this code prints.
Step 3: Group Rows by Category
A hash of arrays is the mirror image of the hash of hashes from the Contact List Manager project: here, each key (a category name) maps to an array reference holding every row hash that belongs to it. `push @{ $by_category{$category} }, $row` autovivifies a fresh empty array reference for `$by_category{$category}` the first time a category is seen, then pushes onto it — no separate "does this key exist yet?" check is needed.
my %by_category; # category name => array ref of row hashes belonging to that categoryforeach my $row (@rows) { push @{ $by_category{$row->{category}} }, $row; # Autovivifies the array ref on first use per category}
print "Found " . scalar(keys %by_category) . " categor" . (keys %by_category == 1 ? "y" : "ies") . "\n";Step 4: Compute Per-Group Totals
With `%by_category` built, computing each category's total revenue is a straightforward reduction: loop over the category names, and for each one sum the `amount` field of every row in its array reference. Storing that total in a second hash, `%category_totals`, keeps this step's output separate and reusable rather than mutating `%by_category` in place.
my %category_totals; # category name => sum of amount across every row in that categoryforeach my $category (keys %by_category) { my $total = 0; $total += $_->{amount} foreach @{ $by_category{$category} }; # Statement modifier form of foreach $category_totals{$category} = $total;}Step 5: Sort Groups and Rows
Perl's `sort` with a `{ ... }` block lets you compare two elements, conventionally named `$a` and `$b`, and return a negative, zero, or positive number the way `<=>` (numeric comparison) does for numbers. Sorting categories by `$category_totals{$b} <=> $category_totals{$a}` (note `$b` first) puts the highest-revenue category first; sorting each category's rows by `$a->{amount} <=> $b->{amount}` puts its cheapest sale first.
# Categories ordered highest total revenue first ($b <=> $a, not $a <=> $b, for descending order)my @sorted_categories = sort { $category_totals{$b} <=> $category_totals{$a} } keys %category_totals;
# Within each category, sort that category's rows by amount ascendingforeach my $category (@sorted_categories) { my @sorted_rows = sort { $a->{amount} <=> $b->{amount} } @{ $by_category{$category} }; $by_category{$category} = \@sorted_rows; # Replace with the sorted version for Step 6 to print}Step 6: Format an Aligned Report With printf
`printf` and `sprintf` share the same format-string syntax as C: `%-15s` left-aligns a string in a 15-character field, and `%10.2f` right-aligns a floating-point number in a 10-character field with exactly two digits after the decimal point. Using the same field widths for every row and every category header is what makes the printed columns actually line up, no matter how short or long an individual product name or amount happens to be.
print "\n===== SALES REPORT BY CATEGORY =====\n";foreach my $category (@sorted_categories) { printf "\n%s (Total: $%.2f)\n", $category, $category_totals{$category}; printf " %-25s %10s\n", "Product", "Amount"; foreach my $row (@{ $by_category{$category} }) { printf " %-25s %10.2f\n", $row->{product}, $row->{amount}; }}
my $grand_total = 0;$grand_total += $_ foreach values %category_totals;printf "\n%-27s %10.2f\n", "GRAND TOTAL", $grand_total;Complete Code
Here is the full script assembled in the order it runs, ready to save as `csv_report.pl` and run with `perl csv_report.pl` alongside a `sales.csv` file with header `date,category,product,amount`.
use strict;use warnings;use Text::CSV;
my $csv = Text::CSV->new({ binary => 1, auto_diag => 1 }) or die "Cannot use Text::CSV: " . Text::CSV->error_diag();
my $csv_file = 'sales.csv';open(my $fh, '<', $csv_file) or die "Could not open '$csv_file': $!\n";
my $header = $csv->getline($fh); # Discard the header row
my @rows;while (my $fields = $csv->getline($fh)) { my ($date, $category, $product, $amount) = @$fields; push @rows, { date => $date, category => $category, product => $product, amount => $amount + 0, };}close($fh);
print "Parsed " . scalar(@rows) . " row(s) from $csv_file\n";
my %by_category;foreach my $row (@rows) { push @{ $by_category{$row->{category}} }, $row;}
print "Found " . scalar(keys %by_category) . " categor" . (keys %by_category == 1 ? "y" : "ies") . "\n";
my %category_totals;foreach my $category (keys %by_category) { my $total = 0; $total += $_->{amount} foreach @{ $by_category{$category} }; $category_totals{$category} = $total;}
my @sorted_categories = sort { $category_totals{$b} <=> $category_totals{$a} } keys %category_totals;
foreach my $category (@sorted_categories) { my @sorted_rows = sort { $a->{amount} <=> $b->{amount} } @{ $by_category{$category} }; $by_category{$category} = \@sorted_rows;}
print "\n===== SALES REPORT BY CATEGORY =====\n";foreach my $category (@sorted_categories) { printf "\n%s (Total: $%.2f)\n", $category, $category_totals{$category}; printf " %-25s %10s\n", "Product", "Amount"; foreach my $row (@{ $by_category{$category} }) { printf " %-25s %10.2f\n", $row->{product}, $row->{amount}; }}
my $grand_total = 0;$grand_total += $_ foreach values %category_totals;printf "\n%-27s %10.2f\n", "GRAND TOTAL", $grand_total;Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a second grouping level, by month (parsed from `date`), producing a category-then-month nested report.
- Write the report back out as its own CSV file with `Text::CSV`'s `print()` method instead of only printing to the console.
- Add a `--top N` command-line option that only prints the N highest-revenue categories.
- Compute and print each category's percentage of the grand total alongside its dollar total.
- Swap `Text::CSV` for `Text::CSV_XS` (a faster C-backed drop-in) and compare parsing speed on a large file.
Summary
You built a report generator that takes raw CSV rows through a five-stage pipeline: parse with `Text::CSV`, group into a hash of arrays, total with a second hash, sort both the groups and the rows within them, and format the result with `printf`'s field-width specifiers. Reaching for a real module instead of a hand-rolled `split(',', ...)` for CSV, and a hash of arrays instead of ad-hoc parallel arrays for grouping, are two of the most transferable habits from this project — they hold up the same way on a much larger, messier real-world dataset.