Lesson 25: Capstone — Build a CLI Expense Tracker
Bring everything together: functions, dicts, files, JSON, and a menu loop in one complete project.
The Capstone Project
You have learned variables, control flow, data structures, functions, OOP, files, errors, and JSON. Now we combine all of it into a real, usable program: a CLI Expense Tracker. This is the moment you stop being a beginner.
This project mirrors how real software is built: a small set of focused functions, persistence to disk, validation of user input, and a menu loop. If you can build this from memory, you are ready to start building your own projects.
What You'll Learn in This Lesson
- Design a small program's architecture before writing code
- Persist data to JSON so it survives restarts
- Validate user input with try/except
- Structure a menu-driven CLI application
Features We Build
| Feature | What it does | Skills used |
|---|---|---|
| Add | Add an expense (amount + category) | Functions, validation |
| List / Summary | Total spent + per-category totals | Dicts, loops |
| Persistence | Saved to expenses.json |
JSON, files |
| Validation | Reject invalid amounts | try/except, raise |
Architecture — Think Before You Code
The whole program is a small set of focused functions:
| Function | Responsibility |
|---|---|
load_expenses() |
Read JSON from disk (or return []) |
save_expenses(expenses) |
Write the list to JSON |
add_expense(expenses, amount, category) |
Validate + append |
print_summary(expenses) |
Aggregate totals by category |
main() |
The while True menu loop |
Each function does one job — the golden rule of function design (Lesson 12).
Mental model: map each menu option to the function it calls. The menu is the front door; the functions are the rooms behind it.
main()stays thin — it only routes.
Building It Step by Step
Step 1 — Load: if expenses.json doesn't exist, start empty. Otherwise json.load it.
def load_expenses():
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
Step 2 — Save: dump the list with indent=2 for readable JSON.
Step 3 — Add with validation: reject non-positive amounts by raising ValueError.
Step 4 — Summary: a dict of totals using .get() — exactly the pattern from Lesson 9:
by_category = {}
for e in expenses:
by_category[e["category"]] = by_category.get(e["category"], 0) + e["amount"]
Step 5 — The menu loop: while True + break from Lesson 5, with try/except around input:
while True:
print("\n1. Add expense 2. Summary 3. Exit")
choice = input("Choose: ")
if choice == "1":
try:
amount = float(input("Amount: "))
category = input("Category: ").strip()
add_expense(expenses, amount, category)
save_expenses(expenses)
except ValueError as e:
print("Invalid input:", e)
elif choice == "2":
print_summary(expenses)
elif choice == "3":
print("Goodbye!")
break
else:
print("Unknown choice")
How This Course Prepared You
Every line of this project uses a lesson you already completed:
while True+break→ Lesson 5- Dicts +
.get()→ Lesson 9 - Functions → Lesson 12
- try/except +
raise→ Lesson 21 - JSON + files → lessons 20 & 23
- The
__name__guard → Lesson 15
That is the point: professional Python is just these building blocks arranged well.
Testing Your Capstone
Apply Lesson 24's habits to the project:
def test_add_expense():
expenses = []
add_expense(expenses, 250, "food")
assert expenses == [{"amount": 250, "category": "food"}]
def test_add_rejects_zero():
expenses = []
try:
add_expense(expenses, 0, "food")
except ValueError:
pass
else:
raise AssertionError("Expected ValueError for amount 0")
Extension Ideas
| Extension | Skills practiced |
|---|---|
| Delete an expense | Lists + pop() |
| Export to CSV | The csv module |
| Budget alert | Conditionals + logging |
| Date tracking | datetime + dicts |
| Refactor to classes | OOP from Module 5 |
Common Mistakes to Avoid
- Mistake: One giant
main()with everything inline — Fix: split into focused functions. - Mistake: Letting bad input crash the menu — Fix: wrap input parsing in try/except.
- Mistake: Forgetting to save after adding — Fix: call
save_expenses()after every mutation. - Mistake:
float(input(...))on empty input crashing — Fix: validate and handleValueError. - Mistake: Reading the JSON file before it exists — Fix: check
os.path.existsfirst (or catchFileNotFoundError).
Professional Tips & Tricks
- Keep
main()thin: it only routes menu choices to functions — each function does one job. - The try/except around input makes the program survive bad data — test it with 'abc'.
- Extend the capstone with CSV export and a delete option to practice everything again.
- Write a few pytest tests for
add_expense— tests make the project feel professional. - Use
__name__ == "__main__"so the module is also importable.
Key Takeaways
- Design the function list before writing code.
- JSON gives your program memory across runs.
- Validate all user input with try/except.
- One function = one job;
main()only routes. - Test the core functions with pytest.
- You are no longer a beginner. 🎉
Next steps: Review the syllabus, redo any lesson, or explore the Applied Data Science course in the Learning Hub.
import json
import os
DATA_FILE = "expenses.json"
def load_expenses():
"""Load expenses from JSON, or start empty."""
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def save_expenses(expenses):
"""Persist expenses to JSON."""
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses, amount, category):
"""Validate and append one expense."""
if amount <= 0:
raise ValueError("Amount must be positive")
expenses.append({"amount": amount, "category": category})
print(f"Added Rs.{amount} to {category}")
def print_summary(expenses):
"""Print total and per-category totals."""
if not expenses:
print("No expenses yet.")
return
total = sum(e["amount"] for e in expenses)
print(f"Total spent: Rs.{total}")
by_category = {}
for e in expenses:
by_category[e["category"]] = by_category.get(e["category"], 0) + e["amount"]
for cat, amt in by_category.items():
print(f" {cat}: Rs.{amt}")
def main():
expenses = load_expenses()
while True:
print("\n1. Add expense 2. Summary 3. Exit")
choice = input("Choose: ")
if choice == "1":
try:
amount = float(input("Amount: "))
category = input("Category: ").strip()
add_expense(expenses, amount, category)
save_expenses(expenses)
except ValueError as e:
print("Invalid input:", e)
elif choice == "2":
print_summary(expenses)
elif choice == "3":
print("Goodbye!")
break
else:
print("Unknown choice")
if __name__ == "__main__":
main()Lesson Code (Python)
import json
import os
DATA_FILE = "expenses.json"
def load_expenses():
"""Load expenses from JSON, or start empty."""
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def save_expenses(expenses):
"""Persist expenses to JSON."""
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses, amount, category):
"""Validate and append one expense."""
if amount <= 0:
raise ValueError("Amount must be positive")
expenses.append({"amount": amount, "category": category})
print(f"Added Rs.{amount} to {category}")
def print_summary(expenses):
"""Print total and per-category totals."""
if not expenses:
print("No expenses yet.")
return
total = sum(e["amount"] for e in expenses)
print(f"Total spent: Rs.{total}")
by_category = {}
for e in expenses:
by_category[e["category"]] = by_category.get(e["category"], 0) + e["amount"]
for cat, amt in by_category.items():
print(f" {cat}: Rs.{amt}")
def main():
expenses = load_expenses()
while True:
print("\n1. Add expense 2. Summary 3. Exit")
choice = input("Choose: ")
if choice == "1":
try:
amount = float(input("Amount: "))
category = input("Category: ").strip()
add_expense(expenses, amount, category)
save_expenses(expenses)
except ValueError as e:
print("Invalid input:", e)
elif choice == "2":
print_summary(expenses)
elif choice == "3":
print("Goodbye!")
break
else:
print("Unknown choice")
if __name__ == "__main__":
main()Console Output
1. Add expense 2. Summary 3. Exit
Choose: 1
Amount: 250
Category: food
Added Rs.250.0 to food
1. Add expense 2. Summary 3. Exit
Choose: 1
Amount: 1200
Category: travel
Added Rs.1200.0 to travel
1. Add expense 2. Summary 3. Exit
Choose: 2
Total spent: Rs.1450.0
food: Rs.250.0
travel: Rs.1200.0
1. Add expense 2. Summary 3. Exit
Choose: 3
Goodbye!Code Visualization Tips
- Map each menu option to the function it calls — draw the call flow as a small diagram.
- Visualize expenses.json as the program's memory between runs: a box that survives restarts.
- Trace one full cycle: input -> function -> dict/list update -> save to file -> reload on next run.
Professional Tips & Tricks
- Keep main() thin: it only routes menu choices to functions — each function does one job.
- The try/except around input makes the program survive bad data — test it with 'abc'.
- Extend the capstone with CSV export and a delete option to practice everything again.
Python Code Judge & Practice Arena
LeetCode StyleRun real Python 3.12 WebAssembly code directly in your browser against automated test suites.
Problem 1: Expense Manager Engine 1
Test Your Knowledge
Instant feedbackQuick Check: Lesson 25: Capstone Project: CLI Expense Tracker & Personal Finance Manager
Course complete
You finished Complete Python Course!
Review the full syllabus, revisit any lesson, or explore another course in the Learning Hub.