Python Project: Build an Expense Tracker (Files, JSON, Dicts)

This Python expense tracker project pulls together the pieces you have been learning one at a time, dicts, lists, functions, files, JSON, and exceptions, into a single small program you can actually run and keep. If the last few tutorials felt like isolated Lego bricks, this is the post where you snap them into something that holds together.

“Software is eating the world.”

Marc Andreessen, The Wall Street Journal, 2011

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 17 minutes

There is a moment in every beginner’s journey that people call the project desert: you know the syntax, you have done the small exercises, but you have never built one complete thing on your own. This post is the first oasis. We will build a command line Python expense tracker that lets you add expenses, save them so they survive a restart, and print a monthly summary. Everything is standard library, so there is nothing to install and nothing that can rot in a year.

Think of it like a paper notebook you keep in a kitchen drawer. Every time you spend money you jot down the amount, what it was for, and the date. At the end of the month you flip through and add it up. We are just teaching Python to be that notebook, one that never loses a page and does the adding for you.

What We Are Building

Good programmers write down what “done” means before they write code. It is the same idea as a shopping list: decide what you need first, so you know when you are finished and you do not wander the aisles forever. Here is our acceptance criteria, the checklist this project has to satisfy. Keep it next to you and tick each box as we go.

  • Add an expense with an amount, a category, and a note
  • List every expense that has been recorded
  • Summarize spending grouped by category and by month
  • Persist the data so it survives closing and reopening the program
  • Survive bad input (letters where a number should be, unknown categories) without crashing

The diagram below shows how one expense travels through the program, from the keys you press to the report at the end. We will build it in that order, one piece per run.

invalidvalidUser typesamount, category, noteValidate inputparse_amount /parse_categoryRaise ExpenseErrorshow message, ask againBuild one expense dictamount, category, note,dateAppend to theexpenses listSave list to expenses.jsonjson.dumpsNext run: load it backjson.loadsGroup by month andcategorydefaultdict summaryExpense Tracker Data Flow: From Keyboard Input to Saved JSON to Report

Run 1: The Menu Loop

Every interactive tool needs a front desk: something that greets the user, shows the options, and keeps asking until they say they are done. That is a while True loop wrapped around input(). The loop only ends when the user picks quit, which we do with break.

📄 tracker.py: the menu skeleton

def show_menu():
    print("\n=== Expense Tracker ===")
    print("1. Add expense")
    print("2. List expenses")
    print("3. Monthly summary")
    print("4. Quit")


def main():
    while True:
        show_menu()
        choice = input("Choose an option (1-4): ").strip()
        if choice == "1":
            print("-> You picked: Add expense")
        elif choice == "2":
            print("-> You picked: List expenses")
        elif choice == "3":
            print("-> You picked: Monthly summary")
        elif choice == "4":
            print("Goodbye. Your expenses are safe.")
            break
        else:
            print(f"'{choice}' is not a menu option. Please pick 1 to 4.")


if __name__ == "__main__":
    main()

▶ Output

=== Expense Tracker ===
1. Add expense
2. List expenses
3. Monthly summary
4. Quit
Choose an option (1-4): -> You picked: Add expense

=== Expense Tracker ===
1. Add expense
2. List expenses
3. Monthly summary
4. Quit
Choose an option (1-4): '9' is not a menu option. Please pick 1 to 4.

=== Expense Tracker ===
1. Add expense
2. List expenses
3. Monthly summary
4. Quit
Choose an option (1-4): Goodbye. Your expenses are safe.

What happened here: we fed it three choices, 1 then 9 then 4. Option 1 was handled, option 9 was rejected with a clear message instead of a crash, and option 4 broke out of the loop. Notice .strip() on the input: people press extra spaces, and stripping them means " 4 " still quits. The else branch is your safety net, so a wrong key never ends the program by accident.

Run 2: Adding an Expense

Now the notebook needs pages. Each expense is a small record with a few labelled fields, and the natural shape for that in Python is a dictionary. A pile of expenses is just a list of those dictionaries. Say a user named Aditi buys vegetables: that becomes one dict with an amount, a category, a note, and a date, appended to the list.

