Build a Personal Expense Tracker

Build a CLI expense tracker with full CRUD operations and CSV storage.

Introduction

30 Second Summary

Ever wonder where all your money goes each month? Tracking expenses is one of the most practical things you can automate with code.

In this project, you will build a personal expense tracker that runs in your terminal. You will add, view, edit, and delete expenses, save them to a file, and see spending summaries broken down by category.

What You'll Build

A command-line expense tracker application in Python with full CRUD (Create, Read, Update, Delete) operations and persistent CSV storage.

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

  • A working expense tracker that lets you add, view, edit, and delete expenses from the command line.
  • Hands-on experience with Python's csv module for reading and writing structured data to a file.
  • A category summary feature that calculates how much you spend in each area of your life.
  • Secret Mission: Add monthly filtering so you can view expenses for any specific month.

Before We Start

Before you build anything in Python, you need a dedicated workspace. A project folder keeps your code organized and easy to find later.

In this step, you'll create your project folder on your Desktop, set up your Python file, and confirm Python is ready to run it.

In this step, get ready to:
  • Create your project folder and Python file.
  • Verify Python is installed and working.
Create your project folder and file

You need a folder to hold your expense tracker code and a Python file to write it in.

  • 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+` (both macOS and Windows).

Can't find the terminal?

You can also open it from the menu bar: click View, then Terminal.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir expense-tracker
cd expense-tracker
  • Now open this folder as your workspace in VS Code. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the expense-tracker folder, and click Open (macOS) or Select Folder (Windows).

Why open the folder this way?

Opening a folder as your workspace tells VS Code to treat it as your project root. The Explorer sidebar shows your files, and the integrated terminal starts in the right directory.

VS Code may reopen with the folder loaded. If your terminal closed, reopen it with Ctrl+`.

  • Create your Python file. In the Explorer sidebar on the left, hover over the folder name and click the New File icon (a file with a plus sign).
  • Type expense_tracker.py and press Enter.
  • Confirm the file exists by running this in your terminal:
ls

You should see expense_tracker.py in the output.

Don't see the file?

Make sure you created the file inside the expense-tracker folder, not outside it. Check that your terminal is in the right folder by looking at the path shown in your terminal prompt.

If your terminal shows a different path, run cd ~/Desktop/expense-tracker to move to the correct location.

Help me find my project file in the terminal.

Verify Python is ready

You already have Python installed. Let's confirm it's accessible from your project folder.

  • Check your Python version by running:
python --version

You should see a version number of 3.10 or higher (for example, Python 3.12.4).

Seeing 'command not found'?

On macOS, the command is often python3 --version instead. If that works, use python3 in place of python throughout this project.

Help me get Python working in my terminal.

  • Run your empty file to confirm Python can execute it:
python expense_tracker.py

You should see no output at all. The command returns to a new prompt line with no errors. This means Python found and ran your file successfully.

Seeing an error about the file?

If you see a message like can't open file 'expense_tracker.py', your terminal is not in the expense-tracker folder. Run cd ~/Desktop/expense-tracker and try again.

Help me run my Python file.

Your workspace is ready. Next up, you'll write the code that lets you add expenses and view them.

Set Up Your Project

Every program starts with a foundation. Before building any features, your expense tracker needs to know which tools (modules) it will use and where to store its data.

Python's standard library includes modules for working with CSV files, checking file paths, and handling dates. In this step, you'll import these modules and define the constants your tracker will reference throughout the program.

In this step, get ready to:
  • Add import statements for the modules your tracker will use.
  • Define constants for the data file name and its field structure.
Add the imports and constants

The expense tracker uses three Python modules from the standard library.

  • csv handles reading and writing spreadsheet-like data.
  • os checks whether files exist on disk.
  • date (from the datetime module) stamps each expense with today's date.

You'll also define two constants that describe your data file: its name, and the fields (columns) each expense contains.

  • In expense_tracker.py, add the following code:
# Import modules from the standard library
import csv
import os
from datetime import date

# File where expenses will be saved
EXPENSES_FILE = "expenses.csv"
# Column headers for the CSV file
FIELDNAMES = ["date", "amount", "category", "description"]

What does this code do?

import csv gives you tools to write and read rows in a CSV (comma-separated values) file. import os lets you check if the expenses file already exists before trying to read it.

