Just added: Algorithms you can run and practice
Updated Jul 16, 2026 Edit on GitHub

Formatting the Report

Time to put it all together. We have the grand totals from phase 2 and the per-group breakdown from phase 3. This phase turns those numbers into a single report that lines up cleanly, then wires the whole thing into one function - and shows you how to run it on a real CSV on your own machine.

Lining numbers up

Raw print() output looks ragged because names and numbers are different widths. Format specs fix that. Two you'll use constantly:

Spec Effect Example
{name:<10} left-align in 10 chars North
{value:>10.2f} right-align in 10 chars, 2 decimals 642.50

Left-align text, right-align numbers - that's the whole secret to a column that reads well. Numbers line up on their right edge so the decimal points stack.

A thousands separator helps too: {value:,.2f} prints 1884.5 as 1,884.50. Easier to read large totals at a glance.

The whole script

Everything from the last three phases - parse the rows, get the five numbers, group by category, line it up with the format specs above - becomes one function that returns the finished report as a string.

Your turn. This is the capstone: the function the rest of the project ships. Fill in build_report and hit Run. The checks underneath tell you if it works. My version, in full, is right after.

def build_report(rows, group_col="region", value_col="amount"):
    # Return the finished report as one string ("\n".join(lines)).
    # It must contain, in order:
    #   - a "SALES SUMMARY" header
    #   - "Rows:" followed by the row count
    #   - "Total amount:" followed by the sum, 2 decimals
    #   - "Average:" followed by the average, 2 decimals
    #   - "Largest sale:" followed by the biggest single value, 2 decimals
    #   - "By <group_col>:" followed by one line per group (sorted by
    #     key), each showing the group's name and its total
    pass


# --- checks: fix your function until this prints "All good." ---
sample_rows = [
    {"region": "North", "amount": "120.50"},
    {"region": "South", "amount": "89.00"},
    {"region": "North", "amount": "210.00"},
]
report = build_report(sample_rows)
assert isinstance(report, str), f"build_report should return a string, got: {type(report)}"
assert "SALES SUMMARY" in report, f"missing the header, got:\n{report}"
assert "Rows:" in report and "3" in report, f"row count (3) should show up, got:\n{report}"
assert "419.50" in report, f"total amount (419.50) should show up, got:\n{report}"
assert "139.83" in report, f"average (139.83) should show up, got:\n{report}"
assert "210.00" in report, f"largest sale (210.00) should show up, got:\n{report}"
lines = report.splitlines()
north_line = next((l for l in lines if "North" in l), "")
south_line = next((l for l in lines if "South" in l), "")
assert "330.50" in north_line, f"North's total (330.50) should be on its own line, got: {north_line!r}"
assert "89.00" in south_line, f"South's total (89.00) should be on its own line, got: {south_line!r}"
assert lines.index(north_line) < lines.index(south_line), "groups should be sorted alphabetically (North before South)"
print("All good.")

Nothing here is new work - every piece was built in a previous phase. Stuck? Build it in stages: get the five numbers printing first, then add the group loop, then worry about the exact wording of each line.

One way to write it

import csv
import io
from collections import defaultdict

CSV_TEXT = """date,region,product,amount
2026-01-03,North,Widget,120.50
2026-01-05,South,Gadget,89.00
2026-01-08,North,Gadget,210.00
2026-01-11,East,Widget,55.50
2026-01-14,South,Widget,134.00
2026-01-19,East,Gadget,410.00
2026-01-22,North,Widget,98.00
2026-01-25,South,Gadget,175.50"""


def build_report(rows, group_col="region", value_col="amount"):
    amounts = [float(r[value_col]) for r in rows]
    count = len(amounts)
    total = sum(amounts)
    average = total / count if count else 0.0
    largest = max(amounts) if amounts else 0.0

    group_totals = defaultdict(float)
    for r in rows:
        group_totals[r[group_col]] += float(r[value_col])

    lines = []
    lines.append("SALES SUMMARY")
    lines.append("=" * 13)
    lines.append(f"Rows:          {count}")
    lines.append(f"Total amount:  {total:>12,.2f}")
    lines.append(f"Average:       {average:>12,.2f}")
    lines.append(f"Largest sale:  {largest:>12,.2f}")
    lines.append("")
    lines.append(f"By {group_col}:")
    for key in sorted(group_totals):
        lines.append(f"  {key:<10} {group_totals[key]:>12,.2f}")

    return "\n".join(lines)


