rust 77 lines · 3 tabs

Aggregate CSV Transaction Totals per Category With Serde and csv Crate

Shared by codesnips Jul 2026
3 tabs
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer};

#[derive(Debug, Clone, Deserialize)]
pub struct Transaction {
    #[serde(rename = "Date", deserialize_with = "parse_date")]
    pub date: NaiveDate,

    #[serde(rename = "Category")]
    pub category: String,

    #[serde(rename = "Description")]
    pub description: String,

    #[serde(rename = "Amount")]
    pub amount: Decimal,
}

fn parse_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = String::deserialize(deserializer)?;
    NaiveDate::parse_from_str(raw.trim(), "%Y-%m-%d").map_err(serde::de::Error::custom)
}
3 files · rust Explain with highlit

This snippet shows a focused, real-world Rust task: streaming a CSV of financial transactions off disk, deserializing each row into a typed struct, and folding the rows into per-category totals. It is split across three collaborating files so the concerns stay separated: the record type and its serde attributes, the aggregation logic that consumes an iterator of records, and the binary entry point that wires a csv::Reader to those pieces.

In transaction.rs, Transaction derives Deserialize so the csv crate can map header columns onto fields by name. The #[serde(rename)] attributes decouple the Rust field names from the exact CSV headers, which is important because CSV headers are frequently inconsistent with Rust naming conventions. Amounts are stored as rust_decimal::Decimal rather than f64; money must never be summed as binary floating point because rounding errors accumulate, and Decimal gives exact base-10 arithmetic. The custom deserialize_with on date parses an ISO date via chrono, turning a raw string column into a real NaiveDate during deserialization so downstream code never handles unparsed text.

In aggregate.rs, summarize accepts any Iterator<Item = Result<Transaction, csv::Error>>. Accepting an iterator rather than a Vec means the whole file is never buffered in memory — records are pulled and folded one at a time, so the memory cost is bounded regardless of file size. The function uses the ? operator inside the loop to propagate a malformed row as an error instead of silently skipping it, and it accumulates into a BTreeMap<String, Decimal> via the entry API. A BTreeMap is chosen over HashMap so the resulting categories come out in deterministic sorted order, which makes output stable and testable. Each entry(...).or_insert(Decimal::ZERO) either seeds a new category or adds to the running total in place.

In main.rs, ReaderBuilder opens the file with headers enabled, and reader.deserialize() yields the typed Result stream that summarize expects. The main function returns Result<(), Box<dyn Error>> so any I/O, parse, or decimal error short-circuits with a readable message rather than a panic. This pipeline is the pattern to reach for whenever tabular input must be validated, typed, and reduced: parsing failures surface loudly, money math stays exact, and the streaming shape keeps large files cheap to process.


Related snips

Share this code

Here's the card — post it anywhere.

Aggregate CSV Transaction Totals per Category With Serde and csv Crate — share card
Link copied