Skip to main content

Step-by-step tutorial

Python Tutorial: Build a Command-Line Expense Tracker

Build a useful command-line program with a clear data model, pure calculations, validated input, safe CSV persistence, and focused tests.

Pythonbeginner4 min readProgramming languages

Learning overview

Estimated time
4 minutes
Difficulty
Beginner
Prerequisites
No prior experience required
Learning outcome
Explain python using a clear mental model · Apply python in practical Python work
Last updated
July 18, 2026

What you will build and learn

The tracker records a date, category, description, and positive amount. It can list records and summarize spending by category. Keep parsing, validation, calculations, and file access separate so each part can be tested.

  • Model records with dictionaries
  • Validate text, dates, and decimal amounts
  • Read and write CSV with context managers
  • Test calculations without touching the filesystem

Step 1: Define and validate an expense

Decimal avoids binary floating-point surprises for money. ISO dates sort naturally and are easy to validate with date.fromisoformat.

expenses.py
from datetime import date
from decimal import Decimal, InvalidOperation


def create_expense(date_text: str, category: str, description: str, amount_text: str) -> dict:
    spent_on = date.fromisoformat(date_text)
    amount = Decimal(amount_text)
    if amount <= 0:
        raise ValueError("Amount must be positive")
    category = category.strip()
    if not category:
        raise ValueError("Category is required")
    return {
        "date": spent_on.isoformat(),
        "category": category,
        "description": description.strip(),
        "amount": amount,
    }

Step 2: Write a pure summary function

The function accepts records and returns new data without printing or reading files. That makes success, empty input, and repeated categories straightforward to test.

summary.py
from collections import defaultdict
from decimal import Decimal


def totals_by_category(expenses: list[dict]) -> dict[str, Decimal]:
    totals: defaultdict[str, Decimal] = defaultdict(Decimal)
    for expense in expenses:
        totals[expense["category"]] += expense["amount"]
    return dict(sorted(totals.items()))

Step 3: Append records to CSV

Pathlib represents paths consistently. Open with newline="" as recommended by the csv module and specify UTF-8 explicitly. Write the header only when the file is new or empty.

storage.py
import csv
from pathlib import Path

FIELDS = ["date", "category", "description", "amount"]


def append_expense(path: Path, expense: dict) -> None:
    needs_header = not path.exists() or path.stat().st_size == 0
    with path.open("a", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDS)
        if needs_header:
            writer.writeheader()
        writer.writerow({**expense, "amount": str(expense["amount"])})

Step 4: Load and parse saved records

Convert amounts at the storage boundary so the rest of the application consistently receives Decimal values. Let malformed rows produce a useful error instead of silently corrupting totals.

load.py
import csv
from decimal import Decimal
from pathlib import Path


def load_expenses(path: Path) -> list[dict]:
    if not path.exists():
        return []
    with path.open(encoding="utf-8", newline="") as file:
        return [
            {**row, "amount": Decimal(row["amount"])}
            for row in csv.DictReader(file)
        ]

Step 5: Keep the command interface thin

Use argparse to parse commands, call the focused functions, and translate expected validation errors into concise messages. Avoid placing calculation or CSV details in the command handler.

  • add DATE CATEGORY AMOUNT --description TEXT
  • list
  • summary
  • Exit non-zero for invalid commands or data

Step 6: Test and extend safely

Test create_expense and totals_by_category with pytest. Use a temporary path for storage tests. After the core path is stable, add month filtering, budgets, JSON export, or a SQLite repository behind the same function boundaries.

test_summary.py
from decimal import Decimal
from expenses import totals_by_category


def test_totals_by_category_combines_matching_categories():
    expenses = [
        {"category": "Food", "amount": Decimal("4.50")},
        {"category": "Food", "amount": Decimal("5.50")},
    ]
    assert totals_by_category(expenses) == {"Food": Decimal("10.00")}

Frequently asked questions

Why use Decimal instead of float for expenses?

Decimal represents decimal quantities according to explicit decimal arithmetic, avoiding common binary floating-point representation surprises in financial calculations.

Why separate file access from calculations?

Pure calculations are fast and deterministic to test. Storage can then change from CSV to SQLite without rewriting the business rules.

Should invalid CSV rows be skipped?

Not silently. Report the row and reason, or provide an explicit tolerant import mode, so users know their totals may be incomplete.

Topic graph

A taxonomy-generated path through this subject.

  1. Python
  2. Programming languages
  3. python
  4. tutorial
  5. csv
  6. testing
  7. What Is Python in Programming?
  8. Learn Python: From Fundamentals to Real Applications
  9. Java Tutorial: Build a Student Grade Calculator

Recommended automatically from shared technologies, topics, intent, and difficulty.

Hand-picked companion pages that deepen this topic.

Your next steps

Continue learning

  1. 1glossaryWhat Is Python in Programming?
  2. 2guidesLearn Python: From Fundamentals to Real Applications
  3. 3guidesJava Tutorial: Build a Student Grade Calculator
  4. 4guidesJavaScript Tutorial: Build a Filterable Task List