Build a Personal Expense Tracker CLI

Build a multi-file Python CLI app that tracks expenses with JSON persistence.

Introduction

30 Second Summary

Keeping track of where your money goes is one of those things everyone means to do but few stick with. What if you could build your own tool to do it, right from the terminal?

In this project, you will build a personal expense tracker CLI in Python that lets you log expenses, filter them by category or month, and generate spending reports.

What You'll Build

A multi-file Python command-line application that persists expense data in JSON and provides filtering and reporting features.

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

  • A working multi-file Python project split into four modules, each with a single responsibility.
  • Persistent data storage using JSON so your app remembers expenses between sessions.
  • Filtering and reporting capabilities that let you view expenses by category, month, or as a full spending summary.
  • Secret Mission: Add a monthly budget feature that warns you when you're close to overspending.

Are there any prerequisites?

This project assumes you have basic Python knowledge including variables, loops, conditionals, functions, and dictionaries. You do not need any prior experience with multi-file projects or JSON.

Before We Start

Before writing any code, you need a clean project structure in place. A well-organized project starts with separate files for separate responsibilities.

In this step, you will create the project folder and the four Python files that make up your expense tracker. You will also confirm that Python runs correctly on your machine.

In this step, get ready to:
  • Create the project folder and four Python files.
  • Verify Python is working in the VS Code terminal.
  • Understand how the four files fit together.
Create the project folder and files

Your expense tracker lives inside a single folder with four Python files. Each file will handle one specific job in the app.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab on most keyboards).

What is the integrated terminal?

The integrated terminal is a command-line panel built into VS Code. It lets you run commands without leaving your editor.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir expense_tracker
cd expense_tracker
  • Create the four Python files by running this command:

macOS

touch main.py tracker.py storage.py reports.py

Windows

New-Item -ItemType "File" -Path "main.py", "tracker.py", "storage.py", "reports.py"
  • Confirm the files exist by running:
ls

You should see main.py, tracker.py, storage.py, and reports.py listed in the output.

  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the expense_tracker folder, and click Open.

You should now see the four Python files listed in the Explorer sidebar on the left.

Don't see the files in the Explorer?

Make sure you opened the expense_tracker folder itself, not the Desktop folder. If the Explorer sidebar is hidden, press Cmd+B (macOS) or Ctrl+B (Windows) to toggle it.

Help me troubleshoot my Explorer sidebar

Verify Python is working

Before writing any application code, you need to confirm that Python runs correctly inside VS Code's terminal.

  • In the integrated terminal, check your Python version by running:
python --version

✔️ I see a version number

Python is ready to go. The version number confirms your installation is working.

ⓧ Command not found

Your system may use python3 instead of python. Try running:

python3 --version

If this shows a version number, use python3 instead of python for all commands throughout this project.

If neither command works, revisit the Python installation at python.org/downloads and install the latest version.

  • Confirm Python can run code by running this command:
python -c "print('Ready to build!')"

You should see Ready to build! printed in the terminal.

Seeing a syntax error?

Make sure you copied the command exactly, including the double quotes around the print statement and the single quotes inside it. On Windows PowerShell, you may need to use single quotes on the outside and double quotes inside instead.

Help me fix this syntax error

Understand the architecture

Your four files each have a single responsibility. This pattern is called separation of concerns, and it is how professional Python projects are organized.

  • storage.py handles reading and writing data to a file on disk.
  • tracker.py holds the business logic for adding and filtering expenses.
  • reports.py generates spending summaries and category breakdowns.
  • main.py ties everything together with a menu the user interacts with.

Why split into multiple files?

Keeping each responsibility in its own file makes your code easier to read, test, and update. If you need to change how data is stored, you only touch storage.py without risking the rest of the app.

This approach also teaches you how Python imports work between your own files. Each file becomes a module that other files can pull functions from.

Your project skeleton is ready. Next up, you will write real Python code in storage.py to save and load expense data.

Set Up the Project Structure

Before writing any code, you need to know the lay of the land. Your project folder already contains the four files you'll be working with throughout this project.

In this step, you'll verify everything is in place and learn what role each file plays. Understanding the structure upfront makes it easier to navigate as you add code in later steps.

In this step, get ready to:
  • Verify the project files are in place.
  • Run a quick Python sanity check.
  • Understand the role of each file in the project.
Verify your project files

Your expense_tracker folder should already be open in Visual Studio Code.

  • Look at the Explorer sidebar on the left side of VS Code.
  • Confirm you see four files listed: main.py, tracker.py, storage.py, and reports.py.
  • Confirm the files exist in your terminal by running:
ls

You should see all four Python files listed in the output.

Don't see all four files?

Make sure your terminal is inside the expense_tracker folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.

If you're in the wrong folder, close and reopen the terminal from within VS Code while the expense_tracker folder is open. Help me find my project files in the terminal.

Run a Python sanity check

You've already confirmed that Python is installed by checking the version. Now confirm Python can actually execute code from your terminal.

  • Run this quick one-liner to confirm Python executes code correctly:
python -c "print('Ready to build!')"

You should see Ready to build! printed in your terminal.

Seeing an error or no output?

On some systems, the command is python3 instead of python. Try running python3 -c "print('Ready to build!')" instead.

If neither works, confirm Python is in your system PATH. Help me get Python working in my VS Code terminal.

Understand the architecture

Each of the four files in your project has a single responsibility. This pattern is called separation of concerns.

  • storage.py handles reading and writing data to a JSON file.
  • tracker.py holds the business logic for adding, listing, and filtering expenses.
  • reports.py generates spending summaries and category breakdowns.
  • main.py is the entry point that ties everything together with a menu loop.

Why split into multiple files?

You could write everything in one big file, but that gets messy fast. Splitting by responsibility means you can test, debug, and update each piece independently.

This is how professional Python projects are organized. If your storage logic has a bug, you know exactly where to look.

How do the files talk to each other?

Python lets you import functions from one file into another using from module import function syntax. For example, tracker.py will import saving functions from storage.py.

Since all four files live in the same folder, Python can find them automatically when you run main.py from that folder.

Your project structure is ready and Python is running. Next up, you'll build the storage layer that reads and writes expense data to a JSON file.

Build the Storage Layer

Your project structure is in place and Python is confirmed working. Now it's time to write the first real code for your expense tracker.

Every app that stores data needs a way to save and load it between sessions. You'll build storage.py first because it's the foundation that every other module will depend on.

In this step, get ready to:
  • Write a function to load expenses from a JSON file.
  • Write a function to save expenses to a JSON file.
  • Test the storage module to confirm it works in isolation.
Write the load_expenses() function

The storage module needs to read expense data from a JSON file. If the file doesn't exist yet (like when the app runs for the first time), the function should return an empty list instead of crashing.

  • In VS Code, open storage.py from the Explorer sidebar.
  • Add the following code to storage.py:
import json
import os

DATA_FILE = "expenses.json"


def load_expenses():
    """Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
    try:
        with open(DATA_FILE, "r") as f:
            expenses = json.load(f)
    except FileNotFoundError:
        expenses = []
    return expenses

What does this code do?

This sets up the foundation for reading expense data from disk.

  • DATA_FILE = "expenses.json" stores the filename in one place. If you ever rename the file, you only change it here.
  • json.load(f) reads the JSON file and converts it into a Python list of dictionaries.
  • The try/except FileNotFoundError block handles the first-ever run when no expenses file exists yet. Instead of crashing, it returns an empty list.
  • Save storage.py.

You won't see any output yet. The next chunk adds the save function so you can test both together.

Write the save_expenses() function

Now you need a way to write expense data back to disk. This function takes a list of expenses and writes them into the JSON file.

  • Below the load_expenses() function in storage.py, add the save_expenses() function:
def save_expenses(expenses):
    """Save the expenses list to the JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(expenses, f, indent=2)

What does this code do?

  • json.dump(expenses, f, indent=2) converts the Python list into JSON format and writes it to the file.
  • The indent=2 argument formats the JSON with 2 spaces of indentation, making it human-readable if you open the file later.
  • The "w" mode overwrites the file each time, so the entire expenses list is saved fresh on every write.
  • Save storage.py.

