Build a Python Expense Tracker

Build a persistent Python CLI that saves and summarizes personal expenses.

Introduction

30 Second Summary

Small purchases are easy to forget once the receipt disappears. A useful spending record should still be there the next time you need it.

In this project, you will build a personal expense tracker in Python using IDLE. Your tracker stores every entry in a CSV file so your spending returns after each restart.

What You'll Build

You will run your tracker in IDLE to watch saved expenses return with an overall total after the program restarts.

By the end of this project, you'll have:

  • Reliable expense entry through a menu that captures an amount, category, and description. Invalid amounts or blank text stay out of your record.
  • A persistent spending history that reloads automatically whenever you run the tracker again.
  • Spending summaries that show your overall total. A category breakdown reveals where the money went.
  • Secret Mission: Add a category filter that shows only matching saved expenses. Display their filtered total.

Are there any prerequisites?

You need Windows with Python 3.8 or newer. You also need IDLE.

No third-party packages are required.

Before We Start

First, lock in who you are building this personal expense tracker for. Your goal is to keep their spending records available after the program restarts.

Prepare Your IDLE Workspace

A growing expense tracker needs one known workspace. The script and its future data file will live together there.

In this step, you will verify that IDLE is using Python 3.8 or newer. You will save the starter script in a dedicated folder.

In this step, get ready to:
  • Verify that IDLE uses Python 3.8 or newer.
  • Create a dedicated folder for the expense tracker.
  • Run a starter script in IDLE.
Verify your Python version

IDLE runs your script with a specific Python interpreter. The version in the title bar confirms which interpreter is active.

  • Press the Windows key to open the search bar.
  • Type IDLE into the search bar. Press Enter to open it.
  • Read the Python version displayed in the IDLE title bar.

✔️ I see version 3.8 or higher

You're set. IDLE is using a compatible interpreter for every script in this project.

ⓧ I see an older version

Your installed version is below the project's minimum requirement. Updating it gives the tracker access to the supported Python features used throughout this project.

Windows may request permission during installation. This permission allows the installer to update Python on your computer.

  • Install Python 3.15.2 using the official Windows installer.
  • Press the Windows key after the installation finishes.
  • Type IDLE into the search bar. Press Enter to reopen it.
  • Read the version in the title bar again.

You should now see Python 3.15.2 in the title bar.

ⓧ IDLE does not open

IDLE ships with Python. Installing the current Python release gives you both the interpreter and the editor used in this project.

Windows may request permission during installation. This permission allows the installer to add Python to your computer.

  • Install Python 3.15.2 using the official Windows installer.
  • Press the Windows key after the installation finishes.
  • Type IDLE into the search bar. Press Enter to open it.
  • Read the Python version displayed in the title bar.

You should now see Python 3.15.2 in the title bar.

IDLE Still Not Opening?

  • Restart Windows if the installation finishes but IDLE does not appear in search.
  • Run the official installer again if the installation does not complete.

Help me troubleshoot why IDLE does not open after installing Python on Windows.

Create the project workspace

A dedicated folder keeps the script easy to find. It also gives the tracker one predictable place for the data file it creates later.

  • Click the folder icon on the Windows taskbar to open the file browser.
  • Select Documents in the left sidebar.

You should see the files and folders currently stored in Documents.

  • Right-click an empty area inside Documents.
  • Choose the option to create a new folder.

You should see an editable field for the new folder's name.

  • Type python-expense-tracker as the folder name.
  • Press Enter to create the folder.

You should see the python-expense-tracker folder inside Documents.

  • Switch back to IDLE from the Windows taskbar.

The IDLE window from your version check should return to the front.

  • Select File in the IDLE menu.
  • Select New File.

You should see a blank Editor window.

  • Select File in the Editor menu.
  • Select Save As….

The save window lets you choose where the new script belongs.

  • Choose the Documents\python-expense-tracker folder.

The save window should now show python-expense-tracker as the destination folder.

  • Enter expense_tracker.py as the file name.
  • Confirm the save.

Good progress. The Editor title bar now shows expense_tracker.py inside Documents\python-expense-tracker.

Run the starter script

A one-line setup check proves that IDLE can execute the saved script. Its output gives you an immediate signal before the tracker grows.

  • Add the setup check to expense_tracker.py by entering this code:
print("Expense Tracker setup complete")

