Build a Personal Expense Tracker
Build a CLI expense tracker in Python with OOP, CSV storage, and menus.
Introduction
30 Second Summary
You know that feeling at the end of the month when you check your bank balance and wonder where all your money went? Tracking expenses is one of those problems everyone understands, which makes it a perfect playground for practicing real programming.
In this project, you will build a command-line expense tracker with Python that lets you add expenses, view them in a formatted table, and see a spending summary grouped by category.
What You'll Build
A multi-file Python program that saves and loads expenses from a CSV file, so your data persists between sessions.
By the end of this project, you'll have:
- A working Expense class that models your spending data with automatic date stamping.
- A CSV storage layer that saves and loads expenses so nothing is lost when you close the program.
- A menu-driven CLI with input validation that ties everything together into a usable tool.
- Secret Mission: Add date-range filtering so you can view expenses from a specific month.
Are there any prerequisites?
You should be comfortable with basic Python syntax: variables, loops, conditionals, and functions. This project focuses on putting those pieces together to build something real, not on learning them from scratch.
Before We Start
Before We Start...
Set Up the Project Structure
Every Python project starts with a clear folder structure. By separating your code into dedicated files from the beginning, you practice one of the most important programming skills: deciding what goes where.
In this step, you'll create the project folder, set up three Python files, and confirm that Python runs correctly on your machine.
In this step, get ready to:
- Create the expense-tracker folder with three Python files.
- Verify Python 3.10 or higher is installed.
- Run a test to confirm the project is connected.
Create the project folder and files
Your expense tracker will live in its own folder on your Desktop with three files, each handling a different responsibility.
- 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+` (backtick key, top-left of your keyboard).
- 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
- Create three empty Python files by running this command:
macOS
touch expense.py storage.py main.py
Windows
New-Item -ItemType File -Name "expense.py", "storage.py", "main.py"
- Confirm the files exist by running:
ls
You should see expense.py, storage.py, and main.py listed in the output.
- Now open this folder in VS Code so it becomes your workspace. Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the expense-tracker folder, and click Open.
Why three separate files?
Each file will handle a different job. expense.py defines what an expense looks like. storage.py handles reading and writing data. main.py ties everything together and talks to the user.
This separation is called separation of concerns. It keeps your code organized and makes each piece easier to understand and change independently.
Don't see the three files?
Make sure you ran the file creation command while inside the expense-tracker folder. If ls shows nothing, re-run cd ~/Desktop/expense-tracker and try the file creation command again.
Help me create the project files for my expense tracker.
Verify Python is installed
Your expense tracker requires Python 3.10 or higher to run correctly.
- In the VS Code terminal, check your Python version by running:
python --version
✔️ I see version 3.10 or higher
Your terminal shows something like Python 3.10.x or higher. You're all set to continue.
ⓧ I see an older version
If you see a version lower than 3.10, your system might have an older Python as the default.
- Try running python3 --version instead. On macOS, the python command sometimes points to an older version while python3 points to a newer one.
- If python3 --version shows 3.10 or higher, use python3 instead of python for all commands in this project.
- If both commands show an older version, download the latest Python from python.org/downloads and install it.
ⓧ Command not found
Python is not installed on your machine yet.
- Download Python from python.org/downloads.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen the VS Code terminal, then run python --version again to confirm.
Test your setup with a print statement
Before building anything real, you want to confirm that VS Code, the terminal, and Python are all talking to each other correctly.
- In the VS Code Explorer sidebar on the left, click main.py to open it.
- Add this code to main.py:
print("Expense Tracker starting...")
What does this code do?
print() sends text to your terminal. If you see the message appear after running the file, it proves Python can find and execute your main.py file.
- Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Run your file by entering this command in the terminal:
python main.py
You should see Expense Tracker starting... printed in the terminal.
Don't see the expected output?
If you see an error about the file not being found, make sure your terminal is in the expense-tracker folder. Run cd ~/Desktop/expense-tracker to navigate there.
If you used python3 in the previous step, use python3 main.py here instead.
Help me run my main.py file.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete main.py file at this point:
print("Expense Tracker starting...")
The other two files (expense.py and storage.py) should be empty. You will add code to them in the next steps.
Your project structure is ready. Next up, you'll build the Expense class that defines what a single expense looks like.
Build the Expense Class
Your project folder is ready with three empty Python files waiting to be filled. Now it's time to start building the core piece of your tracker.
Every expense tracker needs to understand what an expense actually is. In this step, you'll define an Expense class that captures the key data points: how much you spent, what category it falls in, and a short description.
In this step, get ready to:
- Create the Expense class with an __init__ method that stores amount, category, description, and date.
- Add a __str__ method that formats an expense as a readable line.
- Test the class by creating an expense and printing it.
Write the __init__ method
A class is a blueprint for creating objects. Your Expense class needs to store four pieces of information about each expense: the dollar amount, a category, a description, and the date it was recorded.
- In VS Code, open expense.py from the Explorer sidebar.
- Add the following code to define the Expense class with its __init__ method:
import datetime
class Expense:
def __init__(self, amount, category, description):
self.amount = amount
self.category = category
self.description = description
self.date = datetime.date.today().isoformat()
What does this code do?
The __init__ method runs automatically every time you create a new Expense object. It takes three inputs from the user (amount, category, description) and stores them on the object using self.
The fourth attribute, self.date, is set automatically to today's date. datetime.date.today().isoformat() captures today's date and converts it into a string like 2026-06-12. This format is CSV-friendly and sortable.
You won't see any output yet. The next substep adds a method that formats expenses for display.
Add the __str__ method
Python calls the __str__ method whenever you print() an object. Without it, printing an Expense would just show something unhelpful like <Expense object at 0x...>. You'll define a formatted string that makes each expense easy to read.
- In expense.py, find the last line of the __init__ method:
self.date = datetime.date.today().isoformat()
- Add the following __str__ method below it, at the same indentation level as __init__:
def __str__(self):
return f"{self.date} | {self.category:<12} | ${self.amount:>8.2f} | {self.description}"
What do those format codes mean?
The f-string uses Python's format specification to align text into neat columns.
- {self.category:<12} pads the category to 12 characters wide, left-aligned.
- {self.amount:>8.2f} right-aligns the amount in an 8-character space with 2 decimal places.
- The | characters act as column separators so the output reads like a table.
You won't see output from this yet either. The next substep tests both methods together.
Test your Expense class
Time to confirm the class works. You'll temporarily add test code to the bottom of the file, run it, then remove it.
- At the very bottom of expense.py (below the class), add these two lines to create and print a test expense:
# Temporary test code - remove after testing
test = Expense(15.50, "Food", "Lunch at cafe")
print(test)
- Save expense.py.
- Run the file by entering this command in the VS Code terminal:
python expense.py
- You should see a line that looks like this (your date will be today's date):
2026-06-12 | Food | $15.50 | Lunch at cafe
Seeing an error instead of formatted output?
- If you see IndentationError, make sure __str__ is indented at the same level as __init__ (one level inside the class).
- If you see NameError: name 'Expense' is not defined, make sure your test code sits below and outside the class definition.
- If the dollar amount looks wrong, double-check the f-string format spec. The colon and format codes must be inside the curly braces with no spaces.
Why isn't my Expense class printing correctly?
- Once you've confirmed the output looks correct, delete the three test lines (the comment, test = ..., and print(test)) from the bottom of the file.
- Save expense.py again.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and removed the test code.
ⓧ I'd like to double check the full code
Here's what expense.py should look like after removing the test code:
import datetime
class Expense:
def __init__(self, amount, category, description):
self.amount = amount
self.category = category
self.description = description
self.date = datetime.date.today().isoformat()
def __str__(self):
return f"{self.date} | {self.category:<12} | ${self.amount:>8.2f} | {self.description}"
Your Expense class knows how to hold data and display itself. Next up, you'll build the storage layer that saves these expenses to a CSV file so they stick around between sessions.
Build the CSV Storage Layer
Your Expense class can represent a single expense in memory. The problem is that any expenses you create vanish the moment your program stops running.
In this step, you'll build a storage layer that saves expenses to a CSV file and loads them back. This gives your tracker persistence between sessions.
In this step, get ready to:
- Write a function that saves expenses to a CSV file.
- Write a function that loads expenses back from CSV.
- Test that data survives a round trip (save then load).
Write the save_expense function
The save function needs to append one row of expense data to a CSV file each time it's called. Appending (instead of overwriting) means previously saved expenses stay intact.
- Switch to storage.py in VS Code and add the following code:
import csv
import os
from expense import Expense
def save_expense(expense, filepath="expenses.csv"):
# Open in append mode so new expenses are added without erasing old ones
with open(filepath, "a", newline="") as csvfile:
writer = csv.writer(csvfile)
writer.writerow([expense.date, expense.category, expense.amount, expense.description])
What does this code do?
- import csv and import os bring in Python's built-in modules for working with CSV files and checking file paths.
- from expense import Expense pulls in your Expense class from the other file so this module can create Expense objects.
- open(filepath, "a", newline="") opens the file in append mode. The "a" means new data is added to the end, not overwriting what's already there.
- csv.writer(csvfile) creates a writer object, and writerow() writes a single list as one comma-separated row.
Why newline=""?
Python's csv module requires you to open files with newline="". Without it, Windows adds extra blank lines between rows in your CSV file. Including it on macOS is harmless, so it's a good habit regardless of your operating system.
- Save storage.py.
- You won't see any output yet. The next substep adds the loading function that completes the storage layer.
Seeing a red underline on the import?
- Make sure expense.py is in the same folder as storage.py. Python looks for imports in the same directory.
- Check that the filename is exactly expense.py (not expenses.py or Expense.py). Python imports are case-sensitive.
Help me fix my import error in storage.py
Write the load_expenses function
Loading is the reverse of saving. You need to read each CSV row and turn it back into an Expense object that your program can work with.
- In storage.py, add this function below save_expense:
def load_expenses(filepath="expenses.csv"):
expenses = []
# If the file doesn't exist yet, return empty list
if not os.path.exists(filepath):
return expenses
try:
with open(filepath, newline="") as csvfile:
reader = csv.reader(csvfile)
for row in reader:
# Each valid row has exactly 4 fields
if len(row) == 4:
date_str, category, amount_str, description = row
# Reconstruct an Expense object from the CSV data
expense = Expense(float(amount_str), category, description)
expense.date = date_str
expenses.append(expense)
except Exception as e:
print(f"Error loading expenses: {e}")
return expenses
What does this code do?
- os.path.exists(filepath) checks if the CSV file exists before trying to open it. On the first ever run, there's no file yet.
- csv.reader(csvfile) returns an iterable where each item is a list of strings representing one row.
- if len(row) == 4 skips any malformed rows that don't have exactly four fields.
- After creating the Expense object, expense.date = date_str overwrites today's auto-generated date with the original date from the file.
- The try/except block catches any unexpected errors (corrupted file, permission issues) so the program doesn't crash.
- Save storage.py.
- You won't see any output yet. The next substep tests both functions together.
Seeing a syntax error?
- Make sure the load_expenses function starts at the same indentation level as save_expense (no extra indent).
- Check that there's a blank line between the end of save_expense and the start of load_expenses.
Help me debug my load_expenses function
✔️ Awesome, I've got everything!
Great. Double check you've saved storage.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete storage.py file at this point:
import csv
import os
from expense import Expense
def save_expense(expense, filepath="expenses.csv"):
with open(filepath, "a", newline="") as csvfile:
writer = csv.writer(csvfile)
writer.writerow([expense.date, expense.category, expense.amount, expense.description])
def load_expenses(filepath="expenses.csv"):
expenses = []
if not os.path.exists(filepath):
return expenses
try:
with open(filepath, newline="") as csvfile:
reader = csv.reader(csvfile)
for row in reader:
if len(row) == 4:
date_str, category, amount_str, description = row
expense = Expense(float(amount_str), category, description)
expense.date = date_str
expenses.append(expense)
except Exception as e:
print(f"Error loading expenses: {e}")
return expenses
Test the storage layer
Before wiring these functions into the main menu, you should confirm they work together. You'll add temporary test code, run it, then remove it.
- At the very bottom of storage.py, add this temporary test code:
# --- Temporary test code (delete after testing) ---
test_expense = Expense(15.50, "Food", "Lunch at cafe")
save_expense(test_expense)
print("Saved:", test_expense)
loaded = load_expenses()
print("\nLoaded expenses:")
for e in loaded:
print(e)
What does this test do?
This creates an Expense, saves it to CSV, then immediately loads all expenses from the same file and prints them. If the output matches what you saved, the round trip works.
- Save storage.py.
- Run the test by entering this command in your terminal:
python storage.py
You should see output showing the saved expense, followed by the loaded expense printed in the same formatted style you built in Step 2.
- Confirm that an expenses.csv file now exists in your project folder by running:
ls
You should see expenses.csv listed alongside your three Python files.
- Now delete the temporary test code from the bottom of storage.py (everything below the load_expenses function).
- Save storage.py.
Seeing "ModuleNotFoundError: No module named 'expense'"?
- Make sure you're running the command from inside the expense-tracker folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
- Confirm that expense.py and storage.py are in the same folder. Python can only import from files in the same directory.
Help me fix my ModuleNotFoundError
Your storage layer is solid. You can save expenses and load them back without losing any data. Next up, you'll build the interactive menu that ties everything together into a real expense tracker.
Build the Menu and User Interaction
You have an Expense class that models your data and a storage layer that reads and writes CSV files. The pieces exist, but there is no way for a user to actually interact with them.
In this step, you will wire everything together with a menu-driven command-line interface. The user will pick options from a numbered menu, and your code will route each choice to the right function.
In this step, get ready to:
- Build a menu loop that keeps prompting the user until they choose to exit.
- Implement an add-expense flow with input validation.
- Implement view and summary functions that display formatted expense data.
Set up the imports and menu loop
The menu loop is the backbone of your program. It prints options, waits for the user's choice, and routes that choice to the correct function.
- In main.py, select all the existing code and delete it.
- Replace it with the following imports, menu function, and entry-point guard:
from expense import Expense
from storage import save_expense, load_expenses
def main():
print("=== Personal Expense Tracker ===\n")
while True:
print("\n1. Add Expense")
print("2. View All Expenses")
print("3. View Summary")
print("4. Exit")
choice = input("\nChoose an option (1-4): ").strip()
if choice == "1":
add_expense()
elif choice == "2":
view_expenses()
elif choice == "3":
view_summary()
elif choice == "4":
print("Goodbye!")
break
else:
print("Invalid option. Please enter 1-4.")
if __name__ == "__main__":
main()
What does this code do?
The imports pull in the Expense class and both storage functions so this file can use everything you built in the previous steps.
- while True creates an infinite loop. The menu keeps showing until the user picks option 4, which triggers break to exit the loop.
- The if/elif/else chain routes the user's choice to the correct function. Invalid input triggers a helpful message instead of crashing.
- if __name__ == "__main__" ensures main() only runs when you execute this file directly, not when it is imported elsewhere.
- Save main.py.
- Run the program to verify the menu appears by running:
python main.py
- You should see the === Personal Expense Tracker === heading followed by the four menu options.
- Type 4 and press Enter to exit cleanly.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from inside the expense-tracker folder. Your terminal's current directory must contain expense.py and storage.py.
- Confirm the filenames match exactly: expense.py (not expenses.py) and storage.py.
- Help me fix my ModuleNotFoundError in main.py
Implement the add expense function
When the user chooses option 1, your program needs to collect an amount, category, and description. It also needs to handle bad input gracefully instead of crashing.
- In main.py, add the following function above the main() function (but below the imports):
def add_expense():
try:
amount = float(input("Enter amount: $"))
except ValueError:
print("Invalid amount. Please enter a number.")
return
category = input("Enter category (e.g. Food, Transport, Entertainment): ").strip()
if not category:
print("Category cannot be empty.")
return
description = input("Enter description: ").strip()
if not description:
print("Description cannot be empty.")
return
expense = Expense(amount, category, description)
save_expense(expense)
print(f"Saved: {expense}")
What does this code do?
- try/except ValueError catches non-numeric input. If the user types abc instead of a number, the function prints a message and returns early instead of crashing.
- .strip() removes leading and trailing whitespace from the user's input. The if not category check catches empty strings so no blank categories sneak into your data.
- Once all inputs pass validation, the function creates an Expense object, saves it to the CSV file, and prints a confirmation using the __str__ method you built earlier.
- Save main.py and run it again:
python main.py
- Choose option 1 to add an expense.
- Enter an amount (e.g. 12.50), a category (e.g. Food), and a description (e.g. Coffee and muffin).
- You should see a Saved: confirmation with the formatted expense details.
- Choose option 4 to exit.
Seeing a NameError for add_expense?
- Make sure the add_expense() function is defined above the main() function. Python reads top to bottom, so functions must be defined before they are called.
- Check that the function name is spelled exactly add_expense with an underscore.
- Help me fix a NameError when choosing option 1 in my expense tracker
Implement the view and summary functions
The tracker needs two display modes. One shows every expense in a formatted table. The other groups spending by category so the user can see where their money goes.
- In main.py, add the following view_expenses() function below add_expense() and above main():
def view_expenses():
expenses = load_expenses()
if not expenses:
print("No expenses recorded yet.")
return
print(f"\n{'Date':<12} | {'Category':<12} | {'Amount':>10} | Description")
print("-" * 60)
for expense in expenses:
print(expense)
print("-" * 60)
total = sum(e.amount for e in expenses)
print(f"{'TOTAL':<12} | {'':<12} | ${total:>8.2f} |")
What does this code do?
- load_expenses() pulls all saved expenses from your CSV file. If the list is empty, the function prints a friendly message and returns early.
- The f-string format specifiers (<12, >10) left-align or right-align text within a fixed column width. This creates a neat table layout in the terminal.
- sum(e.amount for e in expenses) is a generator expression that calculates the total of all expense amounts in one line.
- Now add the view_summary() function directly below view_expenses() (still above main()):
def view_summary():
expenses = load_expenses()
if not expenses:
print("No expenses recorded yet.")
return
summary = {}
for expense in expenses:
if expense.category in summary:
summary[expense.category] += expense.amount
else:
summary[expense.category] = expense.amount
print("\n--- Spending Summary ---")
for category, total in summary.items():
print(f" {category:<15} ${total:.2f}")
print(f"\n {'TOTAL':<15} ${sum(summary.values()):.2f}")
What does this code do?
- The summary dictionary maps each category name to its running total. If a category already exists in the dictionary, the expense amount is added to it. Otherwise, a new entry is created.
- summary.items() returns each key-value pair so you can loop through categories and their totals together.
- sum(summary.values()) adds up all the category totals to produce the grand total across all spending.
- Save main.py.
- Run the full program to test all features:
python main.py
- Add two or three expenses with different categories (e.g. Food, Transport).
- Choose option 2 to view all expenses. You should see a formatted table with a TOTAL row at the bottom.
- Choose option 3 to view the summary. You should see each category with its total, plus an overall total.
- Choose option 4 to exit. Then run python main.py again and choose option 2. Your previous expenses should still be there.
Expenses disappearing after restart?
- Check that save_expense() in storage.py opens the file in append mode ("a"). Using "w" would overwrite the file each time.
- Confirm that expenses.csv exists in your expense-tracker folder. Open it in VS Code to check that rows are being written.
- Help me figure out why my expenses aren't persisting between runs
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and confirmed that all four menu options work as expected.
ⓧ I'd like to double check the full code
Here is the complete main.py file. Compare it against yours line by line.
from expense import Expense
from storage import save_expense, load_expenses
def add_expense():
try:
amount = float(input("Enter amount: $"))
except ValueError:
print("Invalid amount. Please enter a number.")
return
category = input("Enter category (e.g. Food, Transport, Entertainment): ").strip()
if not category:
print("Category cannot be empty.")
return
description = input("Enter description: ").strip()
if not description:
print("Description cannot be empty.")
return
expense = Expense(amount, category, description)
save_expense(expense)
print(f"Saved: {expense}")
def view_expenses():
expenses = load_expenses()
if not expenses:
print("No expenses recorded yet.")
return
print(f"\n{'Date':<12} | {'Category':<12} | {'Amount':>10} | Description")
print("-" * 60)
for expense in expenses:
print(expense)
print("-" * 60)
total = sum(e.amount for e in expenses)
print(f"{'TOTAL':<12} | {'':<12} | ${total:>8.2f} |")
def view_summary():
expenses = load_expenses()
if not expenses:
print("No expenses recorded yet.")
return
summary = {}
for expense in expenses:
if expense.category in summary:
summary[expense.category] += expense.amount
else:
summary[expense.category] = expense.amount
print("\n--- Spending Summary ---")
for category, total in summary.items():
print(f" {category:<15} ${total:.2f}")
print(f"\n {'TOTAL':<15} ${sum(summary.values()):.2f}")
def main():
print("=== Personal Expense Tracker ===\n")
while True:
print("\n1. Add Expense")
print("2. View All Expenses")
print("3. View Summary")
print("4. Exit")
choice = input("\nChoose an option (1-4): ").strip()
if choice == "1":
add_expense()
elif choice == "2":
view_expenses()
elif choice == "3":
view_summary()
elif choice == "4":
print("Goodbye!")
break
else:
print("Invalid option. Please enter 1-4.")
if __name__ == "__main__":
main()
Your expense tracker is fully functional. You can add expenses, view them in a table, and see spending breakdowns by category. Next up, you will take on a Secret Mission to add date-range filtering so you can answer questions like "how much did I spend in June?"
Secret mission
Add Date-Range Filtering
Your tracker shows all expenses at once. What if you want to know how much you spent last month? Add a "View by Month" option that filters expenses by a specific month and year.
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 or delete them entirely.
Resources you used:
- The expense-tracker folder on your Desktop (contains expense.py, storage.py, main.py, and expenses.csv).
Keep everything running
No action needed. Choose this if you want to keep using your expense tracker or plan to extend it further.
- Your expense-tracker folder stays on your Desktop.
- The expenses.csv file will continue to grow as you add expenses.
- You can run python main.py any time to use the tracker.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, there is nothing to pause. Your files sit on disk and use no resources while idle.
- Close the expense-tracker folder in VS Code to free up the editor tab.
- When you want to resume, open the folder again in VS Code and run python main.py.
Delete - I don't want to use this again
Remove the entire project folder to start fresh. This deletes all your code and saved expense data permanently.
- Close the expense-tracker folder in VS Code.
- Delete the folder by running one of the following commands in your terminal.
macOS
cd ~/Desktop
rm -rf expense-tracker
Windows
cd ~\Desktop
rmdir /s /q expense-tracker
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see expense-tracker in the output.
Nice Work!
Nice Work!
Nice work! You just built a fully functional command-line expense tracker in Python from scratch.
You went from an empty folder to a multi-file project that tracks spending, persists data between sessions, and summarizes your expenses by category.
Here's what you accomplished:
- Built an Expense class with __init__ and __str__ methods, putting object-oriented programming into practice.
- Created a CSV storage layer with save and load functions that persist data to disk using Python's csv module.
- Wired together a menu-driven CLI with input validation, organized across three files with clean imports.
- Secret Mission: Added date-range filtering with date parsing and list filtering to view expenses by month.
Ready to quiz yourself?