You still can't run the file yet. The next chunk adds a test block to verify everything works.

Test the storage module in isolation

Python has a special pattern for testing files on their own. When you run a file directly, Python sets a variable called __name__ to "__main__". Code inside an if __name__ == "__main__" block only runs when you execute the file directly, not when another file imports it.

  • At the bottom of storage.py, below the save_expenses() function, add this test block:
if __name__ == "__main__":
    # Quick test
    test_data = [{"amount": 12.50, "category": "food", "description": "Lunch", "date": "2026-05-21"}]
    save_expenses(test_data)
    loaded = load_expenses()
    print(f"Saved and loaded {len(loaded)} expense(s). Storage is working!")

What does this test do?

  • It creates a sample expense dictionary inside a list.
  • It calls save_expenses() to write that data to expenses.json.
  • It calls load_expenses() to read it back and prints how many expenses were loaded.
  • If both functions work correctly, you'll see a success message confirming the round-trip.
  • Save storage.py.
  • Run the storage module to test it by running this command in the terminal:
python storage.py

You should see Saved and loaded 1 expense(s). Storage is working! printed in the terminal.

  • Check the Explorer sidebar in VS Code. A new expenses.json file should now appear inside your expense_tracker folder.

Seeing an error when you run the file?

  • If you see IndentationError, check that each line inside your functions is indented with 4 spaces. Mixing tabs and spaces causes this error.
  • If you see NameError: name 'save_expenses' is not defined, make sure the if __name__ block is below both function definitions, not between them.
  • If nothing happens at all, make sure you're running python storage.py from inside the expense_tracker folder. Run cd expense_tracker first if needed.

Still stuck? Help me debug my storage.py file.

✔️ Awesome, I've got everything!

Great. Double check you've saved storage.py and that expenses.json appeared in your folder.

ⓧ I'd like to double check the full code

Here's the complete storage.py file. Compare it against yours:

import json
import os

DATA_FILE = "expenses.json"


def load_expenses():
    """Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
    try:
        with open(DATA_FILE, "r") as f:
            expenses = json.load(f)
    except FileNotFoundError:
        expenses = []
    return expenses


def save_expenses(expenses):
    """Save the expenses list to the JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(expenses, f, indent=2)


if __name__ == "__main__":
    # Quick test
    test_data = [{"amount": 12.50, "category": "food", "description": "Lunch", "date": "2026-05-21"}]
    save_expenses(test_data)
    loaded = load_expenses()
    print(f"Saved and loaded {len(loaded)} expense(s). Storage is working!")

Your storage layer is locked in. Both functions work, and expenses.json is ready to hold real data. Next up, you'll build the tracker logic that uses this storage to add and filter expenses.

Build the Tracker Logic

Your storage layer can read and write data to disk. Now it's time to build the brains of the app: the tracker logic that creates, retrieves, and filters expenses.

The tracker.py module handles all the business logic. It knows what an expense looks like, how to create one, and how to search through existing ones.

In this step, get ready to:
  • Write a function that creates and saves new expenses.
  • Write filter functions that find expenses by category or month.
  • Write a retrieval function that loads all stored expenses.
Write the add_expense function

The add_expense() function is the core action of your tracker. It builds an expense dictionary with today's date, appends it to the list, and immediately saves everything to disk.

  • Open tracker.py in VS Code (it should be empty).
  • Add the imports and the add_expense() function by copying this code:
import datetime

from storage import load_expenses, save_expenses


def add_expense(expenses, amount, category, description):
    """Create a new expense and add it to the list."""
    expense = {
        "amount": amount,
        "category": category.lower(),
        "description": description,
        "date": datetime.date.today().isoformat(),
    }
    expenses.append(expense)
    save_expenses(expenses)
    return expenses

What does this code do?

