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
deleteoption that removes an expense by index and re-saves. - Store a date with each expense (Module 9 covers
datetime). - Add an
exportoption that writes a CSV report usingcsv.DictWriter. - Write to
expenses.tmpthen 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.