What Does This Code Do?

  • The print call sends a message to the IDLE Shell.
  • The quoted text becomes the exact output you use to confirm that the saved script ran.
  • Save expense_tracker.py from IDLE's File menu.

Before you run the module, where do you expect the message to appear: the Editor or the Shell?

  • Select Run in the Editor menu.
  • Select Run Module.

You should see Expense Tracker setup complete in the Shell. That's your first workspace check complete: IDLE is running the correct saved script.

Setup Message Missing?

  • Return to the Editor and confirm that the print line matches the code above.
  • Confirm that the Editor title bar shows expense_tracker.py before selecting Run Module.
  • Save the file again if your latest change is still unsaved.

Help me find out why my IDLE Shell does not show the setup message.

✔️ Awesome, I've got everything!

  • Save expense_tracker.py once more before moving on.

ⓧ I'd like to double check the full code

  • Compare your saved file with this complete version.
print("Expense Tracker setup complete")

Your workspace is ready. Next, you will replace the setup check with a tracker that can hold expenses while it runs.

Build an In-Memory Tracker

Your IDLE workspace now runs expense_tracker.py successfully. The next goal is a menu that turns typed spending details into reviewable entries.

An in-memory list gives you the simplest working tracker. The restart test at the end reveals whether that list can preserve your spending history.

In this step, get ready to:
  • Collect expense details in dictionaries stored inside a list.
  • Display every expense through a reusable function.
  • Run a numbered menu that exposes what happens after a restart.
Collect an expense in memory

Each expense needs a consistent structure. A dictionary groups the amount with its category and description.

  • In expense_tracker.py, find print("Expense Tracker setup complete").
  • Replace that line with the list and input function by pasting this code:
expenses = []


def add_expense(expenses):
    amount = input("Amount: $").strip()
    category = input("Category: ").strip()
    description = input("Description: ").strip()

    expense = {
        "amount": amount,
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    print("Expense added.")

What does this code do?

  • The expenses list holds every entry created during the current run.
  • The three input() calls collect the spending details as text.
  • The expense dictionary stores each detail under a named key.
  • The append() call places the completed dictionary in the list.
  • Save expense_tracker.py in IDLE.
  • Click Run in the IDLE menu bar.
  • Select Run Module to load the list and function into the Shell.

The add_expense() function pauses for three inputs when you call it. This lets you test the first working slice immediately.

  • Start the input function by entering this call in the Shell:
add_expense(expenses)

What does this Shell call do?

This passes the current expenses list into add_expense(). The function can now add one dictionary to that list.

  • Enter 12.50 at the amount prompt.
  • Enter Food at the category prompt.
  • Enter Lunch at the description prompt.

You should see Expense added. in the Shell. Your first expense is now sitting inside the active list.

Function unavailable in the Shell?

  • Confirm that you saved expense_tracker.py before selecting Run Module.
  • Check that the function name is spelled exactly as add_expense.

Help me fix an IDLE Shell that cannot call my add_expense function.

Display the current expenses

A tracker becomes useful when it can turn stored dictionaries into readable rows. The display function also needs a clear response when the list is empty.

  • In expense_tracker.py, place your cursor below print("Expense added.").
  • Create the display function by pasting this code:
def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${expense['amount']} | "
            f"{expense['category']} | {expense['description']}"
        )

How does the display work?

  • The empty-list branch prints a useful message before ending the function.
  • The for loop visits every stored dictionary.
  • The enumerate() call gives each displayed expense a number starting at 1.
  • The formatted strings combine the three dictionary values into one readable row.
  • Save expense_tracker.py in IDLE.
  • Click Run in the IDLE menu bar.
  • Select Run Module to reload both functions.
  • Add a fresh sample expense by entering this call in the Shell:
add_expense(expenses)

Why add another sample?

Selecting Run Module created a fresh expenses list. This call gives the display function one row to show.

  • Enter 12.50 at the amount prompt.
  • Enter Food at the category prompt.
  • Enter Lunch at the description prompt.
  • Display the stored entry by entering this call in the Shell:
show_expenses(expenses)

What does this Shell call do?

This passes the current list to show_expenses(). The function numbers each dictionary before printing its three stored values.

You should see an Expenses heading followed by 1. $12.50 | Food | Lunch. Your tracker can now collect and display an entry during one run.

Expense row missing?

  • Call add_expense(expenses) before calling show_expenses(expenses).
  • Check that expenses.append(expense) remains inside add_expense().

Help me troubleshoot why show_expenses does not display the expense I added.

Add the menu and test a restart

The main() function keeps the tracker active through a repeating loop. Each numbered choice routes you to one of the functions you already tested.

  • In expense_tracker.py, place your cursor below the show_expenses() function.
  • Create the numbered menu and start it by pasting this code:
def main():
    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, or 3.")


main()

How does the menu stay active?

  • The while True loop displays the menu repeatedly.
  • The choice variable stores the selected menu number.
  • The conditional branches call the matching tracker function.
  • The break statement ends the loop when the learner chooses Exit.
  • Save expense_tracker.py in IDLE.
  • Click Run in the IDLE menu bar.
  • Select Run Module to start the tracker.

You should see the Personal Expense Tracker heading with options 1 through 3. The Shell waits at Choose an option:.

Menu not appearing?

  • Confirm that main() appears at the bottom of expense_tracker.py.
  • Check that the menu lines remain indented inside the while True loop.

Help me fix an expense tracker that does not show its menu after Run Module.

✔️ Awesome, I've got everything!

Your input function, display function, menu loop, and main() call are in place.

ⓧ I'd like to double check the full code

  • Compare your saved expense_tracker.py file with this complete in-memory version:
expenses = []


def add_expense(expenses):
    amount = input("Amount: $").strip()
    category = input("Category: ").strip()
    description = input("Description: ").strip()

    expense = {
        "amount": amount,
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    print("Expense added.")


def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${expense['amount']} | "
            f"{expense['category']} | {expense['description']}"
        )


def main():
    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, or 3.")


main()

The menu now keeps the program active until you choose Exit. One complete run can hold several expenses in the same list.

  • Enter 1 at the menu prompt.
  • Enter 12.50 at the amount prompt.
  • Enter Food at the category prompt.
  • Enter Lunch at the description prompt.
  • Enter 2 at the next menu prompt.

You should see the saved row beneath the Expenses heading. This confirms that the entry remains available throughout the current run.

  • Enter 3 at the next menu prompt to exit.

Before you start a second run, do you think the expense is still available to the program?

  • Click Run in the IDLE menu bar.
  • Select Run Module to start a new run.
  • Enter 2 at the menu prompt.

You should see No expenses recorded. in the Shell. That empty result is intentional because the previous expense lived only in the earlier program run.

Your tracker can collect expenses through a menu, but the restart exposed its memory limit. Next up, you will give each new expense a durable home in expenses.csv.

Save Expenses to CSV

Your tracker can collect expenses during a run. The restart test exposed the gap: every entry disappears when the program closes.

A CSV file gives each expense a durable row on disk. The file can keep those rows after the active list disappears.

You'll keep working in the existing IDLE editor. In this step, each new expense gains a permanent home beside your script.

In this step, get ready to:
  • Define the CSV location plus its column names.
  • Create a function that appends one expense to the file.
  • Connect every new entry to the save function.
Connect the script to its CSV file

The script needs a reliable path to its data file. Shared field names also keep every saved row in the same column order.

  • Switch back to the open expense_tracker.py Editor window.
  • Place your cursor before the expenses = [] line.
  • Paste the file settings by copying this code above that line:
import csv
from pathlib import Path

DATA_FILE = Path(__file__).with_name("expenses.csv")
FIELDNAMES = ["amount", "category", "description"]

What does this setup define?

  • The csv module provides tools for writing dictionary data as CSV rows.
  • The DATA_FILE path places expenses.csv beside expense_tracker.py.
  • The FIELDNAMES list fixes the column order for every row.
  • Save expense_tracker.py.
  • Select Run in the IDLE menu bar.
  • Select Run Module.
  • Enter 3 to exit the tracker.
  • Ask the Shell to display the new file path by entering:
DATA_FILE

What does this check show?

You should see a path ending with Documents\python-expense-tracker\expenses.csv. Your tracker now knows exactly where its CSV file belongs.

Path check not working?

Confirm that you saved expense_tracker.py before selecting Run Module. Check that DATA_FILE appears above expenses = [].

Help me fix the CSV path setup in my expense tracker.

Write one expense to CSV

A dedicated save function keeps the file-writing rules in one place. Each call receives one expense dictionary.

  • Find the expenses = [] line in expense_tracker.py.
  • Place your cursor on the blank line before def add_expense(expenses):.
  • Add the CSV writer by pasting this function:
def save_expense(expense):
    needs_header = not DATA_FILE.exists() or DATA_FILE.stat().st_size == 0

    with DATA_FILE.open("a", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
        if needs_header:
            writer.writeheader()
        writer.writerow(expense)

What does this function do?

  • The needs_header check detects a missing file or an empty file.
  • The "a" mode appends each new row without replacing earlier expenses.
  • The newline="" setting keeps CSV line handling consistent.
  • The encoding="utf-8" setting defines the file's text encoding.
  • The writeheader() call adds the three column names when needed.
  • The writerow(expense) call appends the expense dictionary as one row.
  • Save expense_tracker.py.
  • Select Run in the IDLE menu bar.
  • Select Run Module.
  • Enter 3 to exit the tracker.
  • Confirm that Python recognizes the writer by entering this name in the Shell:
save_expense

What should you see?

You should see Python identify save_expense as a function. You've got the CSV writer ready for real expenses.

Writer not recognized?

Check that save_expense starts at the far-left edge of the Editor. Confirm that the function appears before add_expense.

Help me find the problem in my save_expense function.

Save each new expense

The writer only runs when add_expense() calls it. The existing append line marks the exact point where a complete expense is ready to save.

  • Locate the end of add_expense() in expense_tracker.py.
  • Find these current lines:
    expenses.append(expense)
    print("Expense added.")

What happens here now?

The first line updates the active list. The second line confirms the earlier in-memory behavior.

  • Insert save_expense(expense) directly below expenses.append(expense).
  • Replace print("Expense added.") with print("Expense saved.").
  • Compare the edited lines with this result:
    expenses.append(expense)
    save_expense(expense)
    print("Expense saved.")

What changes with this call?

  • The expense still enters the active expenses list.
  • The same dictionary now passes to save_expense() for file storage.
  • The updated message confirms that both operations finished.

✔️ Awesome, I've got everything!

Your script now includes the path settings plus the CSV writer. The new function call connects every added expense to that writer.

ⓧ I'd like to double check the full code

import csv
from pathlib import Path

DATA_FILE = Path(__file__).with_name("expenses.csv")
FIELDNAMES = ["amount", "category", "description"]
expenses = []


def save_expense(expense):
    needs_header = not DATA_FILE.exists() or DATA_FILE.stat().st_size == 0

    with DATA_FILE.open("a", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
        if needs_header:
            writer.writeheader()
        writer.writerow(expense)


def add_expense(expenses):
    amount = input("Amount: $").strip()
    category = input("Category: ").strip()
    description = input("Description: ").strip()

    expense = {
        "amount": amount,
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    save_expense(expense)
    print("Expense saved.")


def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${expense['amount']} | "
            f"{expense['category']} | {expense['description']}"
        )


def main():
    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, or 3.")


main()

What should match?

This reference shows the complete expense_tracker.py file for this step. The import lines belong at the top.

Before you run the tracker, where do you expect the next expense to exist after the program exits?

  • Save expense_tracker.py.
  • Select Run in the IDLE menu bar.
  • Select Run Module.
  • Enter 1 at the menu prompt.
  • Enter 12.50 at the Amount: $ prompt.
  • Enter Food at the Category: prompt.
  • Enter Lunch at the Description: prompt.
  • Enter 3 at the next menu prompt.

You should see Expense saved. after the description. You should see Goodbye. after choosing 3.

What just changed?

The expense now exists in the list plus expenses.csv. The file remains in your project folder after the program exits.

  • Press the Windows key to open the search bar.
  • Type File Explorer into the search bar.
  • Press Enter.
  • Select Documents in the left sidebar.
  • Open the python-expense-tracker folder.
  • Open expenses.csv.

The first row should show amount,category,description. The next row should contain 12.50,Food,Lunch.

CSV file missing or empty?

  • Confirm that save_expense(expense) sits directly below expenses.append(expense).
  • Confirm that you saved expense_tracker.py before selecting Run Module.
  • Add an expense through option 1 before checking the project folder.

Help me find why expenses.csv was not created or does not contain my expense.

That's the persistence breakthrough: your new expense now survives outside the running program.

Your tracker now preserves every new expense in a CSV file. Next, you'll rebuild the active list from those saved rows whenever the program starts.

Reload Expenses and Calculate Totals

Your tracker already saves each new expense in a CSV file. However, each run still starts from the empty list in the script.

This step teaches startup to rebuild that list from the saved rows. You will also turn the expense display into a spending summary.

In this step, get ready to:
  • Define a loader that rebuilds the expense list from expenses.csv.
  • Connect the loader to the start of main().
  • Display numbered rows with their combined total.
Load saved rows from the CSV file

The load_expenses() function handles both possible startup states. It returns an empty list when the data file is missing.

  • In the IDLE editor, select the code from import csv through the FIELDNAMES line.
  • Replace the selected code with the block below:
import csv
from pathlib import Path

DATA_FILE = Path(__file__).with_name("expenses.csv")
FIELDNAMES = ["amount", "category", "description"]


def load_expenses():
    if not DATA_FILE.exists():
        return []

    with DATA_FILE.open("r", newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))

What does the loader do?

  • The DATA_FILE.exists() check lets a first run begin with an empty list.
  • The read mode opens the saved data without changing its rows.
  • The csv.DictReader(file) call uses the header to turn every row into a dictionary.
  • The list() call collects those dictionaries for the tracker.
  • Save expense_tracker.py.
  • From the IDLE menu bar, click Run.
  • Select Run Module.
  • Enter 3 at the menu prompt to return to the Shell.
  • Test the loader at the Shell prompt by entering the expression below:
load_expenses()

What should I see?

If expenses.csv exists, the Shell displays a list containing your saved expense dictionaries. If the file does not exist, the Shell displays an empty list.

Good progress. Your saved file can now be reconstructed as Python data.

Loader not returning your saved rows?

  • Confirm that load_expenses() appears below FIELDNAMES.
  • Confirm that expenses.csv is beside expense_tracker.py in Documents\python-expense-tracker.
  • Check that the file opens with read mode "r".

Help me diagnose my CSV loader.

Connect loading to startup

The loader must run each time main() begins. Its returned list becomes the shared expense collection for that run.

  • Delete the global expenses = [] line below FIELDNAMES.
  • Inside main(), add expenses = load_expenses() directly below the function definition.
  • Compare the opening of main() with this block:
def main():
    expenses = load_expenses()

    while True:

Why load inside main?

The local expenses variable receives every saved row when the tracker starts. The menu functions then work with that same list.

Every new run begins from the contents of expenses.csv.

  • Save expense_tracker.py.
  • From the IDLE menu bar, click Run.
  • Select Run Module.
  • Enter 2 at the menu prompt.

If you saved an expense earlier, you should see it return in the Shell. If no CSV file exists yet, you should see No expenses recorded.

Saved rows still missing?

  • Confirm that expenses = load_expenses() is indented inside main().
  • Remove any remaining global expenses = [] line.
  • Confirm that IDLE is running expense_tracker.py from Documents\python-expense-tracker.

Help me fix startup loading.

Calculate the overall total

CSV values are loaded as text. The display converts each saved amount to a number before formatting it or adding it to the total.

  • Find the show_expenses(expenses) function in expense_tracker.py.
  • Select the complete function.
  • Replace the selected function with the block below:
def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${float(expense['amount']):.2f} | "
            f"{expense['category']} | {expense['description']}"
        )

    total = sum(float(expense["amount"]) for expense in expenses)
    print(f"Total spent: ${total:.2f}")