from datetime import date imports just the date class so you can auto-fill today's date on each expense.

EXPENSES_FILE stores the filename your tracker writes to. FIELDNAMES defines the four columns every expense will have. Constants like these are written in ALL_CAPS by convention so you know they should not change.

  • Save expense_tracker.py.
  • In your terminal, run the file to confirm there are no errors:
python expense_tracker.py

You should see no output and no error messages. The terminal returns to its prompt, meaning Python read your imports and constants without any issues.

Seeing an error?

  • If you see ModuleNotFoundError, double-check that you typed import csv and import os exactly as shown. These are built-in modules that come with Python.
  • If you see SyntaxError, check for missing quotes around "expenses.csv" or mismatched brackets in the FIELDNAMES list.
  • On macOS or Linux, you may need to use python3 instead of python.

Help me debug an import or syntax error in my expense tracker setup.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is what your expense_tracker.py should look like so far:

import csv
import os
from datetime import date

EXPENSES_FILE = "expenses.csv"
FIELDNAMES = ["date", "amount", "category", "description"]

Your foundation is ready. Next up, you'll write the functions that let you add expenses and view them.

Create and Read Expenses

Your project folder and file are ready to go. Now it's time to write the functions that make your tracker actually do something.

In this step, you'll build the first two operations of CRUD: Create (adding a new expense) and Read (viewing all expenses). These two functions form the foundation that every other feature builds on.

In this step, get ready to:
  • Write a function that collects expense details from the user and saves them to a CSV file.
  • Write a helper function that loads expense data from the CSV file into Python.
  • Write a function that displays all expenses in a formatted table.
Write the add_expense() function

The first thing any expense tracker needs is the ability to record a new expense. Your add_expense() function will prompt the user for details, validate the input, and append the data to your CSV file.

  • In expense_tracker.py, add the following function below your FIELDNAMES constant:
def add_expense():
    """Prompt user for expense details and save to CSV. (CREATE)"""
    print("\n--- Add New Expense ---")

    # Keep asking for amount until the user enters a valid positive number
    while True:
        amount_input = input("Amount spent: $")
        try:
            amount = float(amount_input)
            if amount <= 0:
                print("Amount must be a positive number. Try again.")
                continue
            break
        except ValueError:
            print("Invalid amount. Please enter a number like 12.50")

    # Get category and description from the user
    category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
    if not category:
        category = "Uncategorized"

    description = input("Description (optional): ").strip()

    # Build the expense dictionary with today's date auto-filled
    expense = {
        "date": date.today().isoformat(),
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }

    # Check if file exists to decide whether to write the header row
    file_exists = os.path.exists(EXPENSES_FILE)

    # Append the new expense to the CSV file
    with open(EXPENSES_FILE, "a", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        if not file_exists:
            writer.writeheader()
        writer.writerow(expense)

    print(f"Expense added: ${amount:.2f} for {category}")

What does this code do?

The while True loop keeps asking for an amount until the user enters a valid positive number. The try/except ValueError catches inputs like "abc" that cannot be converted to a number.

The function builds a dictionary with four fields matching your FIELDNAMES constant. It uses date.today().isoformat() to auto-fill today's date in YYYY-MM-DD format.

The file is opened in append mode ("a") so new expenses are added without overwriting existing ones. The newline="" parameter prevents blank rows from appearing in the CSV on Windows.

What is csv.DictWriter?

csv.DictWriter writes dictionaries to a CSV file. You give it a list of field names, and it handles mapping each dictionary key to the correct column. This is easier to read than tracking column positions manually.

  • Save expense_tracker.py.
  • You won't see a change yet. The next function (view_expenses()) will let you display what this function saves.
Write the load_expenses() function

Before you can display expenses, you need a way to read them from the file back into Python. The load_expenses() function handles this. It also gracefully handles the case where the file does not exist yet.

  • Below your add_expense() function, add the following:
def load_expenses():
    """Load all expenses from CSV file. Returns a list of dictionaries."""
    # If the file doesn't exist yet, return an empty list
    if not os.path.exists(EXPENSES_FILE):
        return []

    # Open the file and read all rows as dictionaries
    with open(EXPENSES_FILE, "r", newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        return list(reader)

What does this code do?

os.path.exists() checks if the CSV file has been created yet. If the learner runs the program for the first time, there is no file, so the function returns an empty list instead of crashing.

csv.DictReader reads each row as a dictionary. The first row of the CSV is automatically used as the keys. The list() call converts the reader object into a regular Python list you can loop through or count.

  • Save expense_tracker.py.
  • You won't see a change yet. The next function uses load_expenses() to display your saved data.
Write the view_expenses() function

Now you can add expenses and load them. The final piece for this step is displaying them in a clean, numbered table. Numbering each row is important because you will use those numbers later to edit or delete specific expenses.

  • Below your load_expenses() function, add the following:
def view_expenses():
    """Display all expenses in a numbered, formatted table. (READ)"""
    expenses = load_expenses()

    # Show a message if there are no expenses yet
    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    # Print the table header
    print("\n--- All Expenses ---")
    print(f"{'#':<4} {'Date':<12} {'Amount':>8} {'Category':<15} {'Description'}")
    print("-" * 60)

    # Print each expense as a numbered row
    total = 0
    for i, expense in enumerate(expenses, start=1):
        amount = float(expense["amount"])
        total += amount
        print(
            f"{i:<4} {expense['date']:<12} ${amount:>7.2f} {expense['category']:<15} {expense['description']}"
        )

    # Print the total at the bottom
    print("-" * 60)
    print(f"{'':4} {'TOTAL':<12} ${total:>7.2f}")

What does this code do?

The function first calls load_expenses() to get all saved expenses. If the list is empty, it prints a friendly message and returns early.

enumerate(expenses, start=1) gives each expense a number starting from 1. The f-string formatting (:<4, :>8) aligns the columns neatly. The total variable accumulates all amounts and displays a sum at the bottom.

  • Save expense_tracker.py.
  • Test your three functions by running this command in your terminal:
python -c "import expense_tracker; expense_tracker.add_expense(); expense_tracker.view_expenses()"
  • The program will prompt you for an amount, category, and description. Enter some test data (for example, 25.50 for amount, Food for category, Lunch for description).
  • After you enter the data, you should see a formatted table showing your expense with a numbered row, the date, amount, category, and description.

Not seeing the table?

  • Make sure you saved expense_tracker.py before running the command.
  • Check that your function names match exactly: add_expense and view_expenses (with an "s" at the end of view_expenses).
  • If you see ModuleNotFoundError, make sure your terminal is in the expense-tracker folder. Run cd to the correct directory and try again.

Help me debug my expense tracker

✔️ Awesome, I've got everything!

Great. Make sure you have saved expense_tracker.py with all three functions.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py file at the end of this step:

import csv
import os
from datetime import date

EXPENSES_FILE = "expenses.csv"
FIELDNAMES = ["date", "amount", "category", "description"]


def add_expense():
    """Prompt user for expense details and save to CSV. (CREATE)"""
    print("\n--- Add New Expense ---")

    while True:
        amount_input = input("Amount spent: $")
        try:
            amount = float(amount_input)
            if amount <= 0:
                print("Amount must be a positive number. Try again.")
                continue
            break
        except ValueError:
            print("Invalid amount. Please enter a number like 12.50")

    category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
    if not category:
        category = "Uncategorized"

    description = input("Description (optional): ").strip()

    expense = {
        "date": date.today().isoformat(),
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }

    file_exists = os.path.exists(EXPENSES_FILE)

    with open(EXPENSES_FILE, "a", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        if not file_exists:
            writer.writeheader()
        writer.writerow(expense)

    print(f"Expense added: ${amount:.2f} for {category}")


def load_expenses():
    """Load all expenses from CSV file. Returns a list of dictionaries."""
    if not os.path.exists(EXPENSES_FILE):
        return []

    with open(EXPENSES_FILE, "r", newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        return list(reader)


def view_expenses():
    """Display all expenses in a numbered, formatted table. (READ)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    print("\n--- All Expenses ---")
    print(f"{'#':<4} {'Date':<12} {'Amount':>8} {'Category':<15} {'Description'}")
    print("-" * 60)

    total = 0
    for i, expense in enumerate(expenses, start=1):
        amount = float(expense["amount"])
        total += amount
        print(
            f"{i:<4} {expense['date']:<12} ${amount:>7.2f} {expense['category']:<15} {expense['description']}"
        )

    print("-" * 60)
    print(f"{'':4} {'TOTAL':<12} ${total:>7.2f}")

You now have the Create and Read operations working. Next up, you'll add the ability to edit and delete expenses, completing the full CRUD cycle.

Update and Delete Expenses

You can now add and view expenses. But what happens when you accidentally log the wrong amount, or want to remove an expense you entered twice?

In this step, you will build the Update and Delete operations to complete your CRUD set. Both operations share a common challenge: CSV files do not support editing individual rows in place. You will solve this by loading all rows into memory, making your change, and rewriting the entire file.

In this step, get ready to:
  • Write a helper function that saves a full list of expenses to your CSV file.
  • Write an edit function that lets you update any field of an existing expense.
  • Write a delete function that removes an expense after confirmation.
Write the save_expenses() helper function

CSV files work like a printed page. You cannot erase one line in the middle. Instead, you rewrite the whole page with your changes applied.

The save_expenses() helper centralizes this rewrite logic so both the edit and delete functions can share it.

  • In expense_tracker.py, find the end of the load_expenses() function (the line return list(reader) inside the with block).
  • Add the following function between load_expenses() and view_expenses():
def save_expenses(expenses):
    """Save the full list of expenses to CSV, overwriting the file."""
    with open(EXPENSES_FILE, "w", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        writer.writeheader()
        writer.writerows(expenses)

What does this code do?

  • Opens the CSV file in write mode ("w"), which erases the old contents and starts fresh.
  • Writes the header row first with writeheader(), then writes every expense dictionary in one call with writerows().
  • The newline="" parameter prevents blank rows from appearing on Windows.
  • Save expense_tracker.py.

You will not see a change yet. The next two functions will call this helper to persist their edits.

Write the edit_expense() function

Editing an expense means showing the user what they have, letting them change any field, and saving the result. If the user presses Enter without typing anything, the original value stays.

  • Below the view_expenses() function (after its last line), add the following function:
def edit_expense():
    """Edit an existing expense by number. (UPDATE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to edit. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to edit (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Edit cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nEditing expense #{num}. Press Enter to keep the current value.")

    amount_input = input(f"  Amount (current: ${expense['amount']}): $").strip()
    if amount_input:
        while True:
            try:
                new_amount = float(amount_input)
                if new_amount <= 0:
                    amount_input = input("  Amount must be positive. Try again: $").strip()
                    continue
                expense["amount"] = f"{new_amount:.2f}"
                break
            except ValueError:
                amount_input = input("  Invalid number. Try again: $").strip()

    category_input = input(f"  Category (current: {expense['category']}): ").strip()
    if category_input:
        expense["category"] = category_input

    desc_input = input(f"  Description (current: {expense['description']}): ").strip()
    if desc_input:
        expense["description"] = desc_input

    save_expenses(expenses)
    print("Expense updated successfully!")

What does this code do?

  • Loads all expenses and shows them numbered so the user can pick one.
  • Validates the chosen number is within range. Typing q cancels the operation.
  • For each editable field (amount, category, description), it shows the current value. Pressing Enter keeps the old value.
  • After all changes are made, it calls save_expenses() to rewrite the file with the updated list.
  • Save expense_tracker.py.
  • Run your file to confirm there are no syntax errors:
python expense_tracker.py

The script should exit cleanly with no output. You will test the full edit flow once the menu is built in the next step.

Seeing a SyntaxError or IndentationError?

Check that the function is indented consistently with 4 spaces per level. Also confirm your f-strings use matching quote types (outer double quotes with inner single quotes for dictionary keys).

Make sure edit_expense() starts at the same indentation level as view_expenses() above it.

Help me debug my edit_expense function

Write the delete_expense() function

Deleting follows the same pattern as editing: show the list, pick an expense, and rewrite the file. The key difference is a confirmation prompt to prevent accidental deletions.

  • Below the edit_expense() function you just added, add the following function:
def delete_expense():
    """Delete an expense by number. (DELETE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to delete. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to delete (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Delete cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nAbout to delete: {expense['date']} - ${expense['amount']} - {expense['category']} - {expense['description']}")

    confirm = input("Are you sure? (y/n): ").strip().lower()
    if confirm == "y":
        expenses.pop(num - 1)
        save_expenses(expenses)
        print("Expense deleted.")
    else:
        print("Delete cancelled.")

What does this code do?

  • Shows all expenses numbered, just like edit does.
  • Displays the full details of the chosen expense before asking for confirmation. This lets the user double-check they picked the right one.
  • Uses expenses.pop(num - 1) to remove the expense from the in-memory list, then save_expenses() rewrites the file without it.
  • If the user types anything other than y, the delete is cancelled and nothing changes.
  • Save expense_tracker.py.
  • Run the file one more time to confirm everything is clean:
python expense_tracker.py

The script should exit with no errors. All three new functions are now in place.

Seeing a NameError for save_expenses?

Make sure save_expenses() is defined above both edit_expense() and delete_expense() in the file. Python reads top to bottom, so a function must be defined before another function calls it at runtime.

Help me fix a NameError in my expense tracker

Why rewrite the whole file?

CSV is a flat text format. There is no way to edit line 5 without rewriting the entire file. For a personal expense tracker with hundreds of rows, this is perfectly fast.

Databases like SQLite solve this with indexed storage, letting you update one row without touching the rest. That is a natural next step when your data grows large.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and it runs without errors.

ⓧ I'd like to double check the full code

Here is the full expense_tracker.py file as it should look at the end of this step:

import csv
import os
from datetime import date

EXPENSES_FILE = "expenses.csv"
FIELDNAMES = ["date", "amount", "category", "description"]


def add_expense():
    """Prompt user for expense details and save to CSV. (CREATE)"""
    print("\n--- Add New Expense ---")

    while True:
        amount_input = input("Amount spent: $")
        try:
            amount = float(amount_input)
            if amount <= 0:
                print("Amount must be a positive number. Try again.")
                continue
            break
        except ValueError:
            print("Invalid amount. Please enter a number like 12.50")

    category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
    if not category:
        category = "Uncategorized"

    description = input("Description (optional): ").strip()

    expense = {
        "date": date.today().isoformat(),
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }

    file_exists = os.path.exists(EXPENSES_FILE)

    with open(EXPENSES_FILE, "a", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        if not file_exists:
            writer.writeheader()
        writer.writerow(expense)

    print(f"Expense added: ${amount:.2f} for {category}")


def load_expenses():
    """Load all expenses from CSV file. Returns a list of dictionaries."""
    if not os.path.exists(EXPENSES_FILE):
        return []

    with open(EXPENSES_FILE, "r", newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        return list(reader)


def save_expenses(expenses):
    """Save the full list of expenses to CSV, overwriting the file."""
    with open(EXPENSES_FILE, "w", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        writer.writeheader()
        writer.writerows(expenses)


def view_expenses():
    """Display all expenses in a numbered, formatted table. (READ)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    print("\n--- All Expenses ---")
    print(f"{'#':<4} {'Date':<12} {'Amount':>8} {'Category':<15} {'Description'}")
    print("-" * 60)

    total = 0
    for i, expense in enumerate(expenses, start=1):
        amount = float(expense["amount"])
        total += amount
        print(
            f"{i:<4} {expense['date']:<12} ${amount:>7.2f} {expense['category']:<15} {expense['description']}"
        )

    print("-" * 60)
    print(f"{'':4} {'TOTAL':<12} ${total:>7.2f}")


def edit_expense():
    """Edit an existing expense by number. (UPDATE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to edit. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to edit (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Edit cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nEditing expense #{num}. Press Enter to keep the current value.")

    amount_input = input(f"  Amount (current: ${expense['amount']}): $").strip()
    if amount_input:
        while True:
            try:
                new_amount = float(amount_input)
                if new_amount <= 0:
                    amount_input = input("  Amount must be positive. Try again: $").strip()
                    continue
                expense["amount"] = f"{new_amount:.2f}"
                break
            except ValueError:
                amount_input = input("  Invalid number. Try again: $").strip()

    category_input = input(f"  Category (current: {expense['category']}): ").strip()
    if category_input:
        expense["category"] = category_input

    desc_input = input(f"  Description (current: {expense['description']}): ").strip()
    if desc_input:
        expense["description"] = desc_input

    save_expenses(expenses)
    print("Expense updated successfully!")


def delete_expense():
    """Delete an expense by number. (DELETE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to delete. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to delete (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Delete cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nAbout to delete: {expense['date']} - ${expense['amount']} - {expense['category']} - {expense['description']}")

    confirm = input("Are you sure? (y/n): ").strip().lower()
    if confirm == "y":
        expenses.pop(num - 1)
        save_expenses(expenses)
        print("Expense deleted.")
    else:
        print("Delete cancelled.")

Your expense tracker now has all four CRUD operations built. Next up, you will wire them together with a menu so you can actually interact with the program.

Add the Summary and Menu

You now have a complete set of CRUD functions that can create, read, update, and delete expenses. The missing piece is a way for users to actually choose which operation to run.

In this step, you'll add a category summary that reveals spending patterns. Then you'll build the main menu loop that ties everything together into an interactive program.

In this step, get ready to:
  • Write a category_summary() function that groups spending by category.
  • Build a main() function with a menu loop.
  • Add the entry point and run the full program.
Write the category_summary() function

Tracking individual expenses is useful, but the real insight comes from seeing totals by category. This function groups all your expenses and shows how much you've spent on Food, Transport, Entertainment, or any other category.

  • In expense_tracker.py, scroll down below the delete_expense() function.
  • Add the category_summary() function by copying the following code below delete_expense():
def category_summary():
    """Display spending totals grouped by category."""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    # Build a dictionary of category totals
    categories = {}
    for expense in expenses:
        category = expense["category"]
        amount = float(expense["amount"])
        if category in categories:
            categories[category] += amount
        else:
            categories[category] = amount

    # Print formatted summary table
    print("\n--- Spending by Category ---")
    print(f"{'Category':<20} {'Total':>10}")
    print("-" * 32)

    grand_total = 0
    for category, total in sorted(categories.items()):
        print(f"{category:<20} ${total:>9.2f}")
        grand_total += total

    print("-" * 32)
    print(f"{'ALL CATEGORIES':<20} ${grand_total:>9.2f}")

What does this code do?

This function builds a dictionary where each key is a category name and each value is the running total for that category.

  • categories = {} starts with an empty dictionary, then the loop adds each expense's amount to the matching category key.
  • sorted(categories.items()) prints the categories in alphabetical order so the output is predictable.
  • The f-string formatting aligns category names to the left and dollar amounts to the right, creating a clean table.
  • Save expense_tracker.py.

You won't see output yet. The next substep builds the menu that calls this function.

Write the main() function with the menu loop

A menu-driven program uses a loop that keeps showing options until the user decides to quit. Your main() function displays numbered choices and calls the matching function based on user input.

  • Below the category_summary() function you just added, add the main() function:
def main():
    """Main menu loop for the expense tracker."""
    # Welcome banner
    print("=" * 40)
    print("   Personal Expense Tracker")
    print("=" * 40)

    # Keep looping until the user chooses Exit
    while True:
        print("\nWhat would you like to do?")
        print("1) Add Expense")
        print("2) View All Expenses")
        print("3) Edit Expense")
        print("4) Delete Expense")
        print("5) Category Summary")
        print("6) Exit")

        choice = input("\nEnter your choice (1-6): ").strip()

        # Route to the correct function based on input
        if choice == "1":
            add_expense()
        elif choice == "2":
            view_expenses()
        elif choice == "3":
            edit_expense()
        elif choice == "4":
            delete_expense()
        elif choice == "5":
            category_summary()
        elif choice == "6":
            print("\nGoodbye! Keep tracking those expenses.")
            break
        else:
            print("Invalid choice. Please enter 1, 2, 3, 4, 5, or 6.")

What does this code do?

  • while True: creates an infinite loop. The program keeps running and showing the menu until the user picks option 6, which triggers break to exit the loop.
  • Each elif branch checks the user's input and calls the corresponding function. This pattern keeps the menu logic clean and easy to extend.
  • The else at the bottom catches any invalid input and shows a helpful message instead of crashing.
  • Save expense_tracker.py.

You won't see the menu yet. The next substep adds the entry point that kicks off main() when you run the file.

Add the entry point and test

Python files need an entry point to know where to start executing. The if __name__ == "__main__": guard ensures main() only runs when you execute the file directly.

  • At the very bottom of expense_tracker.py, below the main() function, add the entry point:
if __name__ == "__main__":
    main()

What is __name__ == "__main__"?

When you run a Python file directly (like python expense_tracker.py), Python sets a special variable called __name__ to "__main__". This guard makes sure main() only runs when the file is executed directly, not when it's imported by another script.

  • Save expense_tracker.py.
  • Run the program by entering this command in your terminal:
python expense_tracker.py

You should see the welcome banner and a numbered menu with 6 options.

  • Test the full CRUD cycle by doing each of these actions:
  • Enter 1 to add a few expenses with different categories (try Food, Transport, and Entertainment).
  • Enter 2 to view all your expenses in a formatted table.
  • Enter 3 to edit one of your expenses.
  • Enter 4 to delete one of your expenses.
  • Enter 5 to see your spending broken down by category.
  • Enter 6 to exit the program.

What should I see?

After adding a few expenses and choosing option 5, your category summary should look something like this:

--- Spending by Category ---
Category             Total
--------------------------------
Entertainment        $    15.00
Food                 $    32.50
Transport            $    12.00
--------------------------------
ALL CATEGORIES       $    59.50

Program not starting or crashing?

  • Make sure the if __name__ == "__main__": line is at the very bottom of the file, not indented inside another function.
  • Check that you have no typos in the function names inside main(). Each name must match the function definitions exactly (e.g., add_expense not addExpense).
  • If you see IndentationError, make sure each function body is indented with 4 spaces consistently.

Help me debug my expense tracker program

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py file. Compare it against yours:

import csv
import os
from datetime import date

EXPENSES_FILE = "expenses.csv"
FIELDNAMES = ["date", "amount", "category", "description"]


def add_expense():
    """Prompt user for expense details and save to CSV. (CREATE)"""
    print("\n--- Add New Expense ---")

    while True:
        amount_input = input("Amount spent: $")
        try:
            amount = float(amount_input)
            if amount <= 0:
                print("Amount must be a positive number. Try again.")
                continue
            break
        except ValueError:
            print("Invalid amount. Please enter a number like 12.50")

    category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
    if not category:
        category = "Uncategorized"

    description = input("Description (optional): ").strip()

    expense = {
        "date": date.today().isoformat(),
        "amount": f"{amount:.2f}",
        "category": category,
        "description": description,
    }

    file_exists = os.path.exists(EXPENSES_FILE)

    with open(EXPENSES_FILE, "a", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        if not file_exists:
            writer.writeheader()
        writer.writerow(expense)

    print(f"Expense added: ${amount:.2f} for {category}")


def load_expenses():
    """Load all expenses from CSV file. Returns a list of dictionaries."""
    if not os.path.exists(EXPENSES_FILE):
        return []

    with open(EXPENSES_FILE, "r", newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        return list(reader)


def save_expenses(expenses):
    """Save the full list of expenses to CSV, overwriting the file."""
    with open(EXPENSES_FILE, "w", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=FIELDNAMES)
        writer.writeheader()
        writer.writerows(expenses)


def view_expenses():
    """Display all expenses in a numbered, formatted table. (READ)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    print("\n--- All Expenses ---")
    print(f"{'#':<4} {'Date':<12} {'Amount':>8} {'Category':<15} {'Description'}")
    print("-" * 60)

    total = 0
    for i, expense in enumerate(expenses, start=1):
        amount = float(expense["amount"])
        total += amount
        print(
            f"{i:<4} {expense['date']:<12} ${amount:>7.2f} {expense['category']:<15} {expense['description']}"
        )

    print("-" * 60)
    print(f"{'':4} {'TOTAL':<12} ${total:>7.2f}")


def edit_expense():
    """Edit an existing expense by number. (UPDATE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to edit. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to edit (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Edit cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nEditing expense #{num}. Press Enter to keep the current value.")

    amount_input = input(f"  Amount (current: ${expense['amount']}): $").strip()
    if amount_input:
        while True:
            try:
                new_amount = float(amount_input)
                if new_amount <= 0:
                    amount_input = input("  Amount must be positive. Try again: $").strip()
                    continue
                expense["amount"] = f"{new_amount:.2f}"
                break
            except ValueError:
                amount_input = input("  Invalid number. Try again: $").strip()

    category_input = input(f"  Category (current: {expense['category']}): ").strip()
    if category_input:
        expense["category"] = category_input

    desc_input = input(f"  Description (current: {expense['description']}): ").strip()
    if desc_input:
        expense["description"] = desc_input

    save_expenses(expenses)
    print("Expense updated successfully!")


def delete_expense():
    """Delete an expense by number. (DELETE)"""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses to delete. Add one first!")
        return

    view_expenses()

    while True:
        num_input = input("\nEnter the expense number to delete (or 'q' to cancel): ").strip()
        if num_input.lower() == "q":
            print("Delete cancelled.")
            return
        try:
            num = int(num_input)
            if num < 1 or num > len(expenses):
                print(f"Please enter a number between 1 and {len(expenses)}.")
                continue
            break
        except ValueError:
            print("Invalid input. Enter a number or 'q' to cancel.")

    expense = expenses[num - 1]
    print(f"\nAbout to delete: {expense['date']} - ${expense['amount']} - {expense['category']} - {expense['description']}")

    confirm = input("Are you sure? (y/n): ").strip().lower()
    if confirm == "y":
        expenses.pop(num - 1)
        save_expenses(expenses)
        print("Expense deleted.")
    else:
        print("Delete cancelled.")


def category_summary():
    """Display spending totals grouped by category."""
    expenses = load_expenses()

    if not expenses:
        print("\nNo expenses recorded yet. Add one first!")
        return

    categories = {}
    for expense in expenses:
        category = expense["category"]
        amount = float(expense["amount"])
        if category in categories:
            categories[category] += amount
        else:
            categories[category] = amount

    print("\n--- Spending by Category ---")
    print(f"{'Category':<20} {'Total':>10}")
    print("-" * 32)

    grand_total = 0
    for category, total in sorted(categories.items()):
        print(f"{category:<20} ${total:>9.2f}")
        grand_total += total

    print("-" * 32)
    print(f"{'ALL CATEGORIES':<20} ${grand_total:>9.2f}")


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

    while True:
        print("\nWhat would you like to do?")
        print("1) Add Expense")
        print("2) View All Expenses")
        print("3) Edit Expense")
        print("4) Delete Expense")
        print("5) Category Summary")
        print("6) Exit")

        choice = input("\nEnter your choice (1-6): ").strip()

        if choice == "1":
            add_expense()
        elif choice == "2":
            view_expenses()
        elif choice == "3":
            edit_expense()
        elif choice == "4":
            delete_expense()
        elif choice == "5":
            category_summary()
        elif choice == "6":
            print("\nGoodbye! Keep tracking those expenses.")
            break
        else:
            print("Invalid choice. Please enter 1, 2, 3, 4, 5, or 6.")


if __name__ == "__main__":
    main()

Secret mission

Add Monthly Filtering

Your expense tracker can show all expenses at once, but what if you only want to see what you spent last month? Add a monthly filter that lets you view expenses for any specific year and month.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future reference or delete them entirely.

Resources you used:

  • The expense-tracker folder on your Desktop (contains expense_tracker.py and expenses.csv).

Keep everything running

No action needed. Choose this if you want to keep tracking expenses or refer back to your code later.

  • Your expense-tracker folder stays on your Desktop with all your code and data intact.
  • You can run python expense_tracker.py anytime to keep using your tracker.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit quietly on disk until you need them again.

  • No background processes are running. The program only runs when you execute it manually.
  • Your expense data in expenses.csv is saved and will be there when you come back.

Delete - I don't want to use this again

Remove the project folder and all its contents to start fresh.

  • On macOS or Linux, open your terminal and run:
rm -rf ~/Desktop/expense-tracker
  • On Windows, open PowerShell and run:
rmdir /s /q "$HOME\Desktop\expense-tracker"
  • This deletes both expense_tracker.py and expenses.csv permanently. Make sure you no longer need your expense data before running this.

Nice Work!

Nice Work!

Nice work! You just built a fully functional personal expense tracker from scratch using Python.

Your program can add, view, edit, and delete expenses, save everything to a CSV file, and break down your spending by category. That is a complete, real-world application.

You've learned how to:

  • Build a complete CRUD application with Create, Read, Update, and Delete operations powered by a menu-driven loop.
  • Use Python's csv module (DictWriter and DictReader) to read and write structured data that persists between sessions.
  • Structure a program with single-responsibility functions and input validation that handles bad user input gracefully.
  • Secret Mission: Added date filtering to view expenses for any specific month using datetime.strptime().

Ready to quiz yourself?