The two imports at the top bring in everything this module needs. datetime gives you today's date, and the storage import pulls in the functions you wrote in the previous step.

  • category.lower() normalizes category names so "Food" and "food" are treated as the same category.
  • datetime.date.today().isoformat() generates today's date as a string like "2026-05-21". This format sorts correctly and is easy to filter later.
  • save_expenses(expenses) writes the updated list to disk immediately, so no data is lost between sessions.
  • Save tracker.py.
  • You won't see a change yet. The filter functions in the next chunk complete the module.

Seeing a red underline on the import?

VS Code may show a warning on from storage import ... because its linter hasn't found storage.py yet. This is safe to ignore as long as both files live in the same expense_tracker folder.

help me fix an import warning in VS Code for my tracker.py file

Write the filter functions

Filtering is what makes the tracker useful. Instead of scrolling through every expense, you can ask for just the ones matching a specific category or month.

  • Add the two filter functions below add_expense() in tracker.py:
def filter_by_category(expenses, category):
    """Return expenses matching the given category."""
    return [e for e in expenses if e["category"] == category.lower()]


def filter_by_month(expenses, year, month):
    """Return expenses matching the given year and month."""
    target = f"{year:04d}-{month:02d}"
    return [e for e in expenses if e["date"].startswith(target)]

What does this code do?

Both functions use list comprehensions to filter expenses. A list comprehension is a one-line loop that builds a new list from an existing one, keeping only items that pass a condition.

  • filter_by_category() compares each expense's category to the one you're searching for (lowercased for consistency).
  • filter_by_month() builds a target string like "2026-05" and checks if each expense's date starts with it. This works because dates are stored in YYYY-MM-DD format.
  • Save tracker.py.
  • You won't see a change yet. The next function completes the module so you can test everything together.

Confused by list comprehension syntax?

The pattern is [expression for item in list if condition]. Read it like English: "give me expression for each item in list if condition is true."

explain list comprehension syntax to me with a simple example

Write the get_all_expenses function

The last piece is a simple retrieval function. It wraps the load_expenses() call from storage.py, keeping the rest of the app from needing to know about the storage layer directly.

  • Add the get_all_expenses() function below the filter functions in tracker.py:
def get_all_expenses():
    """Load and return all expenses."""
    return load_expenses()

Why wrap a single function call?

This looks like an unnecessary wrapper, but it serves a purpose. Other modules (like main.py) will call get_all_expenses() without needing to know that data comes from a JSON file.

If you later swap JSON for a database, you only change storage.py. The rest of the app keeps working unchanged. This is the benefit of separating concerns across files.

  • Save tracker.py.
  • Test the completed module by running this command in your terminal:
python -c "from tracker import get_all_expenses; print(get_all_expenses())"
  • You should see the test expense from the previous step printed in your terminal:
[{'amount': 12.5, 'category': 'food', 'description': 'Lunch', 'date': '2026-05-21'}]

Seeing ModuleNotFoundError?

Make sure your terminal is inside the expense_tracker folder. Python looks for storage.py in the current directory. Run cd ~/Desktop/expense_tracker and try again.

help me fix a ModuleNotFoundError when importing tracker

✔️ Awesome, I've got everything!

Great. Double check you've saved tracker.py and move on.

ⓧ I'd like to double check the full code

Here is the complete tracker.py file. Make sure yours matches exactly:

import datetime

from storage import load_expenses, save_expenses


def add_expense(expenses, amount, category, description):
    """Create a new expense and add it to the list."""
    expense = {
        "amount": amount,
        "category": category.lower(),
        "description": description,
        "date": datetime.date.today().isoformat(),
    }
    expenses.append(expense)
    save_expenses(expenses)
    return expenses


def get_all_expenses():
    """Load and return all expenses."""
    return load_expenses()


def filter_by_category(expenses, category):
    """Return expenses matching the given category."""
    return [e for e in expenses if e["category"] == category.lower()]


def filter_by_month(expenses, year, month):
    """Return expenses matching the given year and month."""
    target = f"{year:04d}-{month:02d}"
    return [e for e in expenses if e["date"].startswith(target)]

Your tracker logic is complete. It can create, retrieve, and filter expenses. Next up, you'll build the reports module that turns raw expense data into readable spending summaries.

