Build a CLI Expense Tracker in Python
Build a persistent command-line expense tracker using Python fundamentals.
Introduction
30 Second Summary
Tracking where your money goes is one of those tasks everyone knows they should do but rarely sticks with. A tool that lives right in your terminal makes it quick enough to actually use.
In this project, you will build a command-line expense tracker in Python. It lets you add expenses with categories, view spending summaries, and saves everything between sessions so you never lose your data.
What You'll Build
A single-file Python script that runs an interactive menu in your terminal, stores expenses in a JSON file, and displays formatted spending summaries.
By the end of this project, you'll have:
- A working CLI application with an interactive menu for adding, viewing, and deleting expenses.
- JSON-based data storage that persists your expenses across sessions.
- Category-based summaries that show your total spending per category with formatted output.
- Secret Mission: Add a monthly budget feature that warns you when spending exceeds your limit.
Are there any prerequisites?
None. This guide includes full setup instructions for Python and your text editor. No prior programming experience is required.
Before We Start
Before We Start...
Set Up Python and Your Project
Before you can build the expense tracker, you need the right tools installed and a project folder ready to go.
In this step, you will install Python, create your project folder, and confirm everything runs correctly with a quick test.
In this step, get ready to:
- Install and verify Python 3.10 or higher.
- Create your project folder and open it in a text editor.
- Run a test script to confirm your setup works.
Install and verify Python
Python is the programming language you will use for this entire project. You need version 3.10 or higher installed on your machine.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Python version by running this command:
python3 --version
You should see output in the format Python 3.x.y where x is 10 or higher.
✔️ I see version 3.10 or higher
Python is ready to go. Move on to the next substep.
ⓧ I see an older version
Your system has Python installed, but it is an older version. You need 3.10 or higher. Download and install the latest version from the official Python website.
macOS
- Go to python.org/downloads and download the latest macOS installer.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run python3 --version again.
Why the old version might still show up
macOS sometimes ships an older Python via Xcode Command Line Tools. The python.org installer places the new version in /usr/local/bin/ and updates the python3 symlink.
If the old version still shows after reinstalling, try running the versioned command directly (e.g. python3.13 --version).
Windows
- Go to python.org/downloads and download the latest Windows installer.
- Run the installer. Check the box that says Add python.exe to PATH before clicking Install Now.
- Close and reopen your terminal, then run python3 --version again to confirm.
Linux
- Update your package list and install the latest Python by running:
sudo apt update && sudo apt install python3 python3-venv
- Run python3 --version again to confirm the version is 3.10 or higher.
ⓧ Command not found
Python is not installed on your system yet. Follow the instructions for your operating system to install it.
macOS
- Go to python.org/downloads and download the latest macOS installer.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run python3 --version to confirm it installed.
Windows
- Go to python.org/downloads and download the latest Windows installer.
- Run the installer. Check the box that says Add python.exe to PATH before clicking Install Now.
- Close and reopen your terminal, then run python3 --version to confirm it installed.
Linux
- Install Python by running:
sudo apt update && sudo apt install python3 python3-venv
- Run python3 --version to confirm the version is 3.10 or higher.
Set up your project folder
You need a dedicated folder to hold your project file. You will create it on your Desktop so it is easy to find.
- 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
- Confirm the folder was created by running:
pwd
You should see a path ending in expense-tracker.
Seeing a 'no such file or directory' error?
If cd ~/Desktop fails, your Desktop folder may be in a different location. Try cd ~ instead to move to your home directory, then create the folder there.
Help me figure out the right path for my project folder
Now open VS Code so you can edit files in this folder.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- 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.
What is VS Code?
VS Code (Visual Studio Code) is a free text editor built for writing code. It gives you syntax highlighting, a built-in terminal, and file management in one window.
Now create the file you will write your Python code in.
- In the VS Code Explorer sidebar on the left, hover over the EXPENSE-TRACKER heading.
- Click the New File icon (a page with a plus sign).
- Type expense_tracker.py and press Enter.
Your new file opens in the editor, ready for code.
Run a hello world test
Before building anything complex, you should verify that Python can run your file successfully.
- In expense_tracker.py, type the following code:
print("Expense Tracker is ready!")
What does this code do?
The print() function outputs text to your terminal. If you see the message when you run the script, it means Python found your file and executed it correctly.
- Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, below Escape).
- Run your script by entering this command:
python3 expense_tracker.py
You should see Expense Tracker is ready! printed in your terminal.
Don't see the expected output?
- Make sure you saved expense_tracker.py before running the command.
- Check that your terminal is in the expense-tracker folder. Run pwd to confirm.
- Verify the filename is exactly expense_tracker.py with underscores, not hyphens.
Help me debug why my script isn't running
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
Here is the complete expense_tracker.py at the end of this step:
print("Expense Tracker is ready!")
Your environment is ready. Next up, you will build the data layer that loads, saves, and adds expenses.
Build the Data Layer
Your environment is set up and Python is running. Now it's time to write the code that manages your expense data.
Every expense tracker needs a way to store and retrieve data. In this step, you'll create functions that save expenses to a JSON file and load them back. You'll also build a function that handles adding new expenses with input validation.
In this step, get ready to:
- Create functions that load and save expense data to a JSON file.
- Build an add_expense function with input validation.
- Test the data layer by adding an expense and confirming it saves.
Create load and save functions
Before your tracker can add or display expenses, it needs a place to store them. You'll use a JSON file as your database. Two functions handle all the reading and writing.
- In expense_tracker.py, select all the existing code (the print statement) and replace it with the following:
import json
import os
from datetime import date
# File where all expenses are stored
DATA_FILE = "expenses.json"
def load_expenses():
# Return an empty list if the file does not exist yet
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
def save_expenses(expenses):
# Write the expenses list to JSON with readable formatting
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
What does this code do?
This sets up the backbone of your data layer.
- The three imports bring in json for reading and writing JSON, os for checking if a file exists, and date for stamping each expense with today's date.
- DATA_FILE is a constant that stores the filename in one place. If you ever rename the file, you only change this one line.
- load_expenses() checks whether expenses.json exists. If it does, the function reads it and returns the data. If not, it returns an empty list so the rest of your code doesn't crash.
- save_expenses() writes the full expenses list to the JSON file. The indent=2 argument formats the output so it's easy to read when you open the file yourself.
- Save expense_tracker.py.
You won't see any output yet. The next substep creates a function that uses these to add a real expense.
Seeing a red underline or import error?
- Make sure you deleted the old print("Expense Tracker is ready!") line. The imports must be at the very top of the file.
- Check that you typed from datetime import date (lowercase date) and not from datetime import datetime.
Still stuck? Why is my Python import not working?
Create the add_expense function
Now you need a way to actually create an expense. The add_expense function prompts the user for details, validates the input, and saves the new entry. It also needs a helper function to assign unique IDs.
- Add the following code below your save_expenses function in expense_tracker.py:
def get_next_id(expenses):
# Return 1 if the list is empty, otherwise increment the highest ID
if not expenses:
return 1
return max(expense["id"] for expense in expenses) + 1
def add_expense(expenses):
# Ask for the dollar amount and validate it is a number
try:
amount = float(input("Enter amount: $"))
except ValueError:
print("Invalid amount. Please enter a number.")
return
# Ask for a category and reject empty input
category = input("Enter category (e.g., food, transport, entertainment): ").strip()
if not category:
print("Category cannot be empty.")
return
description = input("Enter description (optional): ").strip()
# Build the expense dictionary with all required fields
expense = {
"id": get_next_id(expenses),
"date": date.today().isoformat(),
"amount": amount,
"category": category.lower(),
"description": description,
}
# Save and confirm
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} in '{category}' on {expense['date']}")
What does this code do?
get_next_id() looks at all existing expenses and returns the next available ID. If the list is empty, it starts at 1.
add_expense() walks the user through entering an expense step by step.
- It wraps the amount input in a try/except block so typing letters instead of a number doesn't crash the program.
- It rejects an empty category so every expense is organized.
- It builds a dictionary with five fields: id, date, amount, category, and description.
- Finally, it appends the new expense to the list, saves everything to the JSON file, and prints a confirmation.
- Save expense_tracker.py.
You won't see output yet. The next substep runs the function so you can confirm everything works together.
Seeing a syntax error?
- Make sure there is a blank line between the end of save_expenses and the start of def get_next_id. Python requires blank lines between top-level function definitions.
- Check that the expense dictionary uses curly braces {} and each key-value pair ends with a comma.
- Confirm the f is present before the final print string. Without it, the curly braces won't be treated as expressions.
Need a hand? Help me fix a syntax error in my add_expense function.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on to the test.
ⓧ I'd like to double check the full code
Here is the complete expense_tracker.py file so far. Compare your code against this:
import json
import os
from datetime import date
DATA_FILE = "expenses.json"
def load_expenses():
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
def save_expenses(expenses):
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def get_next_id(expenses):
if not expenses:
return 1
return max(expense["id"] for expense in expenses) + 1
def add_expense(expenses):
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 (optional): ").strip()
expense = {
"id": get_next_id(expenses),
"date": date.today().isoformat(),
"amount": amount,
"category": category.lower(),
"description": description,
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} in '{category}' on {expense['date']}")
Test the data layer
Your functions are defined, but you haven't run them yet. Time to confirm that adding an expense actually creates a JSON file with your data inside.
- At the very bottom of expense_tracker.py, add these two temporary test lines:
# Temporary test - remove after confirming it works
expenses = load_expenses()
add_expense(expenses)
- Save the file, then run it by executing this command:
python3 expense_tracker.py
- When prompted, enter a test expense. For example:
- Amount: 12.50
- Category: food
- Description: lunch
You should see a confirmation message like this:
Enter amount: $12.50
Enter category (e.g., food, transport, entertainment): food
Enter description (optional): lunch
Added: $12.50 in 'food' on 2026-05-18
- Confirm the JSON file was created by running:
cat expenses.json
You should see your expense stored as a JSON list with all five fields: id, date, amount, category, and description.
- Now remove the two temporary test lines (expenses = load_expenses() and add_expense(expenses)) and the comment above them from the bottom of your file.
- Save expense_tracker.py.
Why remove the test lines?
Those lines run every time you execute the script. In the next step, you'll build a proper menu loop that calls add_expense only when the user chooses to add an expense. Leaving the test lines in would prompt you for input before the menu even appears.
Don't see the confirmation message?
- If you see Invalid amount. Please enter a number., make sure you entered just a number without the dollar sign. The prompt already includes the $ symbol.
- If you see NameError: name 'load_expenses' is not defined, make sure the test lines are at the very bottom of the file, below all function definitions.
- If no expenses.json file appears after running, check that your save_expenses function uses "w" mode (write) in the open() call.
Need help? My expense tracker script isn't saving data to a JSON file.
Your data layer is solid. Every expense you add gets validated, assigned an ID, and saved to a file that persists between runs. Next up, you'll build the menu and display functions that bring everything together into a real interactive tool.
Build the Menu and Display
Your expense tracker can store data, but there is no way for a user to interact with it. Right now, the only way to add an expense is by calling the function directly in code.
In this step, you will build the user interface: a text-based menu that loops until the user decides to quit. You will also create display functions that format your data into readable tables and summaries.
In this step, get ready to:
- Create a menu loop that displays options and routes user input.
- Build formatted display functions for viewing expenses and summaries.
- Add a delete function and test the full workflow end to end.
Create the menu loop
Every interactive command-line application needs a main loop. This is a while True loop that keeps the program running, displaying options and waiting for the user to make a choice.
- In expense_tracker.py, scroll to the bottom of the file (below the add_expense function).
- Add the main() function and the entry point guard by copying the following code:
def main():
print("=" * 40)
print(" EXPENSE TRACKER")
print("=" * 40)
expenses = load_expenses()
while True:
print("\n1. Add Expense")
print("2. View Expenses")
print("3. View Summary")
print("4. Delete Expense")
print("5. Exit")
choice = input("\nChoose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
view_summary(expenses)
elif choice == "4":
delete_expense(expenses)
elif choice == "5":
print("\nGoodbye! Your expenses are saved.")
break
else:
print("Invalid option. Please choose 1-5.")
if __name__ == "__main__":
main()
What does this code do?
- print("=" * 40) displays a welcome banner when the program starts.
- load_expenses() reads your saved expenses from the JSON file into memory once at startup.
- The while True loop keeps the menu visible until the user picks option 5, which triggers break to exit.
- if __name__ == "__main__": ensures main() only runs when you execute the file directly, not when importing it as a module.
- Save expense_tracker.py.
- Run your script to confirm the menu appears by running:
python3 expense_tracker.py
You should see the welcome banner and five menu options. Type 1 to test adding an expense, then type 5 to exit.
What about options 2, 3, and 4?
Those options call functions that do not exist yet (view_expenses, view_summary, delete_expense). If you try them now, you will see a NameError. You will build those functions next.
Not seeing the menu?
- Make sure the if __name__ == "__main__": line is at the very bottom of your file with no indentation.
- Check that main() under the guard is indented with exactly 4 spaces.
- Confirm you saved the file before running it.
Why isn't my expense tracker menu showing up when I run the script?
Create the view functions
Now that the menu loop is running, you need to fill in the functions it calls. The view functions format your raw expense data into readable output.
- In expense_tracker.py, find the blank space between the add_expense function and the main function.
- Add the view_expenses function by copying this code:
def view_expenses(expenses):
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'ID':<5}{'Date':<12}{'Category':<15}{'Amount':<10}{'Description'}")
print("-" * 55)
for exp in expenses:
print(
f"{exp['id']:<5}{exp['date']:<12}{exp['category']:<15}"
f"${exp['amount']:<9.2f}{exp['description']}"
)
print()
What does this code do?
- The early return on an empty list prevents printing column headers with no data beneath them.
- The :<5, :<12, etc. are Python f-string alignment operators. They pad each value to a fixed width so your output lines up in columns.
- :<9.2f formats the amount to exactly 2 decimal places and pads it to 9 characters wide.
- Save expense_tracker.py and run it with python3 expense_tracker.py.
- Choose option 2 to view expenses. If you added a test expense earlier, you should see it displayed in a formatted table with columns for ID, date, category, amount, and description.
Seeing a NameError or misaligned columns?
- A NameError means Python cannot find view_expenses. Make sure you placed the function ABOVE main() in the file.
- If columns are misaligned, double-check the f-string format specifiers. The angle bracket < means left-align, and the number after it sets the column width.
Help me fix my view_expenses function. It's not displaying correctly.
Now add the summary function. This groups your expenses by category and calculates totals.
- Directly below view_expenses (and still above main), add the view_summary function by copying this code:
def view_summary(expenses):
if not expenses:
print("\nNo expenses recorded yet.")
return
categories = {}
for exp in expenses:
cat = exp["category"]
categories[cat] = categories.get(cat, 0) + exp["amount"]
total = sum(categories.values())
print(f"\n{'Category':<20}{'Total'}")
print("-" * 30)
for cat, amount in sorted(categories.items()):
print(f"{cat:<20}${amount:.2f}")
print("-" * 30)
print(f"{'TOTAL':<20}${total:.2f}\n")
What does this code do?
- The categories dictionary accumulates the total spending per category. Each category name becomes a key, and the value is the running total.
- categories.get(cat, 0) returns the current total for that category, or 0 if the category has not been seen yet. This avoids a KeyError.
- sorted(categories.items()) prints categories in alphabetical order for consistency.
- Save expense_tracker.py and run it again.
- Choose option 3 to view the summary. You should see a table showing each category with its total amount, plus a grand total at the bottom.
Summary showing $0.00 for everything?
- Make sure you have at least one expense saved. Run the script, choose option 1 to add a test expense first, then try option 3 again.
- Check that exp["amount"] matches the key name used in add_expense. Both must use "amount" as the dictionary key.
Help me debug my view_summary function.
Create the delete function and wire it all up
The last feature to add is deletion. Users need a way to remove an expense they entered by mistake. This function will show the current expenses, ask for an ID, and remove the matching entry.
- In expense_tracker.py, add the delete_expense function between view_summary and main by copying this code:
def delete_expense(expenses):
if not expenses:
print("\nNo expenses to delete.")
return
view_expenses(expenses)
try:
expense_id = int(input("Enter the ID of the expense to delete: "))
except ValueError:
print("Invalid ID. Please enter a number.")
return
for i, exp in enumerate(expenses):
if exp["id"] == expense_id:
removed = expenses.pop(i)
save_expenses(expenses)
print(f"\nDeleted: ${removed['amount']:.2f} in '{removed['category']}' from {removed['date']}")
return
print(f"No expense found with ID {expense_id}.")
What does this code do?
- view_expenses(expenses) is called first so the user can see the IDs before choosing which to delete.
- enumerate(expenses) gives both the index position and the expense dictionary. The index is needed for expenses.pop(i) to remove the correct item from the list.
- After removing the expense, save_expenses(expenses) writes the updated list to disk immediately so the deletion persists.
- Save expense_tracker.py.
- Run the full workflow test by running:
python3 expense_tracker.py
- Choose option 1 to add two or three test expenses with different categories.
- Choose option 2 to view all expenses in the table.
- Choose option 3 to see the category summary with totals.
- Choose option 4 to delete one expense by its ID.
- Choose option 5 to exit.
- Run the script again and choose option 2. Your remaining expenses should still be there, proving data persists between runs.
Delete not removing the expense?
- Make sure you are entering the ID number shown in the leftmost column when viewing expenses, not the row number.
- Check that delete_expense is placed ABOVE main() in the file. Python reads top to bottom, so the function must be defined before it is called.
- Open expenses.json in your text editor to confirm the deleted expense is actually gone from the file.
Help me debug my delete_expense function.
✔️ Awesome, I've got everything!
Great. Double check you have saved expense_tracker.py and that all five menu options work correctly.
ⓧ I'd like to double check the full code
Here is the complete expense_tracker.py file at this point. Compare your code against it:
import json
import os
from datetime import date
DATA_FILE = "expenses.json"
def load_expenses():
if not os.path.exists(DATA_FILE):
return []
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
def save_expenses(expenses):
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def get_next_id(expenses):
if not expenses:
return 1
return max(expense["id"] for expense in expenses) + 1
def add_expense(expenses):
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 (optional): ").strip()
expense = {
"id": get_next_id(expenses),
"date": date.today().isoformat(),
"amount": amount,
"category": category.lower(),
"description": description,
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} in '{category}' on {expense['date']}")
def view_expenses(expenses):
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'ID':<5}{'Date':<12}{'Category':<15}{'Amount':<10}{'Description'}")
print("-" * 55)
for exp in expenses:
print(
f"{exp['id']:<5}{exp['date']:<12}{exp['category']:<15}"
f"${exp['amount']:<9.2f}{exp['description']}"
)
print()
def view_summary(expenses):
if not expenses:
print("\nNo expenses recorded yet.")
return
categories = {}
for exp in expenses:
cat = exp["category"]
categories[cat] = categories.get(cat, 0) + exp["amount"]
total = sum(categories.values())
print(f"\n{'Category':<20}{'Total'}")
print("-" * 30)
for cat, amount in sorted(categories.items()):
print(f"{cat:<20}${amount:.2f}")
print("-" * 30)
print(f"{'TOTAL':<20}${total:.2f}\n")
def delete_expense(expenses):
if not expenses:
print("\nNo expenses to delete.")
return
view_expenses(expenses)
try:
expense_id = int(input("Enter the ID of the expense to delete: "))
except ValueError:
print("Invalid ID. Please enter a number.")
return
for i, exp in enumerate(expenses):
if exp["id"] == expense_id:
removed = expenses.pop(i)
save_expenses(expenses)
print(f"\nDeleted: ${removed['amount']:.2f} in '{removed['category']}' from {removed['date']}")
return
print(f"No expense found with ID {expense_id}.")
def main():
print("=" * 40)
print(" EXPENSE TRACKER")
print("=" * 40)
expenses = load_expenses()
while True:
print("\n1. Add Expense")
print("2. View Expenses")
print("3. View Summary")
print("4. Delete Expense")
print("5. Exit")
choice = input("\nChoose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
view_summary(expenses)
elif choice == "4":
delete_expense(expenses)
elif choice == "5":
print("\nGoodbye! Your expenses are saved.")
break
else:
print("Invalid option. Please choose 1-5.")
if __name__ == "__main__":
main()
Your expense tracker is fully functional. You can add, view, summarize, and delete expenses, and everything persists between sessions. Next up, take on the Secret Mission to add budget tracking that warns you when you are overspending.
Secret mission
Add a Monthly Budget Feature
Your expense tracker records what you spend. But what if it could also warn you when you're spending too much? Add a monthly budget feature that alerts you the moment your spending crosses the line.
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:
- Project folder containing expense_tracker.py.
- Data file expenses.json (created when you first add an expense).
Keep everything running
No action needed. Choose this if you plan to keep using or extending your expense tracker.
- Your project folder stays on your Desktop.
- Your expenses.json file keeps all your saved data.
- You can run the tracker anytime with python3 expense_tracker.py.
Pause - I'll come back to this later
Since this is a local Python script with no running processes, there is nothing to pause. Your files sit on disk until you return.
- Your project folder and data file remain on your Desktop.
- No background processes are running. The script only runs when you execute it.
Delete - I don't want to use this again
Remove all project files from your machine.
- Delete your project folder by running the command for your operating system.
macOS
- Open your terminal and run:
rm -rf ~/Desktop/expense-tracker
Windows
- Open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\expense-tracker
This removes both expense_tracker.py and expenses.json permanently.
Nice Work!
Nice Work!
You just built a real Python program from scratch. Your expense tracker runs in the terminal, saves data between sessions, and handles invalid input gracefully.
You've learned how to:
- Built a persistent CLI application that saves and loads data from a JSON file.
- Wrote modular functions for each feature, keeping your code organized and reusable.
- Handled user input with validation and error handling so your program never crashes on bad data.
- Added budget tracking with spending alerts that warn you when you exceed your monthly limit.
Ready to quiz yourself?