How is the total calculated?

  • The float() calls convert saved amount strings into numbers.
  • The .2f format displays every amount with two decimal places.
  • The sum() call combines every converted amount.
  • The final formatted string displays the result after Total spent:.

Before you run the two-pass check, do you think an expense added during the first run will still appear after the Shell restarts?

  • Save expense_tracker.py.
  • From the IDLE menu bar, click Run.
  • Select Run Module.
  • Enter 1 at the menu prompt.
  • Enter 12.50 for the amount.
  • Enter Transport for the category.
  • Enter Bus ticket for the description.
  • Enter 3 at the menu prompt to exit.

The first run has now saved the new expense to expenses.csv.

  • From the IDLE menu bar, click Run.
  • Select Run Module.
  • Enter 2 at the menu prompt.

You should see numbered saved rows in the Shell. The list should include Bus ticket followed by a Total spent: line.

That is the full persistence loop working. Your tracker now saves expenses during one run and restores them during the next.

Tracker stops while showing expenses?

  • Confirm that each amount value in expenses.csv contains a number.
  • Check that both amount conversions use float().
  • Check that the total calculation remains inside show_expenses(expenses).

Help me troubleshoot my spending total.

✔️ Awesome, I've got everything!

Great work. Save expense_tracker.py once more before moving on.

ⓧ I'd like to double check the full code