Build the Reports Module

Your tracker can now add expenses, load them from storage, and filter them by category or month. The data flows in, but there's no way to make sense of it yet.

In this step, you'll build the reporting layer. This module takes a list of expenses and turns it into a formatted spending summary with category breakdowns.

In this step, get ready to:
  • Write a function that calculates total spending using a generator expression.
  • Write a function that groups spending by category.
  • Write a formatted report printer using f-strings with alignment.
Write the total_spending() function

The simplest report you can generate is the total amount spent. Python's built-in sum() function can handle this in a single line when paired with a generator expression.

  • In VS Code, open reports.py and add the following function:
def total_spending(expenses):
    """Calculate the total spending from a list of expenses."""
    return sum(e["amount"] for e in expenses)

What does this code do?

The generator expression e["amount"] for e in expenses pulls out just the amount from each expense dictionary.

Wrapping it in sum() adds all those amounts together and returns the total. No loops or temporary variables needed.

  • Save reports.py.
  • You won't see a change yet. The next function uses this to build a full report.
Write the spending_by_category() function

Knowing the total is useful, but seeing where your money goes is more actionable. This function groups expenses by their category and totals each one.

  • Below the total_spending() function in reports.py, add this function:
def spending_by_category(expenses):
    """Calculate spending totals grouped by category."""
    categories = {}
    for expense in expenses:
        cat = expense["category"]
        categories[cat] = categories.get(cat, 0) + expense["amount"]
    return categories

What does this code do?

The function starts with an empty dictionary called categories. For each expense, it looks up the category name and adds the amount to that category's running total.

The .get(cat, 0) call returns the current total for that category, or 0 if the category hasn't been seen yet. This avoids a KeyError on the first expense in any category.

  • Save reports.py.
  • You won't see output yet. The next function ties both of these together into a formatted report.
Write the print_report() function

Now you have functions that calculate totals and group by category. This final function calls both and formats everything into a readable table using f-string alignment.

  • Below the spending_by_category() function in reports.py, add this function:
def print_report(expenses):
    """Display a formatted spending report."""
    total = total_spending(expenses)
    by_category = spending_by_category(expenses)

    print("\n" + "=" * 40)
    print(f"{'SPENDING REPORT':^40}")
    print("=" * 40)
    print(f"\n  Total Spending: ${total:.2f}\n")

    if by_category:
        print(f"  {'Category':<15} {'Amount':>10}")
        print(f"  {'-' * 15} {'-' * 10}")
        for cat, amount in sorted(by_category.items()):
            print(f"  {cat:<15} ${amount:>9.2f}")

    print("\n" + "=" * 40)

What does this code do?

This function calls the two helper functions you just wrote to get the data, then formats everything with f-strings.

  • :^40 centers the title across 40 characters.
  • :<15 left-aligns text in a 15-character-wide column.
  • :>10 right-aligns text in a 10-character-wide column.
  • :.2f formats numbers to two decimal places.

Why sort the categories?

The sorted(by_category.items()) call prints categories in alphabetical order. Without it, the order depends on when each category was first added. Sorting makes the output predictable and easier to scan.

  • Save reports.py.
  • Test the reports module by running this command in your terminal:
python -c "from storage import load_expenses; from reports import print_report; print_report(load_expenses())"

You should see a formatted report with a centered title, your total spending of $12.50, and a single category row for food.

Seeing an import error?

  • Make sure your terminal is inside the expense_tracker folder. Python needs to find both storage.py and reports.py in the current directory.
  • Check that your function names match exactly: total_spending, spending_by_category, and print_report.

Why is my report command failing?

✔️ Awesome, I've got everything!

Great. Double check you've saved reports.py before moving on.

ⓧ I'd like to double check the full code

Here's the complete reports.py file:

def total_spending(expenses):
    """Calculate the total spending from a list of expenses."""
    return sum(e["amount"] for e in expenses)


def spending_by_category(expenses):
    """Calculate spending totals grouped by category."""
    categories = {}
    for expense in expenses:
        cat = expense["category"]
        categories[cat] = categories.get(cat, 0) + expense["amount"]
    return categories