📄 add_demo.py: expenses as a list of dicts

expenses = []


def add_expense(amount, category, note, date):
    expense = {
        "amount": amount,
        "category": category,
        "note": note,
        "date": date,
    }
    expenses.append(expense)
    return expense


add_expense(12.50, "groceries", "rice and vegetables", "2026-07-03")
add_expense(4.00, "transport", "bus fare to college", "2026-07-03")
add_expense(9.75, "groceries", "fruit for the week", "2026-07-05")

print(f"{'Date':<12}{'Category':<12}{'Amount':>8}  Note")
print("-" * 48)
for e in expenses:
    print(f"{e['date']:<12}{e['category']:<12}{e['amount']:>8.2f}  {e['note']}")

print(f"\nExpenses stored in memory: {len(expenses)}")

▶ Output

Date        Category      Amount  Note
------------------------------------------------
2026-07-03  groceries      12.50  rice and vegetables
2026-07-03  transport       4.00  bus fare to college
2026-07-05  groceries       9.75  fruit for the week

Expenses stored in memory: 3

What happened here: the dictionary gives each field a name, so e['amount'] reads like plain English instead of a mystery position like e[0]. The format specs do the tidy columns: :<12 left aligns a field in 12 spaces, and :>8.2f right aligns a number in 8 spaces with two decimals. There is one catch though: this list only lives in memory. Close the program and all three expenses vanish. That is what Run 3 fixes.

Run 3: Saving to a JSON File

To make data survive a restart, we write it to a file. JSON is the perfect fit because a list of dictionaries maps directly onto JSON, and Python’s json module is in the standard library. Think of it like photographing your notebook page before you leave home, so even if you lose the notebook you still have the record. If you want the deeper tour of the format, the working with JSON tutorial covers it, and file basics live in reading and writing files.

📄 json_demo.py: save and load with json

import json
from pathlib import Path

DATA_FILE = Path("expenses.json")


def save_expenses(expenses):
    DATA_FILE.write_text(json.dumps(expenses, indent=2), encoding="utf-8")


def load_expenses():
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text(encoding="utf-8"))


expenses = load_expenses()
print(f"Loaded {len(expenses)} expenses from disk.")

expenses.append({"amount": 12.50, "category": "groceries",
                 "note": "rice and vegetables", "date": "2026-07-03"})
expenses.append({"amount": 30.00, "category": "utilities",
                 "note": "electricity bill", "date": "2026-07-06"})
save_expenses(expenses)
print(f"Saved {len(expenses)} expenses to {DATA_FILE.name}.")

reloaded = load_expenses()
print(f"Reloaded {len(reloaded)} expenses from disk:")
for e in reloaded:
    print(f"  {e['date']}  {e['category']:<10} {e['amount']:>7.2f}")

print("\n--- raw contents of expenses.json ---")
print(DATA_FILE.read_text(encoding="utf-8"))

▶ Output

Loaded 0 expenses from disk.
Saved 2 expenses to expenses.json.
Reloaded 2 expenses from disk:
  2026-07-03  groceries    12.50
  2026-07-06  utilities    30.00

--- raw contents of expenses.json ---
[
  {
    "amount": 12.5,
    "category": "groceries",
    "note": "rice and vegetables",
    "date": "2026-07-03"
  },
  {
    "amount": 30.0,
    "category": "utilities",
    "note": "electricity bill",
    "date": "2026-07-06"
  }
]

What happened here: the first run loaded zero because the file did not exist yet, and load_expenses returns an empty list in that case rather than blowing up. json.dumps(..., indent=2) writes human readable JSON, so you can open expenses.json in any editor and read it. The reload proves the round trip worked: the data came back identical. Notice 12.50 became 12.5 in the file, because JSON stores the number, not the trailing zero. The .2f formatting puts the pretty two decimals back when we print.

Run 4: The Monthly Summary

Now for the payoff, the part your paper notebook makes tedious. We want totals grouped by category and by month. Grouping is one of the most common things you will ever do with data, and the clean tool for it is collections.defaultdict. It behaves like a normal dictionary, except a missing key starts at a default value (here, 0.0) instead of raising a KeyError, so you can just keep adding.

📄 summary_demo.py: grouping with defaultdict

from collections import defaultdict

expenses = [
    {"amount": 12.50, "category": "groceries", "note": "veg", "date": "2026-07-03"},
    {"amount": 4.00, "category": "transport", "note": "bus", "date": "2026-07-03"},
    {"amount": 9.75, "category": "groceries", "note": "fruit", "date": "2026-07-05"},
    {"amount": 30.00, "category": "utilities", "note": "power", "date": "2026-07-06"},
    {"amount": 5.50, "category": "transport", "note": "metro", "date": "2026-08-02"},
    {"amount": 18.00, "category": "groceries", "note": "milk, dal", "date": "2026-08-04"},
]


def total_by_category(expenses):
    totals = defaultdict(float)
    for e in expenses:
        totals[e["category"]] += e["amount"]
    return dict(totals)


def total_by_month(expenses):
    totals = defaultdict(float)
    for e in expenses:
        month = e["date"][:7]  # "2026-07-03" -> "2026-07"
        totals[month] += e["amount"]
    return dict(totals)


print("Spending by category")
for category, amount in sorted(total_by_category(expenses).items()):
    print(f"  {category:<12} {amount:>7.2f}")

print("\nSpending by month")
for month, amount in sorted(total_by_month(expenses).items()):
    print(f"  {month}   {amount:>7.2f}")

grand_total = sum(e["amount"] for e in expenses)
print(f"\nGrand total: {grand_total:.2f} across {len(expenses)} expenses")

▶ Output

Spending by category
  groceries      40.25
  transport       9.50
  utilities      30.00

Spending by month
  2026-07     56.25
  2026-08     23.50

Grand total: 79.75 across 6 expenses

What happened here: the trick is totals[e["category"]] += e["amount"]. The first time a category appears, defaultdict quietly creates it at 0.0, then adds the amount. Every time after that it just adds. Grouping by month uses the same idea with a slice: e["date"][:7] takes the first seven characters of "2026-07-03", giving "2026-07". Because our dates are stored as YYYY-MM-DD text, that slice is a clean month key with no date parsing needed.

Run 5: Defensive Input with Custom Exceptions

Real users type real nonsense. They put “abc” where a number should go, or a category you have never heard of. A good program treats bad input as a normal event, not a disaster. We give our own errors clear names by writing a small family of custom exceptions, then raise them from validation functions. If you want the full background on this pattern, it comes straight from the custom exceptions tutorial.

Think of a bouncer at an event checking wristbands. A bad wristband does not end the party, the bouncer just turns that one person away and the queue keeps moving. Our validators are that bouncer.

📄 validate_demo.py: named errors that keep the program alive

class ExpenseError(Exception):
    """Base error for anything the tracker rejects."""


class InvalidAmountError(ExpenseError):
    pass


class InvalidCategoryError(ExpenseError):
    pass


CATEGORIES = {"groceries", "transport", "utilities", "rent", "other"}


def parse_amount(raw):
    try:
        amount = float(raw)
    except ValueError:
        raise InvalidAmountError(f"'{raw}' is not a number")
    if amount <= 0:
        raise InvalidAmountError(f"amount must be positive, got {amount}")
    return round(amount, 2)


def parse_category(raw):
    category = raw.strip().lower()
    if category not in CATEGORIES:
        allowed = ", ".join(sorted(CATEGORIES))
        raise InvalidCategoryError(f"'{raw}' is unknown. Try one of: {allowed}")
    return category


tests = [("12.50", "Groceries"), ("abc", "groceries"),
         ("-5", "transport"), ("8", "snacks")]

for raw_amount, raw_category in tests:
    print(f"Input: amount={raw_amount!r}, category={raw_category!r}")
    try:
        amount = parse_amount(raw_amount)
        category = parse_category(raw_category)
        print(f"  ACCEPTED -> {amount:.2f} in '{category}'")
    except ExpenseError as e:
        print(f"  REJECTED -> {type(e).__name__}: {e}")

▶ Output

Input: amount='12.50', category='Groceries'
  ACCEPTED -> 12.50 in 'groceries'
Input: amount='abc', category='groceries'
  REJECTED -> InvalidAmountError: 'abc' is not a number
Input: amount='-5', category='transport'
  REJECTED -> InvalidAmountError: amount must be positive, got -5.0
Input: amount='8', category='snacks'
  REJECTED -> InvalidCategoryError: 'snacks' is unknown. Try one of: groceries, other, rent, transport, utilities

What happened here: the first input was accepted, and note that "Groceries" was cleaned to "groceries" by .strip().lower(), so capitalization no longer matters. The other three were each rejected with a specific error type and a message that names the exact problem. Because InvalidAmountError and InvalidCategoryError both inherit from ExpenseError, a single except ExpenseError catches either one. That is the whole point of a small exception family: catch broadly when you want, catch narrowly when you need to.

The Full Program

Here are all five runs assembled into one file, about 117 lines. Save it as tracker.py and run it with python tracker.py. It loads any saved expenses on startup, saves after every add, and lets you list and summarize. This is the program that ticks every box on our acceptance list.

📄 tracker.py: the complete expense tracker

"""A small expense tracker: dicts, lists, functions, files, JSON, exceptions."""
import json
from collections import defaultdict
from datetime import date
from pathlib import Path

DATA_FILE = Path("expenses.json")
CATEGORIES = {"groceries", "transport", "utilities", "rent", "other"}


# --- custom exceptions ---
class ExpenseError(Exception):
    """Base error for anything the tracker rejects."""


class InvalidAmountError(ExpenseError):
    pass


class InvalidCategoryError(ExpenseError):
    pass


# --- validation ---
def parse_amount(raw):
    try:
        amount = float(raw)
    except ValueError:
        raise InvalidAmountError(f"'{raw}' is not a number")
    if amount <= 0:
        raise InvalidAmountError(f"amount must be positive, got {amount}")
    return round(amount, 2)


def parse_category(raw):
    category = raw.strip().lower()
    if category not in CATEGORIES:
        allowed = ", ".join(sorted(CATEGORIES))
        raise InvalidCategoryError(f"'{raw}' is unknown. Try: {allowed}")
    return category