rows = list(csv.DictReader(io.StringIO(CSV_TEXT)))
report = build_report(rows)
print(report)

That's the project. One function takes the rows and gives you the formatted report as a string. Building the report into a list of lines and joining at the end (instead of printing as you go) means you can return it, write it to a file, or print it - your choice, decided by the caller.

Try changing build_report(rows) to build_report(rows, group_col="product") and rerun. Same report, broken out by product instead. That flexibility came free from passing the column name as an argument.

A quick sanity check

When you write aggregation code, check it against a number you can verify by hand. The per-group totals must add up to the grand total - if they don't, something's double-counting or dropping rows. Let's assert exactly that:

import csv
import io
from collections import defaultdict

CSV_TEXT = """date,region,product,amount
2026-01-03,North,Widget,120.50
2026-01-05,South,Gadget,89.00
2026-01-08,North,Gadget,210.00
2026-01-11,East,Widget,55.50"""

rows = list(csv.DictReader(io.StringIO(CSV_TEXT)))

grand = sum(float(r["amount"]) for r in rows)

group_totals = defaultdict(float)
for r in rows:
    group_totals[r["region"]] += float(r["amount"])
group_sum = sum(group_totals.values())

# Floats don't compare exactly; allow a tiny tolerance.
assert abs(grand - group_sum) < 0.001, "groups don't sum to the total!"
print(f"Grand total:     {grand:.2f}")
print(f"Sum of groups:   {group_sum:.2f}")
print("Check passed: the groups add up.")

The abs(...) < 0.001 is there because floats don't always compare exactly - 0.1 + 0.2 famously isn't 0.3 to a computer. Comparing with a small tolerance instead of == is the right habit for any money math.

Running it on a real file

Everything so far ran in the browser using StringIO. On your own machine you read an actual file instead - and that's the only line that changes. The rest of build_report is identical.

You don't need to install anything; csv ships with Python. Save this as report.py next to your CSV, then run python report.py sales.csv:

import csv
import sys
from collections import defaultdict


def build_report(rows, group_col="region", value_col="amount"):
    amounts = [float(r[value_col]) for r in rows]
    count = len(amounts)
    total = sum(amounts)
    average = total / count if count else 0.0
    largest = max(amounts) if amounts else 0.0

    group_totals = defaultdict(float)
    for r in rows:
        group_totals[r[group_col]] += float(r[value_col])

    lines = ["SALES SUMMARY", "=" * 13]
    lines.append(f"Rows:          {count}")
    lines.append(f"Total amount:  {total:>12,.2f}")
    lines.append(f"Average:       {average:>12,.2f}")
    lines.append(f"Largest sale:  {largest:>12,.2f}")
    lines.append("")
    lines.append(f"By {group_col}:")
    for key in sorted(group_totals):
        lines.append(f"  {key:<10} {group_totals[key]:>12,.2f}")
    return "\n".join(lines)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else "sales.csv"
    with open(path, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    report = build_report(rows)
    print(report)

    # Also save it next to the input.
    with open("report.txt", "w", encoding="utf-8") as out:
        out.write(report)
    print("\nSaved to report.txt")


if __name__ == "__main__":
    main()

Run it from a terminal:

python report.py sales.csv

Three things to notice about the file version:

  • open(path, newline="", encoding="utf-8") - the newline="" is the one csv quirk worth remembering. It stops Python from mangling line endings on Windows; the csv docs ask for it every time you open a CSV file. encoding="utf-8" handles names with accents.
  • sys.argv[1] lets you pass the filename on the command line, with sales.csv as a fallback. No argument-parsing library needed.
  • We write the report to report.txt as well as printing it, so you've got a file to email. Same string, two destinations.

What you built

A working CSV summary tool. It reads rows, totals and averages a numeric column, breaks the total out by any category, and prints a clean aligned report - from an embedded sample in the browser, or from a real file on your machine with one changed line.

The shape you learned here - parse to dicts, convert to numbers, accumulate into a defaultdict, format with alignment specs - is the backbone of nearly every quick data script you'll write. Next time someone asks "what's the total per X?", you won't open a spreadsheet. You'll write ten lines and have the answer.