def print_report(expenses):
    """Display a formatted spending report."""
    total = total_spending(expenses)
    by_category = spending_by_category(expenses)

    print("\n" + "=" * 40)
    print(f"{'SPENDING REPORT':^40}")
    print("=" * 40)
    print(f"\n  Total Spending: ${total:.2f}\n")

    if by_category:
        print(f"  {'Category':<15} {'Amount':>10}")
        print(f"  {'-' * 15} {'-' * 10}")
        for cat, amount in sorted(by_category.items()):
            print(f"  {cat:<15} ${amount:>9.2f}")

    print("\n" + "=" * 40)

Your reports module is ready to turn raw expense data into clear summaries. Next up, you'll wire all four modules together into main.py and run the complete expense tracker for the first time.

Wire Everything Together in main.py

You've built all the individual pieces of your expense tracker. The storage layer handles persistence, the tracker manages business logic, and the reports module generates summaries.

Now it's time to bring everything together in main.py. This file acts as the front door to your application. It imports functions from your other modules, presents a menu to the user, and routes their choices to the right functions.

In this step, get ready to:
  • Build a menu loop that presents options and reads user input.
  • Implement each menu option with input validation so the app never crashes.
  • Run the complete app and test every feature end to end.
Build the menu loop

The main.py file ties your three modules together into one interactive application. The first thing it needs is imports from your other files, and a helper function to display expenses in a readable table.

  • In VS Code, switch to main.py (which is still empty) and paste the following code:
from tracker import add_expense, get_all_expenses, filter_by_category, filter_by_month
from reports import print_report


def display_expenses(expenses):
    """Print a list of expenses in a readable format."""
    if not expenses:
        print("\n  No expenses found.")
        return
    print(f"\n  {'Date':<12} {'Category':<12} {'Amount':>8}  {'Description'}")
    print(f"  {'-' * 12} {'-' * 12} {'-' * 8}  {'-' * 15}")
    for e in expenses:
        print(f"  {e['date']:<12} {e['category']:<12} ${e['amount']:>7.2f}  {e['description']}")

What does this code do?

The first two lines import specific functions from your other modules. This is the from module import function pattern you've been using throughout the project.

The display_expenses() helper formats a list of expenses as a neat table using f-string alignment. The :<12 syntax left-aligns text in a 12-character column, and :>8 right-aligns in 8 characters.

You won't see a change yet. The next chunk uses this display helper inside the menu.

Implement each menu option with input validation

Your menu needs to handle user input safely. If someone types abc when asked for a dollar amount, the app should ask again instead of crashing.

  • Add the following code below the display_expenses() function in main.py:
def get_valid_amount():
    """Prompt the user for a valid expense amount."""
    while True:
        try:
            amount = float(input("  Amount: $"))
            if amount <= 0:
                print("  Please enter a positive number.")
                continue
            return amount
        except ValueError:
            print("  That's not a valid number. Try again.")


def main():
    """Main menu loop for the expense tracker."""
    print("\n  === Personal Expense Tracker ===")

    while True:
        print("\n  1. Add Expense")
        print("  2. View All Expenses")
        print("  3. Filter by Category")
        print("  4. Filter by Month")
        print("  5. Spending Report")
        print("  6. Quit")

        choice = input("\n  Choose an option (1-6): ").strip()

        if choice == "1":
            amount = get_valid_amount()
            category = input("  Category (e.g. food, transport, entertainment): ").strip()
            if not category:
                print("  Category cannot be empty.")
                continue
            description = input("  Description: ").strip()
            if not description:
                description = "No description"
            expenses = get_all_expenses()
            add_expense(expenses, amount, category, description)
            print(f"\n  \u2713 Added ${amount:.2f} to '{category}'")

        elif choice == "2":
            expenses = get_all_expenses()
            display_expenses(expenses)

        elif choice == "3":
            category = input("  Enter category to filter: ").strip()
            if not category:
                print("  Category cannot be empty.")
                continue
            expenses = get_all_expenses()
            filtered = filter_by_category(expenses, category)
            print(f"\n  Expenses in '{category.lower()}':")
            display_expenses(filtered)

        elif choice == "4":
            try:
                year = int(input("  Year (e.g. 2026): "))
                month = int(input("  Month (1-12): "))
                if not (1 <= month <= 12):
                    print("  Month must be between 1 and 12.")
                    continue
            except ValueError:
                print("  Please enter valid numbers.")
                continue
            expenses = get_all_expenses()
            filtered = filter_by_month(expenses, year, month)
            print(f"\n  Expenses for {year}-{month:02d}:")
            display_expenses(filtered)

        elif choice == "5":
            expenses = get_all_expenses()
            print_report(expenses)

        elif choice == "6":
            print("\n  Goodbye! \U0001f44b")
            break

        else:
            print("  Invalid option. Please choose 1-6.")

