Projects and Next Steps · Lesson 38 of 39
Project: Expense Tracker
This project feels like real software: a command-line app that records your spending, saves it to a file so nothing is lost when you close it, and prints a report by category. It brings together the second half of the course – dataclasses, collections, files, JSON and exceptions – and makes a solid first entry for your GitHub portfolio.
What you will practise
- Modelling data with a
@dataclass - Saving and loading JSON with
pathlib - Grouping and totals with
defaultdict - A menu loop with clean, testable functions
The design
Three layers
- Interfacemenu loop: reads choices, prints results
- Logicadd expense, total, report by category
- Storageexpenses.json – load at start, save after changes
Keeping these layers separate means you could later swap the command line for a web page, or the JSON file for a database, without rewriting the logic.
Step 1: the data model
from dataclasses import dataclass, asdict
@dataclass
class Expense:
description: str
amount: float
category: str
e = Expense("Metro card", 500, "travel")
print(e)
print(asdict(e)) # ready for JSON
Expense(description='Metro card', amount=500, category='travel')
{'description': 'Metro card', 'amount': 500, 'category': 'travel'}Step 2: saving and loading
import json
from dataclasses import dataclass, asdict
from pathlib import Path
@dataclass
class Expense:
description: str
amount: float
category: str
DATA_FILE = Path("expenses.json")
def save(expenses):
DATA_FILE.write_text(json.dumps([asdict(e) for e in expenses], indent=2), encoding="utf-8")
def load():
if not DATA_FILE.exists():
return []
return [Expense(**item) for item in json.loads(DATA_FILE.read_text(encoding="utf-8"))]
save([Expense("Lunch", 180, "food"), Expense("Book", 450, "study")])
print(load())
[Expense(description='Lunch', amount=180, category='food'), Expense(description='Book', amount=450, category='study')]
Expense(**item) unpacks each dictionary straight into the dataclass – keyword-argument unpacking from chapter 9 at work.
Step 3: the report
from collections import defaultdict
def report(expenses):
by_category = defaultdict(float)
for e in expenses:
by_category[e["category"]] += e["amount"]
total = sum(by_category.values())
for category, amount in sorted(by_category.items(), key=lambda kv: kv[1], reverse=True):
share = amount / total
bar = "█" * round(share * 20)
print(f"{category:<10}₹{amount:>8,.2f} {share:>4.0%} {bar}")
print(f"{'TOTAL':<10}₹{total:>8,.2f}")
report([
{"category": "food", "amount": 2450},
{"category": "travel", "amount": 1200},
{"category": "food", "amount": 650},
{"category": "bills", "amount": 3400},
])
bills ₹3,400.00 44% █████████ food ₹3,100.00 40% ████████ travel ₹1,200.00 16% ███ TOTAL ₹7,700.00
Step 4: the complete app
Save as expenses.py and run python expenses.py:
"""Command-line expense tracker – LearnFrenzy Python course project."""
import json
from collections import defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path
DATA_FILE = Path("expenses.json")
CATEGORIES = ["food", "travel", "bills", "study", "other"]
@dataclass
class Expense:
description: str
amount: float
category: str
# ---------- storage ----------
def load_expenses() -> list[Expense]:
if not DATA_FILE.exists():
return []
try:
rows = json.loads(DATA_FILE.read_text(encoding="utf-8"))
return [Expense(**row) for row in rows]
except (json.JSONDecodeError, TypeError):
print("Warning: data file was damaged – starting fresh.")
return []
def save_expenses(expenses: list[Expense]) -> None:
DATA_FILE.write_text(json.dumps([asdict(e) for e in expenses], indent=2), encoding="utf-8")
# ---------- input helpers ----------
def ask_amount() -> float:
while True:
try:
amount = float(input("Amount (₹): "))
if amount > 0:
return round(amount, 2)
print(" Amount must be positive.")
except ValueError:
print(" Please enter a number, e.g. 249.50")
def ask_category() -> str:
options = ", ".join(CATEGORIES)
while True:
category = input(f"Category ({options}): ").strip().lower()
if category in CATEGORIES:
return category
print(" Unknown category.")
# ---------- features ----------
def add_expense(expenses: list[Expense]) -> None:
description = input("Description: ").strip() or "(no description)"
expenses.append(Expense(description, ask_amount(), ask_category()))
save_expenses(expenses)
print("Saved.")
def list_expenses(expenses: list[Expense]) -> None:
if not expenses:
print("No expenses yet.")
return
for number, e in enumerate(expenses, 1):
print(f"{number:>2}. {e.description:<18}{e.category:<8}₹{e.amount:>9,.2f}")
def show_report(expenses: list[Expense]) -> None:
totals = defaultdict(float)
for e in expenses:
totals[e.category] += e.amount
grand = sum(totals.values())
if not grand:
print("Nothing to report.")
return
for category, amount in sorted(totals.items(), key=lambda kv: kv[1], reverse=True):
print(f"{category:<8}₹{amount:>9,.2f} {amount / grand:>4.0%}")
print(f"{'TOTAL':<8}₹{grand:>9,.2f}")
def main() -> None:
expenses = load_expenses()
actions = {"1": add_expense, "2": list_expenses, "3": show_report}
while True:
choice = input("\n1) Add 2) List 3) Report 4) Quit > ").strip()
if choice == "4":
print("Goodbye!")
break
action = actions.get(choice)
if action:
action(expenses)
else:
print("Choose 1-4.")
if __name__ == "__main__":
main()
1) Add 2) List 3) Report 4) Quit > 1 Description: Train ticket Amount (₹): 640 Category (food, travel, bills, study, other): travel Saved. 1) Add 2) List 3) Report 4) Quit > 1 Description: Groceries Amount (₹): abc Please enter a number, e.g. 249.50 Amount (₹): 1250.5 Category (food, travel, bills, study, other): food Saved. 1) Add 2) List 3) Report 4) Quit > 2 1. Train ticket travel ₹ 640.00 2. Groceries food ₹ 1,250.50 1) Add 2) List 3) Report 4) Quit > 3 food ₹ 1,250.50 66% travel ₹ 640.00 34% TOTAL ₹ 1,890.50 1) Add 2) List 3) Report 4) Quit > 4 Goodbye!
Notice the dispatch dictionary
actions = {"1": add_expense, ...} maps menu choices to functions. Adding a feature means writing one function and adding one line – no growing if/elif chain. Functions really are values.
Extend it yourself
Challenges
Add a
datefield (default: today) and a "this month only" report.Hint
Store dates as ISO text (
date.today().isoformat()) so they survive JSON, and filter withe.date.startswith("2026-11").Add "5) Delete" that removes an expense by its list number.
Hint
Reuse a validating number prompt, then
expenses.pop(number - 1)andsave_expenses(expenses).Export the data to
expenses.csvwithcsv.DictWriterso it opens in Excel.Hint
writer = csv.DictWriter(f, fieldnames=["description", "amount", "category"])thenwriter.writerows(asdict(e) for e in expenses); open the file withnewline=""andencoding="utf-8-sig".
Summary
- Separate interface, logic and storage – each can change independently.
- Dataclasses +
asdict()+Expense(**row)make JSON persistence simple. - Validate every input, handle a damaged file, and map menu choices to functions.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.