import csv
from pathlib import Path

DATA_FILE = Path(__file__).with_name("expenses.csv")
FIELDNAMES = ["amount", "category", "description"]


def load_expenses():
    if not DATA_FILE.exists():
        return []

    with DATA_FILE.open("r", newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))


def save_expense(expense):
    needs_header = not DATA_FILE.exists() or DATA_FILE.stat().st_size == 0

    with DATA_FILE.open("a", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
        if needs_header:
            writer.writeheader()
        writer.writerow(expense)


def add_expense(expenses):
    amount = input("Amount: $").strip()
    category = input("Category: ").strip()
    description = input("Description: ").strip()

    expense = {
        "amount": amount,
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    save_expense(expense)
    print("Expense saved.")


def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${float(expense['amount']):.2f} | "
            f"{expense['category']} | {expense['description']}"
        )

    total = sum(float(expense["amount"]) for expense in expenses)
    print(f"Total spent: ${total:.2f}")


def main():
    expenses = load_expenses()

    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, or 3.")


main()

Your tracker now remembers spending across restarts and calculates the overall total. Next, you will protect the saved data with input validation before adding category summaries.

Validate Entries and Summarize Categories

Your tracker now reloads saved spending from CSV data. That persistence makes every entry matter because mistakes survive the next restart.

Persistent bad data is harder to repair than a rejected entry. In this step, you will validate each field before saving it. You will also add category totals that turn the ledger into a useful summary.

In this step, get ready to:
  • Reject invalid amounts with clear feedback.
  • Reject blank categories and descriptions.
  • Calculate spending totals for each category.
Reject invalid amounts

Every amount starts as text from the keyboard. Exception handling lets your tracker catch text that cannot become a number. A loop can then keep asking until the learner enters a positive amount.

  • In expense_tracker.py, place get_amount() between save_expense() and add_expense() by copying the code below:
def get_amount():
    while True:
        raw_amount = input("Amount: $").strip()
        try:
            amount = float(raw_amount)
            if amount <= 0:
                print("Amount must be greater than zero.")
                continue
            return amount
        except ValueError:
            print("Enter a number, such as 12.50.")

What does this code do?

  • The while True loop keeps the amount prompt active until the input passes every check.
  • The try block attempts to convert the entered text into a floating-point number.
  • The except ValueError block catches text such as abc without stopping the program.
  • The positive-number check rejects zero and negative spending values.
  • Save expense_tracker.py.
  • Select Run > Run Module in IDLE.

Before you call the helper, what do you expect it to do after receiving text or a negative number?

  • Call the helper directly from the Shell by entering the code below:
get_amount()

What does this check prove?

  • Calling the helper directly isolates its validation loop from the rest of the tracker.
  • The function returns control to the Shell only after it receives a positive number.
  • Enter abc at the amount prompt.

The Shell displays Enter a number, such as 12.50. before showing the amount prompt again.

  • Enter -5 at the new amount prompt.

The Shell displays Amount must be greater than zero. before asking again.

  • Enter 12.50 at the next amount prompt.

The helper accepts the value. You should see 12.5 returned at the Shell prompt.

You now have a numeric gate that keeps malformed amounts away from the tracker. That is the first layer of protection for your saved spending.

Does the helper stop unexpectedly?

  • Check that except ValueError: lines up with try:.
  • Check that the validation lines inside while True: use consistent indentation.
  • Confirm that return amount sits after the positive-number check.

Help me fix my amount validation loop.

Reject blank text

A valid amount still needs a useful category and description. A shared text helper applies the same non-empty rule to both fields.

  • Place get_text() directly below get_amount() by copying the code below:
def get_text(prompt):
    while True:
        value = input(prompt).strip()
        if value:
            return value
        print("This field cannot be blank.")

How does text validation work?

  • The prompt parameter lets one helper validate either the category or description field.
  • The strip() call removes surrounding spaces before the helper checks the value.
  • A value containing visible text is returned to add_expense().
  • A blank value keeps the loop active and displays a clear message.
  • Replace the existing add_expense() function with the version below:
def add_expense(expenses):
    amount = get_amount()
    category = get_text("Category: ").title()
    description = get_text("Description: ")

    expense = {
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    save_expense(expense)
    print("Expense saved.")

How does the expense stay clean?

  • The function collects every field through a validation helper before building the expense dictionary.
  • The .2f format stores the accepted amount with two decimal places.
  • The title() call normalizes categories such as food to Food.
  • The validated dictionary reaches save_expense() only after every field is complete.

Before you run the module, which attempted entries do you think can reach save_expense()?

  • Save expense_tracker.py.
  • Select Run > Run Module in IDLE.
  • Enter 1 at the menu.
  • Enter abc at the amount prompt.

You should see Enter a number, such as 12.50. The tracker asks for the amount again.

  • Enter -5 at the new amount prompt.

You should see Amount must be greater than zero. The tracker keeps the amount prompt active.

  • Enter 12.50 at the next amount prompt.
  • Press Enter at the category prompt to submit a blank value.

You should see This field cannot be blank. The tracker asks for the category again.

  • Enter food at the category prompt.
  • Press Enter at the description prompt to submit a blank value.

You should see This field cannot be blank. The tracker asks for the description again.

  • Enter Lunch at the description prompt.

You should see Expense saved. Only the complete expense is added to the CSV file.

  • Enter 2 at the menu.

The expense list should include $12.50 with the category Food and the description Lunch.

Are invalid fields still being saved?

  • Confirm that add_expense() calls get_amount() instead of calling input() for the amount.
  • Confirm that both text fields call get_text().
  • Check that save_expense(expense) remains below the completed expense dictionary.

Help me find why my tracker accepts an invalid expense.

Summarize spending by category

Aggregation combines multiple expense rows into useful totals. A dictionary can hold one running total for each category found in the saved ledger.

  • Place show_category_totals() between show_expenses() and main() by copying the code below:
def show_category_totals(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    totals = {}
    for expense in expenses:
        category = expense["category"]
        if category not in totals:
            totals[category] = 0
        totals[category] += float(expense["amount"])

    print("\nCategory totals")
    for category in sorted(totals):
        print(f"{category}: ${totals[category]:.2f}")

How are the totals calculated?

  • The empty-list branch gives the learner clear feedback when there is nothing to summarize.
  • The totals dictionary uses each category as a key.
  • Each expense amount is converted to a number before it is added to the matching category total.
  • The final loop sorts the category names before displaying every total with two decimal places.
  • Replace the existing main() function with the version below:
def main():
    expenses = load_expenses()

    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Show category totals")
        print("4. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            show_category_totals(expenses)
        elif choice == "4":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, 3, or 4.")

How does the menu change?

  • Option 3 now calls show_category_totals() with the loaded expense list.
  • The Exit action moves to option 4.
  • The fallback message now lists every valid menu choice.

Before you run the finished tracker, do you expect either invalid amount to reach the category prompt?

  • Save expense_tracker.py.
  • Select Run > Run Module in IDLE.
  • Enter 1 at the menu.
  • Enter abc at the amount prompt.

The tracker rejects the text with Enter a number, such as 12.50. It keeps the amount prompt active.

  • Enter -5 at the new amount prompt.

The tracker rejects the negative value with Amount must be greater than zero. It asks for the amount again.

  • Enter 8.25 at the next amount prompt.
  • Enter transport at the category prompt.
  • Enter Bus fare at the description prompt.

You should see Expense saved. The menu appears again with category totals as option 3.

  • Enter 3 at the menu.

The Shell displays Category totals followed by one line for each category. You should see a line for Transport with its combined amount.

  • Enter 4 at the menu.

The Shell displays Goodbye. before the tracker exits.

Missing the category totals option?

  • Confirm that the menu prints 3. Show category totals.
  • Check that the option 3 branch calls show_category_totals(expenses).
  • Confirm that Exit uses option 4 in both the menu and its condition.

Help me fix my category totals menu option.

✔️ Awesome, I've got everything!

Your file now contains the complete core expense tracker.

  • Save expense_tracker.py before moving on.

ⓧ I'd like to double check the full code

import csv
from pathlib import import Path

DATA_FILE = Path(__file__).with_name("expenses.csv")
FIELDNAMES = ["amount", "category", "description"]


def load_expenses():
    if not DATA_FILE.exists():
        return []

    with DATA_FILE.open("r", newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))


def save_expense(expense):
    needs_header = not DATA_FILE.exists() or DATA_FILE.stat().st_size == 0

    with DATA_FILE.open("a", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
        if needs_header:
            writer.writeheader()
        writer.writerow(expense)


def get_amount():
    while True:
        raw_amount = input("Amount: $").strip()
        try:
            amount = float(raw_amount)
            if amount <= 0:
                print("Amount must be greater than zero.")
                continue
            return amount
        except ValueError:
            print("Enter a number, such as 12.50.")


def get_text(prompt):
    while True:
        value = input(prompt).strip()
        if value:
            return value
        print("This field cannot be blank.")


def add_expense(expenses):
    amount = get_amount()
    category = get_text("Category: ").title()
    description = get_text("Description: ")

    expense = {
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }
    expenses.append(expense)
    save_expense(expense)
    print("Expense saved.")


def show_expenses(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    print("\nExpenses")
    for number, expense in enumerate(expenses, start=1):
        print(
            f"{number}. ${float(expense['amount']):.2f} | "
            f"{expense['category']} | {expense['description']}"
        )

    total = sum(float(expense["amount"]) for expense in expenses)
    print(f"Total spent: ${total:.2f}")


def show_category_totals(expenses):
    if not expenses:
        print("No expenses recorded.")
        return

    totals = {}
    for expense in expenses:
        category = expense["category"]
        if category not in totals:
            totals[category] = 0
        totals[category] += float(expense["amount"])

    print("\nCategory totals")
    for category in sorted(totals):
        print(f"{category}: ${totals[category]:.2f}")


def main():
    expenses = load_expenses()

    while True:
        print("\nPersonal Expense Tracker")
        print("1. Add expense")
        print("2. Show expenses")
        print("3. Show category totals")
        print("4. Exit")

        choice = input("Choose an option: ").strip()

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            show_expenses(expenses)
        elif choice == "3":
            show_category_totals(expenses)
        elif choice == "4":
            print("Goodbye.")
            break
        else:
            print("Choose 1, 2, 3, or 4.")


main()

What should match?

  • The validation helpers should appear above add_expense().
  • The expense dictionary should store a formatted amount and a title-cased category.
  • The category summary should appear above main().
  • The menu should contain options 1 through 4.

That is the core tracker complete. Your program now rejects malformed entries before they reach expenses.csv.

It also turns saved spending into category totals you can use. Your persistent tracker is ready to run and demonstrate.

Secret mission

Filter Expenses by Category

Category totals show how much you spent in each area. Add a case-insensitive filter that reveals the individual expenses behind one category and calculates their combined amount.

Clean Up Your Resources

Clean Up Your Resources

Your tracker runs entirely on your Windows computer. Both project files cost $0 to keep, so there are no cloud resources or ongoing charges.

Resources you used:

  • The expense_tracker.py Python application source file in Documents\python-expense-tracker. Cost: $0.
  • The expenses.csv local CSV data file in Documents\python-expense-tracker. Cost: $0.

Keep everything running

No action is needed. Choose this if you want to keep using your expense tracker with its saved spending history.

  • Leave expense_tracker.py in Documents\python-expense-tracker.
  • Keep expenses.csv beside the script so your validated expense records load during future runs.

Pause - I'll come back to this later

Stop the tracker to close its current session while preserving your script and saved expense history. You can run the same module again whenever you are ready to continue.

  • Enter 5 at the Choose an option: prompt in the IDLE Shell from earlier.
  • Close IDLE.
  • Leave the Documents\python-expense-tracker folder on your computer.

Your records remain in expenses.csv for the next run.

Delete - I don't want to use this again

Remove all local project resources when you no longer need the tracker. Deleting your saved spending history can feel risky, so continue only when you are ready to lose both files.

  • Enter 5 at the Choose an option: prompt in the IDLE Shell from earlier.
  • Close IDLE.
  • Press the Windows key to open Windows search.
  • Type File Explorer and press Enter to open it.
  • Select Documents in the left sidebar.
  • Right-click the python-expense-tracker folder.
  • Select Delete.
  • Confirm that python-expense-tracker no longer appears in Documents.

This removes both expense_tracker.py and expenses.csv from the project location.

Nice Work!

Nice Work!

You did it! Your Python expense tracker now turns spending entries into a persistent record you can review after every restart.

What you learned:

  • Built a menu-driven application that responds to numbered choices. Its functions keep each expense workflow organized.
  • Experienced the limits of in-memory state by restarting the tracker. Added CSV persistence so records saved in expenses.csv return automatically.
  • Protected saved records with input validation. Calculated an overall spending total. Produced category totals from the restored expense data.
  • Secret Mission: Added case-insensitive category filtering to find matching expenses. Calculated a filtered total for the selected category.

Ready to quiz yourself?