Lesson 20: Final Project — Build a Personal Finance Tracker
🎧 Listen to this lesson
The final project assembles everything — classes, files, JSON, validation, summaries, tests — into a personal finance tracker: record expenses with categories and notes, list them, summarise spending by category, check budgets, and export to CSV for a spreadsheet. It is deliberately the same architecture as Lesson 11's planner with richer data, because recognising that shape in a new problem is the real graduation skill of this course.
The staged build
Professionals build in working stages, never all at once: Stage 1, the data model and totals in memory (test immediately). Stage 2, persistence with JSON. Stage 3, the menu interface. Stage 4, reports and budgets. Stage 5, tests and polish. Each stage ends with something that runs — so bugs are born in small rooms, not mansions. Stage 1 uses the class design from Lesson 10:
class Expense:
"""One expense: an amount, a category, and an optional note."""
def __init__(self, amount, category, note=""):
self.amount = amount
self.category = category
self.note = note
def __str__(self):
return f"${self.amount:.2f} - {self.category}"
expenses = [Expense(12.50, "Food"), Expense(8.00, "Food"), Expense(5.25, "Transport")]
def total_by_category(expenses):
totals = {}
for e in expenses:
totals[e.category] = totals.get(e.category, 0) + e.amount
return totals
print(total_by_category(expenses))
print("Total spent:", sum(e.amount for e in expenses))
Output:
{'Food': 20.5, 'Transport': 5.25}
Total spent: 25.75
The class gives records names instead of positional indices (e.category cannot be confused with e.amount the way tuple positions can), __str__ (Lesson 16) makes any expense print readably, and the counting-pattern accumulation produces the core summary. Stages 2–3 move to dictionaries for storage — JSON cannot store class instances directly, so the application layer keeps dicts as its schema (Lesson 11's lesson) and the program below is the result.
The complete tracker
Stages 2 and 3: load-or-fresh persistence, save after every change, validated input, one function per menu action:
import json
SAVE_FILE = "expenses.json"
def load_expenses():
"""Load expenses from disk; start empty on first run or a bad file."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_expenses(expenses):
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses, amount, category, note=""):
if amount <= 0:
print("Amount must be positive.")
return
expenses.append({"amount": amount, "category": category, "note": note})
save_expenses(expenses)
print(f"Added ${amount:.2f} to {category}.")
def list_expenses(expenses):
if not expenses:
print("No expenses yet.")
return
for i, e in enumerate(expenses, 1):
print(f"{i}. ${e['amount']:.2f} - {e['category']} {e['note']}".rstrip())
def show_summary(expenses):
totals = {}
for e in expenses:
totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
for category, total in totals.items():
print(f"{category}: ${total:.2f}")
print(f"TOTAL: ${sum(totals.values()):.2f}")
def main():
expenses = load_expenses()
print(f"Loaded {len(expenses)} expense(s).")
while True:
print("\n1) Add 2) List 3) Summary 4) Quit")
choice = input("> ").strip()
if choice == "1":
try:
amount = float(input("Amount: "))
except ValueError:
print("Please enter a number.")
continue
add_expense(expenses, amount, input("Category: "), input("Note (optional): "))
elif choice == "2":
list_expenses(expenses)
elif choice == "3":
show_summary(expenses)
elif choice == "4":
print("Saved. Spend wisely!")
break
else:
print("Pick 1-4.")
if __name__ == "__main__":
main()
Sample run:
Loaded 0 expense(s).
1) Add 2) List 3) Summary 4) Quit
> 2
No expenses yet.
1) Add 2) List 3) Summary 4) Quit
> 1
Amount: 12.50
Category: Food
Note (optional): Groceries
Added $12.50 to Food.
1) Add 2) List 3) Summary 4) Quit
> 1
Amount: 5.25
Category: Transport
Note (optional):
Added $5.25 to Transport.
1) Add 2) List 3) Summary 4) Quit
> 3
Food: $12.50
Transport: $5.25
TOTAL: $17.75
1) Add 2) List 3) Summary 4) Quit
> 2
1. $12.50 - Food Groceries
2. $5.25 - Transport
1) Add 2) List 3) Summary 4) Quit
> 4
Saved. Spend wisely!
Study the damage-proofing layered into ordinary-looking code: a non-numeric amount is rejected inside the menu *before* it can reach add_expense (with continue restarting the loop), and add_expense validates again anyway — defence in depth, because other callers (Lesson 14's tracker exceptions were the stricter cousin of this idea) cannot be trusted to pre-check. The summary's money formatting (:.2f) happens only at display, so stored values never accumulate rounding error. And the empty-note listing uses .rstrip() to trim the dangling space — polish is a habit, not a phase.
Stage 4 — budgets and export
Reports turn records into decisions. A budget check compares category totals against limits, and a CSV export (Lesson 13) hands the data to spreadsheets:
import csv
BUDGETS = {"Food": 50.00, "Transport": 30.00}
def check_budget(expenses, budgets=BUDGETS):
totals = {}
for e in expenses:
totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
for category, limit in budgets.items():
spent = totals.get(category, 0)
status = "OK" if spent <= limit else "WARNING - over budget!"
print(f"{category}: spent ${spent:.2f} of ${limit:.2f} - {status}")
def export_csv(expenses, path="expenses.csv"):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["amount", "category", "note"])
for e in expenses:
writer.writerow([e["amount"], e["category"], e["note"]])
print(f"Exported {len(expenses)} expense(s) to {path}")
demo = [
{"amount": 42.50, "category": "Food", "note": "Weekly shop"},
{"amount": 15.00, "category": "Food", "note": "Snacks"},
{"amount": 5.25, "category": "Transport", "note": "Bus pass"},
]
check_budget(demo)
export_csv(demo)
Output:
Food: spent $57.50 of $50.00 - WARNING - over budget!
Transport: spent $5.25 of $30.00 - OK
Exported 3 expense(s) to expenses.csv
Food's two entries sum to $57.50 against a $50 budget — the warning fires on the *accumulated* total, which is the whole point of tracking. check_budget iterates the *budgets*, not the spending, so a category with no spending still reports ($0 against its limit, via the .get(category, 0) default). The export writes a header row then one row per expense in the schema's field order; open the resulting file in a spreadsheet and Stage 4's promise — your data, portable — is visibly kept.
Stage 5 — tests, then done
Close the project the Lesson 17 way, with tests pinning the core calculation before you call it finished:
sample = [Expense(10.0, "Food"), Expense(2.5, "Food"), Expense(5.0, "Transport")]
assert total_by_category(sample) == {"Food": 12.5, "Transport": 5.0}
assert total_by_category([]) == {}
print("All finance tracker tests passed.")
Output: All finance tracker tests passed. — the float sums here are exactly representable (10.0 + 2.5 = 12.5), which is why direct equality is safe *in this test*; production money code often stores integer cents precisely to keep that property everywhere, a design note worth carrying into real projects.
Common mistakes
- Building the whole app before running anything. Stage discipline exists because a hundred untested lines hide bugs in layers; run after every stage and the bug is always in the newest few lines.
- Storing money as loose floats in serious code. 0.1 + 0.2 is not exactly 0.3 in binary floating point; over thousands of entries the drift becomes visible. Integer cents (or the decimal module) keep money exact. This project formats floats at display only, and its tests chose exactly-representable values — know *why* both choices were made.
- Two sources of truth. Editing expenses in the menu but summarising from a stale copy read at startup would desynchronise the app. One list, mutated through functions that save immediately, keeps every view honest.
- Calling it done without the edge cases. Empty list, first run without a file, zero and negative amounts, a category missing from budgets — this project handles each deliberately. Enumerate yours before your users enumerate them for you.
Real-World Challenge
The task. Reuse the architecture for a different goal: a savings-goal tracker. One goal (a target amount), deposits that must be positive, JSON persistence after every deposit, and a progress report as a percentage.
Worked solution.
import json
SAVE_FILE = "goal.json"
def load_goal():
"""Load the goal; return a fresh one on first run or a bad file."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {"target": 0, "saved": 0}
def deposit(goal, amount):
if amount <= 0:
print("Deposit must be positive.")
return
goal["saved"] += amount
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(goal, f, indent=2)
print(f"Saved ${amount:.2f}. Total: ${goal['saved']:.2f}")
def progress(goal):
if goal["target"] == 0:
return "No goal set."
pct = goal["saved"] / goal["target"] * 100
return f"{pct:.0f}% of ${goal['target']:.2f} goal"
goal = load_goal()
goal["target"] = 120
deposit(goal, 30)
deposit(goal, 45)
print(progress(goal))
Output:
Saved $30.00. Total: $30.00
Saved $45.00. Total: $75.00
62% of $120.00 goal
How it works. Nothing here is new, and that is the achievement this final challenge is certifying: the load-or-fresh pattern from Lessons 6/11/13, save-on-every-change, validation at the door (a zero or negative deposit is refused without touching the file), display-only formatting, and a percentage computed from stored exact values — 75 of 120 is 62.5%, shown as 62% by the :.0f format (standard rounding of.5 follows the round-half-even rule you met in Lesson 3). The demo at the bottom stands in for a menu exactly as Lesson 11's habit tracker did; wrap the functions in a loop and not one of them changes. One course ago this program was unimaginable to write; now its design decisions are ones you can name, defend and vary. That is the foundation everything else — Advanced Python, data science, web development — builds on.
Extension. Generalise to named goals (a dictionary of goals in one file: "Laptop", "Trip", "Emergency fund"), each with its own target and saved total, and a report printing every goal's percentage with a Lesson 19 text bar scaled to 100%. Then protect it with Lesson 17 tests: deposit refuses negatives, progress at exactly the target reads 100%, and loading a missing file starts fresh.
Going further — grouping by month, and where to go next
One realistic report finishes the architecture tour: monthly totals, produced by filtering records whose ISO date string starts with the target month — the chronological-string property doing its final shift of the course:
def monthly_total(expenses, month):
"""Sum expenses whose ISO date starts with YYYY-MM."""
return sum(e["amount"] for e in expenses if e["date"].startswith(month))
dated = [
{"amount": 10.0, "date": "2026-10-03"},
{"amount": 5.0, "date": "2026-10-20"},
{"amount": 7.5, "date": "2026-11-01"},
]
print(monthly_total(dated, "2026-10"))
Output: 15.0 — October's two records, November's correctly excluded by a prefix test that only works because the schema stores ISO dates. Adding the date field to the live tracker is this lesson's Try it exercise precisely because schema changes are the real test of an architecture: add the field at the single creation point, and every downstream function keeps working. From here the paths branch exactly as the portal's other courses do — Advanced Python for the language's deeper machinery, Data Science for statistics beyond summaries, Web Development for trackers other people can open in a browser. Each starts from the habits certified in this project: stage the build, guard the doors, save on every change, and finish with tests.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Build a Python finance tracker — full project tutorial — pick a recent, well-reviewed video
- Tech With Tim: Python portfolio project from scratch — pick a recent, well-reviewed video
- freeCodeCamp: complete a Python capstone project — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- CSV export — Implement
export_csvfrom the Applied Track and open the result in a spreadsheet app.Hint
Usecsv.DictWriterwithfieldnames=["date", "category", "description", "amount"]. - Monthly report — Add a menu option showing only this month's expenses and the month's total.
Hint
Filter withe["date"].startswith("2026-10")(adjust to the current month). - Budget alerts — Let the user set a monthly budget per category and warn when spending passes 90% of it.
Hint
Reuse thecheck_budgetpattern: sum amounts per category, compare to the limit.
🌍 Real World
Budgeting apps like Mint and YNAB are your finance tracker at scale: the same expense objects, category totals, file/database persistence, and charts — plus bank connections and a polished interface. This project is portfolio-ready: it demonstrates classes, files, testing, and UI in one program, which is exactly what employers and colleges look for.
Key vocabulary
- Program
- A sequence of instructions a computer executes.
- String
- Text data in quotes, e.g. "hello"; type str.
- Variable
- A named reference to a value, e.g. score = 0.
- Function
- A named, reusable block of code defined with def; takes parameters, returns values.
- Parameter / Argument
- A parameter is the placeholder in a definition; an argument is the value passed in a call.
- Return value
- The output a function hands back via return (vs. merely printing).
- Scope
- The region of code where a variable name is visible; locals live inside their function.
- Conditional
- if/elif/else branching that runs code based on True/False tests.
- Loop
- for/while repetition; break exits, continue skips ahead.
- List
- An ordered, mutable collection: [1, 2, 3]; indexed from 0.
- Dictionary
- Key-to-value mapping: {"a": 1}; fast lookup by key.
- Tuple
- An immutable ordered collection, e.g. (3, 4).
- Module
- A Python file of reusable code; imported with import, e.g. math, random.
- Bug / Debugging
- An error in code; the systematic process of finding and fixing it.
Audio lesson
A narrated walkthrough of this course's key ideas — great for review on the go.
Quizzes & Exams
Check your understanding
Adaptive test
15 questions that adapt to your level.
Homework
Interactive Lab
Hands-on tools for this course. Everything runs right here in your browser — drag, slide, click, and experiment.
Further Study on YouTube
These free videos from trusted educators go deeper on this course's topics.
- CS50x - Lecture 6 - Python — CS50 Harvard
Python functions, data types, object-oriented programming, lists, dictionaries, and CSV. - Learn Python - Full Course for Beginners [Tutorial] — freeCodeCamp
Full core Python course with calculator, guessing game, and translator projects. - Python Tutorial for Beginners 1: Install and Setup for Mac and Windows — Corey Schafer
Install Python, use the interactive prompt, and run a first script. - Harvard CS50 (2026) - Full Computer Science University Course — freeCodeCamp (CS50 Harvard)
The full CS50 course, including its Python lecture and data structures. - CS50x - Lecture 3 - Algorithms — CS50 Harvard
Searching, sorting, running time, and recursion - thinking behind the code.