# --- storage ---
def load_expenses():
    if not DATA_FILE.exists():
        return []
    try:
        return json.loads(DATA_FILE.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        print("Warning: expenses.json is corrupted. Starting fresh.")
        return []


def save_expenses(expenses):
    DATA_FILE.write_text(json.dumps(expenses, indent=2), encoding="utf-8")


# --- features ---
def add_expense(expenses):
    amount = parse_amount(input("Amount: "))
    category = parse_category(input("Category: "))
    note = input("Note: ").strip()
    expense = {"amount": amount, "category": category,
               "note": note, "date": date.today().isoformat()}
    expenses.append(expense)
    save_expenses(expenses)
    print(f"Added {amount:.2f} to '{category}'.")


def list_expenses(expenses):
    if not expenses:
        print("No expenses yet.")
        return
    print(f"\n{'Date':<12}{'Category':<12}{'Amount':>8}  Note")
    print("-" * 46)
    for e in expenses:
        print(f"{e['date']:<12}{e['category']:<12}{e['amount']:>8.2f}  {e['note']}")


def monthly_summary(expenses):
    if not expenses:
        print("No expenses to summarize.")
        return
    by_month = defaultdict(lambda: defaultdict(float))
    for e in expenses:
        by_month[e["date"][:7]][e["category"]] += e["amount"]
    for month in sorted(by_month):
        print(f"\n{month}")
        for category, amount in sorted(by_month[month].items()):
            print(f"  {category:<12} {amount:>7.2f}")
        print(f"  {'TOTAL':<12} {sum(by_month[month].values()):>7.2f}")


# --- menu loop ---
def main():
    expenses = load_expenses()
    print(f"Loaded {len(expenses)} saved expenses.")
    actions = {"1": add_expense, "2": list_expenses, "3": monthly_summary}
    while True:
        print("\n=== Expense Tracker ===")
        print("1. Add  2. List  3. Summary  4. Quit")
        choice = input("Choose (1-4): ").strip()
        if choice == "4":
            print("Saved and safe. Bye.")
            break
        action = actions.get(choice)
        if action is None:
            print(f"'{choice}' is not an option. Pick 1 to 4.")
            continue
        try:
            action(expenses)
        except ExpenseError as e:
            print(f"Rejected: {type(e).__name__}: {e}")


if __name__ == "__main__":
    main()

Here is a full session. We add one expense, try to add a second with a bad amount, add a real second one, then list and summarize before quitting.

▶ Output

Loaded 0 saved expenses.

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4): Amount: Category: Note: Added 12.50 to 'groceries'.

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4): Amount: Rejected: InvalidAmountError: 'abc' is not a number

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4): Amount: Category: Note: Added 30.00 to 'utilities'.

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4):
Date        Category      Amount  Note
----------------------------------------------
2026-07-10  groceries      12.50  rice and veg
2026-07-10  utilities      30.00  electricity bill

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4):
2026-07
  groceries      12.50
  utilities      30.00
  TOTAL          42.50

=== Expense Tracker ===
1. Add  2. List  3. Summary  4. Quit
Choose (1-4): Saved and safe. Bye.

What happened here: the prompts Amount:, Category:, and Note: sit on the same line as the menu prompt because we typed answers between them, which is exactly how a real terminal session looks. The bad amount "abc" was rejected and the loop simply carried on, so nothing was lost. The dates read 2026-07-10 because date.today().isoformat() stamped today’s date automatically. Every add wrote to expenses.json, so if you rerun the program it starts by loading what you saved.

Stretch Goals

The Python expense tracker works, but a good project is one you keep poking at. Each of these is a self contained upgrade you can add on your own, and each one exercises a skill from earlier in the series. A satisfying one is a windowed front end: the Tkinter GUI tutorial shows how to put buttons and entry fields on top of logic exactly like this.

  • CSV export: add a menu option that writes the expenses to a spreadsheet friendly file using the csv module, so you can open the data in any spreadsheet app.
  • Search: let the user type a keyword and print only the expenses whose note or category contains it.
  • Budget alerts: set a monthly limit per category, then flag any month that goes over it in the summary.
  • Delete and edit: show numbered expenses and let the user remove or correct one by its number.

One more thing worth doing: put this project under version control. Right now a single tracker.py is easy to lose or break. When you reach the Git block later in the series, this is a perfect first repository to practice on, small enough to understand fully and real enough to care about.

Common Mistakes

Mistake 1: Forgetting to save after every change

A classic beginner bug is to save only when the program quits. If it crashes, or the user closes the terminal, the day’s expenses are gone. Saving right after each add, as we do, means the file is always up to date. Disk writes are cheap here, so there is no reason to delay them.

Mistake 2: Storing money as floats and expecting exact math

Floats like 0.1 cannot be represented exactly in binary, so long chains of float math can drift by a fraction of a cent. For a learning project that is fine, and we round to two places. For anything that touches real money in production, reach for decimal.Decimal, which does exact base ten arithmetic. Knowing when a shortcut is acceptable is part of the craft.

Mistake 3: Letting one bad JSON file kill the program forever

If expenses.json ever gets truncated or hand edited into invalid JSON, json.loads raises a JSONDecodeError. Our load_expenses catches that and starts fresh with a warning instead of crashing on every launch. Always assume a file you did not write this second might be broken.

