Lesson 9 of 925 min2 challenges

Mini Project: A Command-Line Expense Tracker

Dictionaries, lists, functions, files, and JSON all at once, in something you can genuinely use.

Assembling the pieces into something real

The goal: a command-line expense tracker. You can record a purchase, list everything you've recorded, see a summary by category, and close the program without losing any of it.

Every technique it uses is one you already have — dictionaries, lists, functions, loops, if, files, JSON, exceptions. That's the point worth internalizing: real programs are not made of advanced magic. They're ordinary pieces, arranged well. The skill you're building from here on is mostly arrangement.

Decide what the data looks like first

This is the step beginners skip and experienced people spend the most time on. Before writing a line of logic, ask what one record needs to contain. One expense needs: how much, what for, and when.

bill = {"amount": 25.5, "category": "Food", "date": "2026-03-01"}
print(bill["category"])

Output:

Food

Many expenses is a list of those dictionaries:

bills = [
    {"amount": 25.5, "category": "Food", "date": "2026-03-01"},
    {"amount": 8.0, "category": "Transit", "date": "2026-03-01"},
]
print(len(bills))

Output:

2

Note the date is stored as a string, not a date object. JSON has no date type, so storing text keeps saving and loading trivial. The format YYYY-MM-DD is chosen on purpose — text sorted alphabetically comes out in chronological order, and date_string[:7] gives you the year and month.

Get the data shape right and the code falls into place. That sentence probably sounds like a slogan right now. Finish this project and it won't.

The whole program

import json
import os
from datetime import date

DATA_FILE = "bills.json"


def load_bills():
    """Read all bills from disk. Return an empty list if there's no file yet."""
    if not os.path.exists(DATA_FILE):
        return []
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)


def save_bills(bills):
    """Write the full list back to disk."""
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(bills, f, indent=2)


def add_bill(bills):
    try:
        amount = float(input("Amount: "))
    except ValueError:
        print("That wasn't a number. Nothing was recorded.")
        return
    category = input("Category (Food / Transit / Shopping...): ")
    bills.append({
        "amount": amount,
        "category": category,
        "date": str(date.today()),
    })
    save_bills(bills)
    print("Recorded.")


def show_bills(bills):
    if not bills:
        print("Nothing recorded yet.")
        return
    for i, b in enumerate(bills, start=1):
        print(f'{i}. {b["date"]}  {b["category"]}  {b["amount"]:.2f}')


def show_summary(bills):
    total = sum(b["amount"] for b in bills)
    print(f"Total spent: {total:.2f}")

    by_category = {}
    for b in bills:
        name = b["category"]
        by_category[name] = by_category.get(name, 0) + b["amount"]

    for name, amount in sorted(by_category.items(), key=lambda kv: -kv[1]):
        print(f"  {name}: {amount:.2f}")


def main():
    bills = load_bills()
    while True:
        print("\n1 add  2 list  3 summary  4 quit")
        choice = input("Choose: ").strip()
        if choice == "1":
            add_bill(bills)
        elif choice == "2":
            show_bills(bills)
        elif choice == "3":
            show_summary(bills)
        elif choice == "4":
            print("Bye")
            break
        else:
            print("No such option")


main()

A session looks like this:

1 add  2 list  3 summary  4 quit
Choose: 1
Amount: 25.5
Category (Food / Transit / Shopping...): Food
Recorded.

1 add  2 list  3 summary  4 quit
Choose: 2
1. 2026-03-01  Food  25.50

Run it, quit, and run it again. The record is still there, because it went to bills.json on disk.

Five lines worth stopping on

1. if not os.path.exists(DATA_FILE): return []

The very first run has no file. Without this check, open(..., "r") raises FileNotFoundError and the program dies before it starts. Handling the empty first case is the kind of thing that separates a demo from something usable.

2. str(date.today())

date.today() gives a date object. JSON can't store one, so str() converts it to "2026-03-01". Converting at the boundary — where data leaves your program — keeps the messy part in one place.

3. enumerate(bills, start=1)

Gives you the position and the item together, with the count starting at 1. Internally Python counts from 0; people count from 1. This is the line where you translate, and doing the translation only at display time keeps the confusion contained.

4. sum(b["amount"] for b in bills)

That inner part is a generator expression: "take the amount out of each b." It's the one-line form of building a list with a loop and then summing it. Read it right to left — for b in bills first, then b["amount"].

5. sorted(by_category.items(), key=lambda kv: -kv[1])

.items() gives pairs of (name, amount). key= tells sorted what to compare — here, the amount, negated, so the biggest comes first. lambda kv: -kv[1] is a throwaway one-line function: it takes a pair and gives back the negative of its second element. Ask the AI tutor to walk through lambda if it feels strange. It's used constantly for exactly this.

Where the persistence actually happens

save_bills(bills) is called inside add_bill, right after the append. That one call is the entire difference between a program that remembers and one that forgets.

bills.append(...) changes the list living in memory. Memory is erased when the process exits. Until the bytes hit the disk, nothing is saved. This is the whole idea behind the word persistence, and it's why databases exist.

Make it yours

In rough order of difficulty:

  1. Add a "delete a record" option. Show the numbered list, read a number, bills.pop(i - 1), then save_bills(bills).
  2. Limit the summary to the current month. Compare b["date"][:7] with str(date.today())[:7].
  3. Reject amounts that are zero or negative and ask again instead of recording nonsense.
  4. Replace the bare main() at the bottom with if __name__ == "__main__": main(). Ask the AI tutor what that line does — it's the standard shape of every Python project, and the explanation is more interesting than it looks.
  5. Add a category budget: warn when one category passes a limit you set.

Pick one and do it before moving on. Reading code teaches you much less than changing code does.

Ten-second check

If you deleted the line save_bills(bills) from add_bill, would the program still work?

During one run, yes — and everything would be lost on restart. Adding, listing, and summarizing all read the in-memory list, so the session looks perfectly normal. The loss only shows up the next time you open the program. Bugs that look fine while you're testing and fail later are the ones worth being paranoid about.

Ad slot (AdSense not configured; ads appear here in production)

Hands-on

Reading it isn't knowing it. Writing it is.

0/2 passed

Challenges and grading are completely free, and a wrong answer costs you nothing. Stuck? Hit "Ask the AI": it can see the code you are writing.

Level 01Write code+25 pts · +40 XP

Find the Total and the Biggest Expense

Given the `bills` list, print two lines: Line 1: `Total:amount` (two decimal places) Line 2: `Largest:category` (the category of the single biggest expense)

Sample input

(none)

Expected output

Total:232.50
Largest:Shopping
pythonTab = 4 spaces
Level 02Multiple choice+10 pts · +15 XP

Why the Data Disappears

In the expense tracker, what happens if you delete the line `save_bills(bills)`?

  • Might be multiple choice. Wrong answers cost nothing, so just try.

Finished? Mark it

Marking it done saves your progress, gives you 50 XP and keeps your streak alive.