What does this code do?

The get_valid_amount() function uses a while True loop with try/except to keep asking until the user provides a valid positive number. If they type letters, the ValueError is caught and they get a friendly retry message.

The main() function is the heart of the application. It runs a while True loop that prints numbered options, reads the user's choice, and routes it to the correct function from your other modules. Each option includes its own validation so bad input never crashes the program.

You won't be able to run the app yet. The next addition makes the file executable.

Add the entry point guard and run the app

Python needs to know that when you run main.py directly, it should call your main() function. The if __name__ == "__main__" guard makes this happen while still allowing other files to import from main.py without triggering the menu.

  • Add this code at the very bottom of main.py, below the main() function:
if __name__ == "__main__":
    main()

Why this pattern again?

You used this same guard in storage.py for testing. It tells Python: only run the menu loop when this file is the one being executed. If another file ever imports from main.py, the menu won't start unexpectedly.

  • Save main.py.
  • Run the complete application by running this command in your terminal:
python main.py
  • You should see the menu appear with 6 numbered options.
  • Type 1 and press Enter to add an expense.
  • Enter 15.99 for the amount.
  • Enter food for the category.
  • Enter Coffee and snacks for the description.
  • You should see a confirmation message: ✓ Added $15.99 to 'food'.

Try a few more features while the app is still running.

  • Type 2 to view all expenses. You should see both the original Lunch entry and your new Coffee expense in a table.
  • Type 3 to filter by category. Enter food and confirm both expenses appear.
  • Type 5 to generate a spending report. You should see a formatted report with the total and a per-category breakdown.
  • Type 6 to quit the app.

Seeing a ModuleNotFoundError?

Make sure your terminal is inside the expense_tracker folder, not its parent. Python looks for tracker.py and reports.py in the same directory where you run the command.

Also check that your import line at the top of main.py says from tracker import (no .py extension).

Help me fix a ModuleNotFoundError when running main.py

✔️ Awesome, I've got everything!

Great. Make sure you've saved main.py and confirm the app ran successfully before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.py file. Compare your code against it:

from tracker import add_expense, get_all_expenses, filter_by_category, filter_by_month
from reports import print_report


def display_expenses(expenses):
    """Print a list of expenses in a readable format."""
    if not expenses:
        print("\n  No expenses found.")
        return
    print(f"\n  {'Date':<12} {'Category':<12} {'Amount':>8}  {'Description'}")
    print(f"  {'-' * 12} {'-' * 12} {'-' * 8}  {'-' * 15}")
    for e in expenses:
        print(f"  {e['date']:<12} {e['category']:<12} ${e['amount']:>7.2f}  {e['description']}")


def get_valid_amount():
    """Prompt the user for a valid expense amount."""
    while True:
        try:
            amount = float(input("  Amount: $"))
            if amount <= 0:
                print("  Please enter a positive number.")
                continue
            return amount
        except ValueError:
            print("  That's not a valid number. Try again.")


