Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 9 of 9

Every project so far has forgotten everything on exit. Not this one. You'll build an expense tracker that loads its data on startup, saves after every change, and handles a missing or corrupt file without crashing.

The storage layer

Two functions do all the persistence. load_expenses() has to cope with three situations: the file doesn't exist yet (first run), the file is corrupt, or it's fine.

import json

DATA_FILE = "expenses.json"

def load_expenses():
    """Return the saved expenses, or an empty list if there are none."""
    try:
        with open(DATA_FILE) as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        print("Warning: data file is corrupt, starting fresh.")
        return []

def save_expenses(expenses):
    """Write expenses to disk."""
    with open(DATA_FILE, "w") as f:
        json.dump(expenses, f, indent=2)

save_expenses([{"description": "Coffee", "amount": 3.5, "category": "food"}])
print(load_expenses())

Note that a missing file is not an error here — it's just the first run, so an empty list is the right answer. A corrupt file is worth warning about but still shouldn't crash the program.

Validation

A custom exception makes bad input impossible to ignore, and lets the menu loop catch exactly this failure:

class InvalidExpenseError(Exception):
    """Raised when an expense fails validation."""

def make_expense(description, amount, category):
    if not description:
        raise InvalidExpenseError("Description is required")
    try:
        amount = float(amount)
    except ValueError:
        raise InvalidExpenseError(f"{amount!r} is not a number")
    if amount <= 0:
        raise InvalidExpenseError("Amount must be positive")
    return {
        "description": description,
        "amount": amount,
        "category": category or "uncategorised",
    }

print(make_expense("Coffee", "3.50", "food"))

for bad in [("", "5", "food"), ("Tea", "abc", "food"), ("Tea", "-2", "food")]:
    try:
        make_expense(bad[0], bad[1], bad[2])
    except InvalidExpenseError as e:
        print(f"Rejected: {e}")

Reporting

Plain dictionary work from Module 3:

def total(expenses):
    return sum([e["amount"] for e in expenses])

def by_category(expenses):
    totals = {}
    for e in expenses:
        totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
    return totals

expenses = [
    {"description": "Coffee", "amount": 3.5, "category": "food"},
    {"description": "Bus", "amount": 2.0, "category": "transport"},
    {"description": "Lunch", "amount": 8.5, "category": "food"},
]

print(total(expenses))
print(by_category(expenses))

The full program

Everything wired into a menu loop. This uses while True and a sentinel from Module 2, dictionaries and comprehensions from Module 3, functions from Module 4, a custom exception class from Module 5 and 6, and JSON persistence from this module.

import json

DATA_FILE = "expenses.json"

class InvalidExpenseError(Exception):
    """Raised when an expense fails validation."""

def load_expenses():
    try:
        with open(DATA_FILE) as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        print("Warning: data file is corrupt, starting fresh.")
        return []

def save_expenses(expenses):
    with open(DATA_FILE, "w") as f:
        json.dump(expenses, f, indent=2)

def make_expense(description, amount, category):
    if not description:
        raise InvalidExpenseError("Description is required")
    try:
        amount = float(amount)
    except ValueError:
        raise InvalidExpenseError(f"{amount!r} is not a number")
    if amount <= 0:
        raise InvalidExpenseError("Amount must be positive")
    return {"description": description, "amount": amount,
            "category": category or "uncategorised"}

def main():
    expenses = load_expenses()
    print(f"Loaded {len(expenses)} expenses.")

    while True:
        print("\n1) Add  2) List  3) Report  4) Quit")
        choice = input("Choose: ")

        if choice == "1":
            try:
                expense = make_expense(
                    input("Description: "),
                    input("Amount: "),
                    input("Category: "),
                )
            except InvalidExpenseError as e:
                print(f"Rejected: {e}")
            else:
                expenses.append(expense)
                save_expenses(expenses)
                print("Saved.")

        elif choice == "2":
            if not expenses:
                print("No expenses yet.")
            for e in expenses:
                print(f"{e['description']}: {e['amount']:.2f} ({e['category']})")

        elif choice == "3":
            totals = {}
            for e in expenses:
                totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
            for category, amount in sorted(totals.items()):
                print(f"{category}: {amount:.2f}")
            print(f"TOTAL: {sum([e['amount'] for e in expenses]):.2f}")

        elif choice == "4":
            print("Goodbye!")
            break

        else:
            print("Unknown option.")

main()

Two details worth pointing out. The try/except/else around make_expense puts only the risky call in try — the append-and-save happens in else, so a rejected expense never gets saved. And save_expenses is called after every change, so an unexpected exit loses nothing.

Try it yourself

This version runs the whole flow without prompts, so you can see persistence working: it saves, reloads from disk, and reports.

Take it further

  • Add a delete option that removes an expense by index and re-saves.
  • Store a date with each expense (Module 9 covers datetime).
  • Add an export option that writes a CSV report using csv.DictWriter.
  • Write to expenses.tmp then rename it, so a crash mid-write can't corrupt good data.

Module 6 complete

Your programs now have a memory. You can read and write text files with with, handle CSV and JSON, read a traceback properly, catch exceptions precisely, raise your own, and define custom exception classes for your program's failures.

That combination — persistence plus real error handling — is most of what separates a script from a tool someone else can rely on.

Next up: Module 7 — Modules, Packages & Environment, where your code stops being one long file.

Questions about this lesson

Log in to ask a question.