Best Practices

  • DO write your acceptance criteria before your code, so you know when you are done
  • DO keep each function doing one job (validate, save, summarize), which makes testing and reading easy
  • DO validate input at the edge and raise named exceptions the caller can catch
  • DO store dates as YYYY-MM-DD text, which sorts correctly and slices into a month cleanly
  • DON’T mix reading input and doing logic in one giant function, split them so each piece stays small
  • DON’T silently swallow errors with a bare except, catch what you expect and let the rest surface

Conclusion

You just built a complete Python expense tracker from nothing, and every skill in it was one you had already met on its own: dictionaries for records, a list to hold them, functions to keep jobs separate, JSON files for persistence, and custom exceptions to survive bad input. That is the real lesson of this post. Projects are not new magic, they are old bricks stacked with intent. The next time a tutorial teaches you one small thing, you now know it is a brick waiting for a wall.

From here, try the stretch goals, then rebuild something similar without looking back at the code, that is when it really sticks. And if you want to see everything this series covers, from first steps to AI and machine learning, browse the Python + AI/ML tutorial series home.

Frequently Asked Questions

Do I need any libraries to build this Python expense tracker?

No. The whole project uses only the standard library: json, collections, datetime, and pathlib all ship with Python. There is nothing to install, so the code will keep running for years.

Why store expenses in JSON instead of a database?

For a small personal tool, a JSON file is simpler and needs zero setup, while still surviving restarts. When you have thousands of records or multiple users, that is the moment to move to SQLite or another database, which the series covers later.

Why use custom exceptions instead of just printing an error?

A custom exception separates detecting a problem (in the validator) from deciding what to do about it (in the menu loop). That keeps the validator reusable, and the named type lets callers catch InvalidAmountError specifically if they ever need to.

How do I reset the tracker and start over?

Delete the expenses.json file. On the next run, load_expenses sees the file is missing and returns an empty list, so you begin fresh with no code changes.

Is it safe to store money as a float here?

For a learning project, yes, and we round to two decimals. For real financial software use decimal.Decimal, which avoids the tiny rounding drift that floats can introduce over many operations.

Interview Questions on This Project

How interviewers actually probe this topic: real scenarios, with answers you can say out loud.

Q: Why is a list of dictionaries a reasonable shape for this data, and when would you outgrow it?

Each expense is a record with named fields, which a dictionary models naturally, and a list preserves insertion order for listing. It works well up to a few thousand rows held in memory. You outgrow it when you need fast lookups by id, queries across fields, or concurrent access, which is when a database like SQLite earns its place.

Q: What does defaultdict(float) give you that a plain dict does not in the summary code?

With a plain dict, totals[category] += amount raises KeyError the first time a category appears, because the key does not exist yet. A defaultdict(float) creates any missing key with 0.0 on first access, so the running total just works. The alternative is dict.get(category, 0.0) or setdefault, which are more verbose.

Q: The validators raise InvalidAmountError and InvalidCategoryError, but the loop only catches ExpenseError. Why does that work?

Both specific errors inherit from ExpenseError, and an except clause matches the named class and all of its subclasses. So one handler catches the whole family. This is the standard pattern: a base exception for the app, specific subclasses for each case, and callers choosing how broadly to catch.

Q: How does the program avoid losing data if it crashes midway?

It saves to expenses.json immediately after every successful add, rather than only at exit. So the file always reflects the last confirmed expense, and a crash loses at most the entry the user was still typing. The tradeoff is more frequent disk writes, which is negligible at this scale.

Q: Why slice the date string with [:7] to get the month instead of parsing it into a date object?

Because the dates are stored in ISO format, YYYY-MM-DD, the first seven characters are always YYYY-MM, a valid month key with no parsing cost. It is a deliberate design choice: storing dates as ISO text makes them sort correctly as strings and slice cleanly. If the format were inconsistent, you would parse with datetime first.

Further reading: the official Python documentation is the authoritative source on this.

Previous: Python: Raising Exceptions & Custom Exception Classes

Next: Python: 20 Most Common Error Messages, What They Mean & How to Fix

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

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