def main():
    """Main menu loop for the expense tracker."""
    print("\n  === Personal Expense Tracker ===")

    while True:
        print("\n  1. Add Expense")
        print("  2. View All Expenses")
        print("  3. Filter by Category")
        print("  4. Filter by Month")
        print("  5. Spending Report")
        print("  6. Quit")

        choice = input("\n  Choose an option (1-6): ").strip()

        if choice == "1":
            amount = get_valid_amount()
            category = input("  Category (e.g. food, transport, entertainment): ").strip()
            if not category:
                print("  Category cannot be empty.")
                continue
            description = input("  Description: ").strip()
            if not description:
                description = "No description"
            expenses = get_all_expenses()
            add_expense(expenses, amount, category, description)
            print(f"\n  \u2713 Added ${amount:.2f} to '{category}'")

        elif choice == "2":
            expenses = get_all_expenses()
            display_expenses(expenses)

        elif choice == "3":
            category = input("  Enter category to filter: ").strip()
            if not category:
                print("  Category cannot be empty.")
                continue
            expenses = get_all_expenses()
            filtered = filter_by_category(expenses, category)
            print(f"\n  Expenses in '{category.lower()}':")
            display_expenses(filtered)

        elif choice == "4":
            try:
                year = int(input("  Year (e.g. 2026): "))
                month = int(input("  Month (1-12): "))
                if not (1 <= month <= 12):
                    print("  Month must be between 1 and 12.")
                    continue
            except ValueError:
                print("  Please enter valid numbers.")
                continue
            expenses = get_all_expenses()
            filtered = filter_by_month(expenses, year, month)
            print(f"\n  Expenses for {year}-{month:02d}:")
            display_expenses(filtered)

        elif choice == "5":
            expenses = get_all_expenses()
            print_report(expenses)

        elif choice == "6":
            print("\n  Goodbye! \U0001f44b")
            break

        else:
            print("  Invalid option. Please choose 1-6.")


if __name__ == "__main__":
    main()

Your expense tracker is fully functional. You've built a complete multi-file Python application where each module has a clear responsibility, and main.py orchestrates them all. Next up, a secret challenge to take this app even further.

Secret mission

Add Monthly Budget Warnings

Your expense tracker records spending, but it doesn't warn you when you're about to overspend. Add a monthly budget feature that automatically alerts you when spending crosses 80% or 100% of your limit.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The expense_tracker folder on your Desktop (contains main.py, tracker.py, storage.py, reports.py, and expenses.json).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep using your expense tracker.

  • Your expense_tracker folder stays on your Desktop.
  • Run python main.py from inside the folder any time you want to track expenses.
  • Your data persists in expenses.json between sessions.

Pause - I'll come back to this later

Since this is a local project, there are no processes to stop. Your files are safe to leave as-is until you're ready to return.

  • Close the VS Code window for the expense_tracker folder.
  • Your files and expense data remain on your Desktop for whenever you come back.

Delete - I don't want to use this again

Remove the entire project folder and all its contents. This deletes your code and any saved expense data permanently.

  • Close VS Code if the expense_tracker folder is still open.

Delete the project folder by running the appropriate command for your operating system.

macOS

  • Open your terminal and run:
rm -rf ~/Desktop/expense_tracker
  • Confirm the folder is gone by running:
ls ~/Desktop/expense_tracker

You should see a "No such file or directory" message.

Windows

  • Open your terminal and run:
rmdir /s /q %USERPROFILE%\Desktop\expense_tracker
  • Confirm the folder is gone by running:
dir %USERPROFILE%\Desktop\expense_tracker

You should see a "The system cannot find the path specified" message.

Nice Work!

Nice Work!

You just built a complete, multi-file Python application from scratch. Your expense tracker can add expenses, filter them by category or month, generate spending reports, and persist data between sessions.

You've learned how to:

  • Built a multi-file Python CLI with four modules, each owning a single responsibility (storage, tracking, reports, and the main menu loop).
  • Implemented persistent JSON storage using json.load() and json.dump(), so your app remembers expenses between sessions.
  • Used from module import function to wire your own files together, and if __name__ == "__main__" to make files both importable and independently runnable.
  • Added a monthly budget warning feature that calculates current-month spending and alerts you when you approach or exceed your limit.

Ready to quiz yourself?