LearnFrenzy
Sign in Take a free test

Projects and Next Steps · Lesson 38 of 39

Project: Expense Tracker

5 min read Updated Nov 7, 2026 Saurabh Samir

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

  1. Interfacemenu loop: reads choices, prints results
  2. Logicadd expense, total, report by category
  3. 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

Output

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())

Output

[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},
])

Output

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()

A sample session

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

  1. Add a date field (default: today) and a "this month only" report.

    Hint

    Store dates as ISO text (date.today().isoformat()) so they survive JSON, and filter with e.date.startswith("2026-11").

  2. Add "5) Delete" that removes an expense by its list number.

    Hint

    Reuse a validating number prompt, then expenses.pop(number - 1) and save_expenses(expenses).

  3. Export the data to expenses.csv with csv.DictWriter so it opens in Excel.

    Hint

    writer = csv.DictWriter(f, fieldnames=["description", "amount", "category"]) then writer.writerows(asdict(e) for e in expenses); open the file with newline="" and encoding="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.

Next lesson

Popular