Skip to content
Happy Programming Guide
Start learning
Programming Projects

Python Project: Build an Expense Tracker

A command-line expense tracker that saves to JSON, groups spending by category and prints a monthly report. Extends the to-do list with real reporting.

A rocket lifting off against the sky

This project takes the skills from the to-do list and adds the thing that makes stored data worth storing: reports. You will group, total and summarise.

What you will build#

Output
EXPENSES — September 2026

  Food            4 items      3,240
  Transport       2 items      1,100
  Bills           1 item       6,500
  ---------------------------------
  Total           7 items     10,840

a) add   r) report   l) list   d) delete   q) quit
Choice:

Decide the shape of one record first#

Before any code, decide what a single expense looks like. Everything else follows from this.

Python
{
    "id": 1757030400,
    "date": "2026-09-05",
    "amount": 450,
    "category": "Food",
    "note": "Lunch"
}

Note amount is a whole number, not 4.50. More on that in a moment.

Money: work in whole units#

Decimals are stored in binary and some values have no exact representation, so totals drift:

Python
print(0.1 + 0.2)        # 0.30000000000000004
print(19.99 * 3)        # 59.97000000000001

Two safe options. The simplest is to store the smallest unit as an integer — paisa, cents, pence — and divide only when displaying:

Python
amount = 450            # 4.50 in whole units
print(f"{amount / 100:.2f}")   # 4.50

If your currency has no subunit in everyday use, storing whole units directly is fine. What you must not do is accumulate floats and expect the total to be exact. This is covered in Python data types explained.

Step 1: load and save#

Python
import json
from pathlib import Path

FILE = Path(__file__).parent / "expenses.json"

def load():
    try:
        with open(FILE, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save(expenses):
    with open(FILE, "w", encoding="utf-8") as f:
        json.dump(expenses, f, indent=2)

Both failure cases are handled: no file on the first run, and a damaged file. See Python try/except explained.

Step 2: adding an expense#

Python
from datetime import date
import time

CATEGORIES = ["Food", "Transport", "Bills", "Shopping", "Health", "Other"]

def ask_amount():
    while True:
        raw = input("Amount: ").strip()
        try:
            value = round(float(raw) * 100)     # accept 4.50, store 450
        except ValueError:
            print("Please enter a number, for example 4.50")
            continue
        if value <= 0:
            print("Amount must be more than zero.")
            continue
        return value

def ask_category():
    for i, name in enumerate(CATEGORIES, start=1):
        print(f"  {i}) {name}")
    while True:
        raw = input("Category: ").strip()
        if raw.isdigit() and 1 <= int(raw) <= len(CATEGORIES):
            return CATEGORIES[int(raw) - 1]
        print("Pick a number from the list.")

def add(expenses):
    amount = ask_amount()
    category = ask_category()
    note = input("Note (optional): ").strip()

    expenses.append({
        "id": int(time.time() * 1000),
        "date": date.today().isoformat(),
        "amount": amount,
        "category": category,
        "note": note,
    })
    return f"Added {category} {amount / 100:.2f}"

Each input has its own small function that loops until the value is usable. That keeps add() readable and means you can reuse the validation elsewhere.

Step 3: the report#

This is the part that makes it an expense tracker rather than a list. Group by category with a dictionary:

Python
def report(expenses, month=None):
    month = month or date.today().strftime("%Y-%m")
    rows = [e for e in expenses if e["date"].startswith(month)]

    if not rows:
        return f"Nothing recorded for {month}."

    totals = {}
    counts = {}
    for e in rows:
        totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
        counts[e["category"]] = counts.get(e["category"], 0) + 1

    lines = [f"EXPENSES — {month}", ""]

    for category in sorted(totals, key=totals.get, reverse=True):
        label = "item" if counts[category] == 1 else "items"
        lines.append(
            f"  {category:<14} {counts[category]:>2} {label:<6} {totals[category] / 100:>10,.2f}"
        )

    lines.append("  " + "-" * 33)
    lines.append(f"  {'Total':<14} {len(rows):>2} items  {sum(totals.values()) / 100:>10,.2f}")
    return "\n".join(lines)

totals.get(key, 0) + amount is the standard counting pattern — see Python dictionaries explained. Sorting by totals.get puts the biggest spend first, which is the useful order.

Step 4: the menu#

Python
def list_all(expenses):
    if not expenses:
        return "Nothing recorded yet."
    lines = []
    for e in sorted(expenses, key=lambda x: x["date"], reverse=True)[:20]:
        note = f" — {e['note']}" if e["note"] else ""
        lines.append(f"  {e['date']}  {e['category']:<12} {e['amount'] / 100:>9,.2f}{note}")
    return "\n".join(lines)

def delete(expenses):
    print(list_all(expenses))
    raw = input("Date to delete from (YYYY-MM-DD): ").strip()
    matches = [e for e in expenses if e["date"] == raw]
    if not matches:
        return "Nothing on that date."
    removed = matches[-1]
    expenses.remove(removed)
    return f"Removed {removed['category']} {removed['amount'] / 100:.2f}"

def main():
    expenses = load()

    while True:
        print("\n" + report(expenses))
        choice = input("\na) add   r) report   l) list   d) delete   q) quit\nChoice: ").strip().lower()

        if choice == "q":
            save(expenses)
            print("Saved. Goodbye.")
            break
        elif choice == "a":
            print(add(expenses))
        elif choice == "r":
            month = input("Month (YYYY-MM, blank for this one): ").strip()
            print("\n" + report(expenses, month or None))
        elif choice == "l":
            print(list_all(expenses))
        elif choice == "d":
            print(delete(expenses))
        else:
            print("Unknown choice.")

        save(expenses)

main()

How the code works#

  • Saving after every change means a crash never loses data.
  • Dates stored as YYYY-MM-DD text sort correctly as strings and filter with a simple startswith. That is why ISO format is worth using everywhere.
  • {value:>10,.2f} right-aligns in 10 characters, adds thousands separators and fixes two decimal places — which is what makes the columns line up.
  • Every function returns a message rather than printing, so all output happens in one place and the functions stay testable.

Questions people ask#

Why JSON and not a database?

At a few thousand records JSON is simpler and you can open the file and read it. Move to sqlite3 when you want to query rather than load everything into memory.

Should I use the decimal module instead?

decimal.Decimal is the rigorous answer for money and is worth learning eventually. Integers in the smallest unit are simpler, fast, and correct for a project this size.

How do I handle more than one currency?

Store the currency code on each record and never mix them in a total. Converting between them needs a rate and a date, which is a much larger problem.

Can I add a graphical interface?

Yes — keep every function above exactly as it is and add tkinter on top. That separation between logic and input/output is the reason it would be easy.

Where to go next#

Related lessonPython dictionaries explained

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *