LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
PerlIntermediate~2 hours

CSV Report Generator

Read a CSV export and produce a grouped, formatted summary report.

String FormattingSortingModules

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.

What You'll Build
  • 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 ones
while (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";
Example Row

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 category
foreach 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 category
foreach 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 ascending
foreach 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

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.