Build a CLI Expense Tracker in Python
Build a command-line expense tracker in Python with data persistence.
Introduction
30 Second Summary
Everyone has that moment where they check their bank account and wonder where all their money went. Tracking your spending is one of those problems that's simple enough to describe but surprisingly useful to solve with code.
In this project, you will build a command-line expense tracker in Python that lets you log expenses, organize them by category, and see exactly where your money goes.
What You'll Build
You'll create a single Python file that runs an interactive terminal program, saving your expense data to a JSON file so it persists between sessions.
By the end of this project, you'll have:
- A working command-line application that adds, lists, and summarizes expenses.
- Hands-on experience with Python fundamentals including functions, dictionaries, loops, and type hints.
- A JSON-based data layer that saves and loads your expenses between sessions.
- Secret Mission: Add monthly budget tracking with warnings when you're close to overspending.
Are there any prerequisites?
None. This guide walks you through installing Python and setting up VS Code from scratch. All you need is a computer running Ubuntu Linux.
Before We Start
You're building a command-line expense tracker in Python. This project gets your Python fundamentals solid before you move on to web frameworks like FastAPI.
Every concept you learn here, including functions, dictionaries, file I/O, and type hints, shows up directly in backend development. Let's start by setting up your development environment.
In this step, get ready to:
- Install Python 3 and verify it's working.
- Set up VS Code with the Python extension for code assistance.
- Create your project folder and first Python file.
Install Python 3 and verify
Python is the programming language you'll use throughout this project. You also need pip (Python's package manager) and venv (for creating isolated project environments).
- Install Python 3 and its essential tools by running this command:
sudo apt update && sudo apt install python3 python3-pip python3-venv -y
What does this command do?
- sudo apt update refreshes your system's list of available packages so you get the latest versions.
- sudo apt install installs three packages: python3 (the interpreter), python3-pip (the package manager for installing libraries), and python3-venv (for creating virtual environments).
- The -y flag automatically confirms the installation so you don't have to type "yes" manually.
- Verify Python installed correctly by running these commands:
python3 --version
pip3 --version
You should see version numbers for both. python3 --version prints something like Python 3.12.x, and pip3 --version shows pip's version and install path.
Seeing 'command not found'?
- Make sure the install command completed without errors. Scroll up in your terminal to check for any red error messages.
- Try closing and reopening your terminal, then run the version checks again.
- If you see a permissions error, make sure you included sudo at the start of the install command.
- Help me troubleshoot Python installation on Ubuntu.
Install the VS Code Python extension
The Python extension for VS Code gives you code completion, error highlighting, and a one-click run button. It also installs Pylance automatically to provide IntelliSense.
- Press the Super key to open the Activities overview.
- Type Visual Studio Code and press Enter to open it.
- Open the Extensions panel by pressing Ctrl+Shift+X.
- In the search bar at the top of the panel, type Python.
- Find the extension called Python by Microsoft (the extension ID is ms-python.python).
- Click Install.
What is Pylance?
Pylance is the default language server for Python in VS Code. It powers code completion, type checking, and error detection as you type.
It installs automatically alongside the Python extension. You don't need to install it separately.
Once the install finishes, you should see the extension listed under Installed in the Extensions panel.
Extension not appearing?
- Make sure you searched for Python (not "Pylance" or "Python Debugger"). The main extension by Microsoft bundles everything.
- If the install button is greyed out, try reloading VS Code by pressing Ctrl+Shift+P and typing Developer: Reload Window.
- Help me install the Python extension in VS Code.
Create your project folder and file
Now you'll create a dedicated folder for this project and set up your first Python file inside it.
- Create your project folder and move into it by running this command in your terminal:
mkdir ~/expense-tracker && cd ~/expense-tracker
What does this command do?
- mkdir ~/expense-tracker creates a new folder called expense-tracker in your home directory.
- cd ~/expense-tracker moves your terminal into that folder so future commands run from there.
- Open this folder in VS Code by running:
code .
VS Code opens with the expense-tracker folder loaded in the Explorer sidebar on the left.
- In the Explorer sidebar, hover over the EXPENSE-TRACKER folder name.
- Click the New File icon (a page with a plus sign).
- Type expense_tracker.py and press Enter.
The file opens in the editor. Check the bottom-left status bar in VS Code. It should show Python 3.x.x, confirming that VS Code detected your Python interpreter.
Don't see a Python version in the status bar?
Press Ctrl+Shift+P to open the Command Palette, type Python: Select Interpreter, and pick the Python 3 option from the list.
- Type the following code into your file:
print("Hello, Python!")
- Save the file by pressing Ctrl+S.
- Click the play button (triangle icon) in the top-right corner of the editor to run your file.
You should see Hello, Python! printed in the terminal panel at the bottom of VS Code.
Not seeing any output?
- Make sure you saved the file (Ctrl+S) before clicking the play button. Unsaved changes won't run.
- Check that your file is named exactly expense_tracker.py with underscores, not hyphens.
- If you see an error about the Python interpreter, press Ctrl+Shift+P, type Python: Select Interpreter, and select a Python 3 version.
- Help me run my first Python file in VS Code.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on.
ⓧ I'd like to double check the full code
print("Hello, Python!")
Your environment is ready. Next up, you'll build the core data layer that stores and manages expense records.
Build the Core Data Layer
Your Python environment is ready and VS Code is configured for Python development.
Now it's time to write actual Python code. You'll build the core functions that store, save, and retrieve expense data using Python's built-in data structures.
In this step, get ready to:
- Create functions that load and save expense data to a JSON file.
- Write an add_expense() function that records new expenses with type hints.
- Build display functions that format expenses into a readable table and category summary.
Define the data structure and create the add_expense function
Every expense tracker needs a way to store data and keep it safe between sessions. You'll start by building the persistence layer: functions that read and write expense data to a JSON file on disk.
- In VS Code, open expense_tracker.py and select all the existing code (the test print statement).
- Delete it so the file is empty.
Why start fresh?
The print("Hello, Python!") line was just a quick test to confirm Python was working. Now you'll replace it with structured, reusable code.
- Set up the imports, data file constant, and persistence functions by copying this code into your empty expense_tracker.py file:
import json
import datetime
DATA_FILE: str = "expenses.json"
def load_expenses() -> list[dict]:
"""Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
return []
except json.JSONDecodeError:
print("Warning: Data file is corrupted. Starting fresh.")
return []
def save_expenses(expenses: list[dict]) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
What does this code do?
- import json and import datetime bring in two modules from Python's standard library. No installation needed.
- DATA_FILE: str = "expenses.json" is a constant that stores the filename. The : str part is a type hint that tells Python (and your editor) this value is a string.
- load_expenses() tries to open the JSON file and read its data. If the file doesn't exist yet (first run), it catches the FileNotFoundError and returns an empty list instead of crashing.
- save_expenses() writes the entire expenses list to the JSON file. The with statement ensures the file is properly closed even if something goes wrong.
What are type hints?
Type hints like -> list[dict] and -> None tell you (and your editor) what a function returns. They don't change how your code runs, but they help catch mistakes early.
Learning them now pays off later. Frameworks like FastAPI require type hints for their core functionality.
- Save expense_tracker.py.
- Run the file by clicking the play button in the top-right corner of VS Code.
- You should see no output and no errors in the terminal panel below. A clean run means Python parsed all your functions successfully.
Seeing a syntax error?
- Check that you deleted the old print("Hello, Python!") line. Leftover code above the imports can cause errors.
- Make sure your indentation uses spaces, not tabs. Python is strict about consistent indentation.
- Verify the try, except, and with blocks are indented correctly. Each level should be 4 spaces deeper than the one above.
Help me debug a syntax error in my expense_tracker.py file
- Now add the add_expense() function below save_expenses(). Copy this code at the bottom of your file:
def add_expense(expenses: list[dict], amount: float, category: str, description: str) -> None:
"""Add a new expense to the list and save to file."""
expense: dict = {
"amount": amount,
"category": category,
"description": description,
"date": datetime.date.today().isoformat()
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} for '{description}' in [{category}]")
What does this code do?
- add_expense() takes the expenses list plus three pieces of data: the amount (a float), category (a string), and description (a string).
- It creates a dictionary with four keys: amount, category, description, and date. Dictionaries are Python's way of storing labeled data.
- datetime.date.today().isoformat() gets today's date as a string like "2026-06-10". This format is easy to store in JSON and sort later.
- The f-string at the end prints a confirmation. The :.2f format shows the amount with exactly two decimal places.
- Save expense_tracker.py.
- Test the function by running this command in your terminal:
python3 -c "from expense_tracker import load_expenses, add_expense; e = load_expenses(); add_expense(e, 9.99, 'food', 'coffee')"
- You should see Added: $9.99 for 'coffee' in [food] printed in the terminal.
- You should also see a new file called expenses.json appear in your Explorer sidebar. This file now holds your test expense.
Don't see the confirmation message?
- Make sure you're running the command from inside the ~/expense-tracker folder. Run pwd to check your current directory.
- If you see ModuleNotFoundError, your terminal is in the wrong folder. Run cd ~/expense-tracker first.
- If you see NameError, double check that save_expenses is defined above add_expense in your file. Python reads top to bottom.
Help me fix an error when testing add_expense
Create the list_expenses and get_summary functions
You can add expenses now, but you can't see them yet. Next you'll write two display functions: one that prints all expenses in a formatted table, and one that groups spending by category.
- Add the list_expenses() function below add_expense(). Copy this code at the bottom of your file:
def list_expenses(expenses: list[dict]) -> None:
"""Display all expenses in a formatted list."""
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>10}")
print("-" * 60)
for expense in expenses:
print(
f"{expense['date']:<12} "
f"{expense['category']:<15} "
f"{expense['description']:<20} "
f"${expense['amount']:>9.2f}"
)
total: float = sum(expense["amount"] for expense in expenses)
print("-" * 60)
print(f"{'Total:':<49} ${total:>9.2f}")
What does this code do?
- if not expenses: checks whether the list is empty. If so, it prints a helpful message and exits early with return. This prevents the function from printing headers with no data beneath them.
- The f-strings use alignment formatting. :<12 means left-aligned in 12 characters, and :>10 means right-aligned in 10 characters. This creates neat columns.
- The for loop iterates through every expense dictionary in the list and prints its values in the same column format.
- sum(expense["amount"] for expense in expenses) calculates the total of all expenses in a single line using a generator expression.
- Save expense_tracker.py.
- Test the function by running this command in your terminal:
python3 -c "from expense_tracker import load_expenses, list_expenses; e = load_expenses(); list_expenses(e)"
- You should see a formatted table with the test expense you added earlier (the $9.99 coffee).
Seeing an empty message instead of the table?
- If you see "No expenses recorded yet.", the expenses.json file might be missing. Re-run the add_expense test command from the previous substep to create it.
- Check that expenses.json exists in your Explorer sidebar. If you see it but still get the empty message, open it and verify it contains data.
Help me debug why list_expenses shows no data
- Now add the final function, get_summary(), below list_expenses(). Copy this code at the bottom of your file:
def get_summary(expenses: list[dict]) -> None:
"""Display spending totals grouped by category."""
if not expenses:
print("\nNo expenses to summarize.")
return
categories: dict[str, float] = {}
for expense in expenses:
category: str = expense["category"]
if category in categories:
categories[category] += expense["amount"]
else:
categories[category] = expense["amount"]
print("\n--- Spending by Category ---")
for category, total in sorted(categories.items()):
print(f" {category:<15} ${total:.2f}")
grand_total: float = sum(categories.values())
print(f"\n {'TOTAL':<15} ${grand_total:.2f}")
What does this code do?
- categories: dict[str, float] = {} creates an empty dictionary that maps each category name (string) to its total amount (float). This is called the dictionary accumulator pattern.
- The for loop checks if a category already exists in the dictionary. If it does, it adds to the running total. If not, it creates a new entry. This groups spending without needing to know the categories in advance.
- sorted(categories.items()) returns the categories in alphabetical order so the output is consistent every time.
- Save expense_tracker.py.
- Test the summary function by running this command:
python3 -c "from expense_tracker import load_expenses, get_summary; e = load_expenses(); get_summary(e)"
- You should see a "Spending by Category" section showing food with a total of $9.99.
Seeing a KeyError or TypeError?
- If you see KeyError, check that the dictionary keys in get_summary() match exactly what add_expense() stores. The key names must be identical strings.
- If you see TypeError, make sure you're using += to add amounts, not string concatenation. The values in the dictionary should be floats.
Help me fix an error in my get_summary function
- Clean up the test data by deleting expenses.json so you start fresh in the next step:
rm expenses.json
✔️ Awesome, I've got everything!
Great. Double check you've saved expense_tracker.py and that expenses.json has been deleted.
ⓧ I'd like to double check the full code
Here's the complete expense_tracker.py file at this point:
import json
import datetime
DATA_FILE: str = "expenses.json"
def load_expenses() -> list[dict]:
"""Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
return []
except json.JSONDecodeError:
print("Warning: Data file is corrupted. Starting fresh.")
return []
def save_expenses(expenses: list[dict]) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses: list[dict], amount: float, category: str, description: str) -> None:
"""Add a new expense to the list and save to file."""
expense: dict = {
"amount": amount,
"category": category,
"description": description,
"date": datetime.date.today().isoformat()
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} for '{description}' in [{category}]")
def list_expenses(expenses: list[dict]) -> None:
"""Display all expenses in a formatted list."""
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>10}")
print("-" * 60)
for expense in expenses:
print(
f"{expense['date']:<12} "
f"{expense['category']:<15} "
f"{expense['description']:<20} "
f"${expense['amount']:>9.2f}"
)
total: float = sum(expense["amount"] for expense in expenses)
print("-" * 60)
print(f"{'Total:':<49} ${total:>9.2f}")
def get_summary(expenses: list[dict]) -> None:
"""Display spending totals grouped by category."""
if not expenses:
print("\nNo expenses to summarize.")
return
categories: dict[str, float] = {}
for expense in expenses:
category: str = expense["category"]
if category in categories:
categories[category] += expense["amount"]
else:
categories[category] = expense["amount"]
print("\n--- Spending by Category ---")
for category, total in sorted(categories.items()):
print(f" {category:<15} ${total:.2f}")
grand_total: float = sum(categories.values())
print(f"\n {'TOTAL':<15} ${grand_total:.2f}")
Your data layer is complete. Next up, you'll wire these functions into a persistent save/load cycle and build the interactive menu that ties everything together.
Save and Load Data with JSON
Your expense tracker has functions that add, list, and summarize expenses. There is one problem though. Every time you close the program, all that data disappears.
In this step, you'll walk through the JSON persistence functions you wrote in the previous step. Then you'll test that expense data actually survives between program runs.
In this step, get ready to:
- Understand how the save function writes data to a JSON file.
- Understand how the load function reads data back safely with error handling.
- Verify that expense data persists between program runs.
Understand the save function
Your program needs a way to write expense data to disk so it sticks around after the program exits. The save_expenses() function in your file handles this.
- In VS Code, find the save_expenses() function in expense_tracker.py:
def save_expenses(expenses: list[dict]) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
What does this code do?
This function takes your list of expense dictionaries and writes it to expenses.json.
- with open(DATA_FILE, "w") as f opens the file in write mode. The with statement guarantees the file is closed properly even if an error occurs mid-write.
- json.dump(expenses, f, indent=2) converts your Python list into JSON text and writes it to the file. The indent=2 makes the output human-readable.
Why use the with statement?
Opening a file creates a connection between your program and the operating system. If you forget to close it (or an error happens before you do), the data might not get written completely.
The with statement handles closing automatically. You will see this pattern in almost every Python program that works with files.
Understand the load function
Saving is half the story. Your program also needs to read that JSON file back into memory when it starts up. This is where error handling becomes important.
- Find the load_expenses() function above save_expenses() in your file:
def load_expenses() -> list[dict]:
"""Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
return []
except json.JSONDecodeError:
print("Warning: Data file is corrupted. Starting fresh.")
return []
What does this code do?
This function tries to read expenses from the JSON file. It uses try/except blocks to handle two things that could go wrong.
- json.load(f) reads the JSON file and converts it back into a Python list of dictionaries.
- except FileNotFoundError catches the case where the file does not exist yet (the very first time you run the program). It returns an empty list so the program starts fresh.
- except json.JSONDecodeError catches the case where the file exists but contains invalid JSON (maybe it got corrupted). It also returns an empty list.
What is try/except?
Python's try/except lets you attempt something risky and specify what to do if it fails. The code inside try: runs first. If a specific error occurs, Python jumps to the matching except block instead of crashing.
This is Python's error handling pattern. You will use it any time your program interacts with something unpredictable like files, network requests, or user input.
Verify data persists between sessions
The add_expense() function already calls save_expenses() after appending a new expense. This means every expense is saved to disk the moment you add it.
- Find add_expense() in your file and confirm it calls save_expenses(expenses) after expenses.append(expense):
def add_expense(expenses: list[dict], amount: float, category: str, description: str) -> None:
"""Add a new expense to the list and save to file."""
expense: dict = {
"amount": amount,
"category": category,
"description": description,
"date": datetime.date.today().isoformat()
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} for '{description}' in [{category}]")
How does the data flow connect?
When the program starts, load_expenses() reads the JSON file into a Python list. When you add a new expense, add_expense() appends it to that list and immediately calls save_expenses() to write the updated list back to disk.
This means your data is always saved. Even if the program crashes right after adding an expense, the JSON file already has the latest data.
Now test that this actually works. You will start an interactive Python session, add an expense, exit, then restart and confirm the data is still there.
- Start an interactive Python session with your file loaded by running this command in the terminal:
python3 -i expense_tracker.py
What does the -i flag do?
The -i flag tells Python to run the file (which defines all your functions) and then drop into interactive mode. This lets you call your functions directly without writing a main() function yet.
- In the interactive prompt (>>>), load any existing expenses and add a test expense by typing these lines one at a time:
expenses = load_expenses()
add_expense(expenses, 12.50, "food", "Lunch")
You should see the message: Added: $12.50 for 'Lunch' in [food].
- Exit the interactive session by typing:
exit()
- Confirm the JSON file was created by running:
cat expenses.json
You should see your expense stored as formatted JSON with the amount, category, description, and today's date.
- Now restart the interactive session to prove data persists:
python3 -i expense_tracker.py
- Load the expenses and print them to confirm your previous entry survived:
expenses = load_expenses()
print(expenses)
You should see a list containing your lunch expense dictionary with all four keys intact.
- Exit the interactive session:
exit()
Not seeing your expense after restart?
- Make sure you are running python3 -i expense_tracker.py from inside the ~/expense-tracker folder. If you are in a different directory, the file will not be found.
- Check that expenses.json exists by running ls in your terminal. If it is missing, the add_expense() call may have raised an error you missed.
Help me debug why my expenses are not persisting between sessions.
✔️ Awesome, I've got everything!
Great. Double check you have saved expense_tracker.py and that expenses.json exists in your ~/expense-tracker folder.
ⓧ I'd like to double check the full code
Here is the complete expense_tracker.py at this point:
import json
import datetime
DATA_FILE: str = "expenses.json"
def load_expenses() -> list[dict]:
"""Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
return []
except json.JSONDecodeError:
print("Warning: Data file is corrupted. Starting fresh.")
return []
def save_expenses(expenses: list[dict]) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses: list[dict], amount: float, category: str, description: str) -> None:
"""Add a new expense to the list and save to file."""
expense: dict = {
"amount": amount,
"category": category,
"description": description,
"date": datetime.date.today().isoformat()
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} for '{description}' in [{category}]")
def list_expenses(expenses: list[dict]) -> None:
"""Display all expenses in a formatted list."""
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>10}")
print("-" * 60)
for expense in expenses:
print(
f"{expense['date']:<12} "
f"{expense['category']:<15} "
f"{expense['description']:<20} "
f"${expense['amount']:>9.2f}"
)
total: float = sum(expense["amount"] for expense in expenses)
print("-" * 60)
print(f"{'Total:':<49} ${total:>9.2f}")
def get_summary(expenses: list[dict]) -> None:
"""Display spending totals grouped by category."""
if not expenses:
print("\nNo expenses to summarize.")
return
categories: dict[str, float] = {}
for expense in expenses:
category: str = expense["category"]
if category in categories:
categories[category] += expense["amount"]
else:
categories[category] = expense["amount"]
print("\n--- Spending by Category ---")
for category, total in sorted(categories.items()):
print(f" {category:<15} ${total:.2f}")
grand_total: float = sum(categories.values())
print(f"\n {'TOTAL':<15} ${grand_total:.2f}")
Your data now survives between sessions. Next up, you'll build the interactive menu that ties all these functions together into a real command-line application.
Build the Interactive Menu
Your expense tracker can now save and load data between sessions. The core data functions are solid, but there's no way for a user to actually interact with them.
In this step, you'll build the user-facing interface that ties everything together. You'll use a while True loop to keep the program running until the user decides to quit, and if/elif/else to route their choices to the right function.
In this step, get ready to:
- Build an interactive menu loop that displays options and routes user choices.
- Handle user input with type conversion and error handling.
- Test the full program flow with data persistence.
Build the menu loop with input validation
A command-line program needs a way to keep running and accepting input until the user is done. The while True pattern creates an infinite loop that only stops when you explicitly break out of it.
- In expense_tracker.py, scroll to the bottom of the file (below the get_summary() function).
- Add the main() function by copying the following code:
def main() -> None:
"""Main program loop."""
expenses: list[dict] = load_expenses()
print("\n=== Expense Tracker ===")
print(f"Loaded {len(expenses)} saved expense(s).\n")
while True:
print("\nWhat would you like to do?")
print(" 1) Add expense")
print(" 2) List all expenses")
print(" 3) View summary by category")
print(" 4) Quit")
choice: str = input("\nEnter choice (1-4): ").strip()
if choice == "1":
print("\n--- Add New Expense ---")
result = get_add_expense_input()
if result is not None:
amount, category, description = result
add_expense(expenses, amount, category, description)
elif choice == "2":
list_expenses(expenses)
elif choice == "3":
get_summary(expenses)
elif choice == "4":
save_expenses(expenses)
print("\nGoodbye! Your expenses are saved.")
break
else:
print("\n Invalid choice. Please enter 1, 2, 3, or 4.")
What does this code do?
- load_expenses() runs once at program start, loading any previously saved data into the expenses list.
- The welcome message tells the user how many expenses were loaded from the file. This confirms persistence is working.
- while True keeps the menu running forever until the user picks option 4, which triggers break to exit the loop.
- if/elif/else checks the user's choice and calls the matching function. The else branch catches any input that isn't 1-4.
- Option 1 calls get_add_expense_input() (which you'll write next) and only adds the expense if the input was valid.
You won't be able to run this yet. The main() function calls get_add_expense_input() which doesn't exist yet. You'll write it in the next substep.
Handle user input for adding expenses
When a user types their expense amount, they might type letters instead of a number. Your program needs to handle that gracefully instead of crashing.
The get_add_expense_input() function uses try/except to catch invalid input and returns None if anything goes wrong. This lets main() skip the add if the input was bad.
- In expense_tracker.py, add this function ABOVE the main() function you just wrote (between get_summary() and main()):
def get_add_expense_input() -> tuple[float, str, str] | None:
"""Prompt user for expense details. Returns None if input is invalid."""
try:
amount_str: str = input(" Amount: $")
amount: float = float(amount_str)
if amount <= 0:
print(" Amount must be positive.")
return None
except ValueError:
print(" Invalid amount. Please enter a number.")
return None
category: str = input(" Category (e.g., food, transport, entertainment): ").strip()
if not category:
print(" Category cannot be empty.")
return None
description: str = input(" Description: ").strip()
if not description:
print(" Description cannot be empty.")
return None
return amount, category, description
What does this code do?
- The return type tuple[float, str, str] | None means the function either returns three values (amount, category, description) or None if something went wrong.
- float(amount_str) converts the user's text input to a number. If they typed letters like "abc", Python raises a ValueError which the except block catches.
- .strip() removes extra whitespace from the beginning and end of strings. This prevents the user from entering just spaces as a category or description.
- Each validation returns None early if input is bad. This pattern keeps the code flat instead of deeply nested.
Why type hints on a beginner project?
Type hints like -> tuple[float, str, str] | None don't change how your code runs. They're documentation that tells you (and your editor) what a function expects and returns.
Learning them now pays off in your next project. FastAPI uses type hints to automatically validate incoming data. The syntax will already feel familiar.
You won't see a change yet. The next substep adds the final piece that lets you run the program.
Polish the user experience and test
Your functions are all in place. The last piece is the if __name__ == "__main__" guard at the bottom of the file. This tells Python to run main() only when you execute the file directly.
- At the very bottom of expense_tracker.py (below the main() function), add the following code:
if __name__ == "__main__":
main()
What is the __name__ guard?
When you run a Python file directly, Python sets a special variable called __name__ to "__main__". This guard ensures main() only runs when you execute the file yourself, not when another file imports your functions.
- Save expense_tracker.py.
- Run the program by clicking the play button in the top-right corner of VS Code.
You should see the welcome message in the terminal below:
=== Expense Tracker ===
Loaded 0 saved expense(s).
What if it says more than 0?
If you tested persistence in the previous step, your expenses.json file already has data in it. Seeing "Loaded 2 saved expense(s)." (or any number above 0) means persistence is working perfectly.
Now test the full flow. Run through each of these actions in order:
- Enter 1 to add an expense. Enter 12.50 for the amount, food for the category, and lunch for the description.
- Add a second expense: 45.00, category transport, description gas.
- Add a third expense: 8.99, category food, description coffee.
- Enter 2 to list all expenses. You should see a formatted table with all three entries and a total.
- Enter 3 to view the summary. You should see spending grouped by category (food and transport).
- Enter 4 to quit. You should see "Goodbye! Your expenses are saved."
Now for the real test. Run the program again (click the play button) and confirm it says "Loaded 3 saved expense(s)." at the top. Your data survived between sessions.
- Test error handling: enter 1 to add an expense, then type abc for the amount. You should see "Invalid amount. Please enter a number."
- Test invalid menu choice: enter 7 at the menu. You should see "Invalid choice. Please enter 1, 2, 3, or 4."
- Enter 4 to quit when you're done testing.
Program not running or crashing?
- If you see NameError: name 'get_add_expense_input' is not defined, make sure the get_add_expense_input() function appears ABOVE main() in the file.
- If you see IndentationError, check that all code inside functions is indented with 4 spaces. Mixing tabs and spaces causes this error.
- If nothing happens when you click play, make sure if __name__ == "__main__": is at the very bottom of the file with no indentation.
Still stuck? Help me debug my expense tracker menu loop.
✔️ 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's the complete expense_tracker.py file as it should look at the end of this step:
import json
import datetime
DATA_FILE: str = "expenses.json"
def load_expenses() -> list[dict]:
"""Load expenses from the JSON file. Returns an empty list if file doesn't exist."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return data
except FileNotFoundError:
return []
except json.JSONDecodeError:
print("Warning: Data file is corrupted. Starting fresh.")
return []
def save_expenses(expenses: list[dict]) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def add_expense(expenses: list[dict], amount: float, category: str, description: str) -> None:
"""Add a new expense to the list and save to file."""
expense: dict = {
"amount": amount,
"category": category,
"description": description,
"date": datetime.date.today().isoformat()
}
expenses.append(expense)
save_expenses(expenses)
print(f"\nAdded: ${amount:.2f} for '{description}' in [{category}]")
def list_expenses(expenses: list[dict]) -> None:
"""Display all expenses in a formatted list."""
if not expenses:
print("\nNo expenses recorded yet.")
return
print(f"\n{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>10}")
print("-" * 60)
for expense in expenses:
print(
f"{expense['date']:<12} "
f"{expense['category']:<15} "
f"{expense['description']:<20} "
f"${expense['amount']:>9.2f}"
)
total: float = sum(expense["amount"] for expense in expenses)
print("-" * 60)
print(f"{'Total:':<49} ${total:>9.2f}")
def get_summary(expenses: list[dict]) -> None:
"""Display spending totals grouped by category."""
if not expenses:
print("\nNo expenses to summarize.")
return
categories: dict[str, float] = {}
for expense in expenses:
category: str = expense["category"]
if category in categories:
categories[category] += expense["amount"]
else:
categories[category] = expense["amount"]
print("\n--- Spending by Category ---")
for category, total in sorted(categories.items()):
print(f" {category:<15} ${total:.2f}")
grand_total: float = sum(categories.values())
print(f"\n {'TOTAL':<15} ${grand_total:.2f}")
def get_add_expense_input() -> tuple[float, str, str] | None:
"""Prompt user for expense details. Returns None if input is invalid."""
try:
amount_str: str = input(" Amount: $")
amount: float = float(amount_str)
if amount <= 0:
print(" Amount must be positive.")
return None
except ValueError:
print(" Invalid amount. Please enter a number.")
return None
category: str = input(" Category (e.g., food, transport, entertainment): ").strip()
if not category:
print(" Category cannot be empty.")
return None
description: str = input(" Description: ").strip()
if not description:
print(" Description cannot be empty.")
return None
return amount, category, description
def main() -> None:
"""Main program loop."""
expenses: list[dict] = load_expenses()
print("\n=== Expense Tracker ===")
print(f"Loaded {len(expenses)} saved expense(s).\n")
while True:
print("\nWhat would you like to do?")
print(" 1) Add expense")
print(" 2) List all expenses")
print(" 3) View summary by category")
print(" 4) Quit")
choice: str = input("\nEnter choice (1-4): ").strip()
if choice == "1":
print("\n--- Add New Expense ---")
result = get_add_expense_input()
if result is not None:
amount, category, description = result
add_expense(expenses, amount, category, description)
elif choice == "2":
list_expenses(expenses)
elif choice == "3":
get_summary(expenses)
elif choice == "4":
save_expenses(expenses)
print("\nGoodbye! Your expenses are saved.")
break
else:
print("\n Invalid choice. Please enter 1, 2, 3, or 4.")
if __name__ == "__main__":
main()
Your expense tracker is fully functional. You can add expenses, list them, view summaries, and your data persists between sessions. Next up, the Secret Mission challenges you to add monthly budget tracking with spending warnings.
Secret mission
Add Monthly Budget Tracking
Your expense tracker logs spending, but it never tells you when to stop. Add a budget feature that warns you when you're approaching your monthly limit and alerts you when you've gone over.
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 remove them.
Resources you used:
- Project folder at ~/expense-tracker/ containing expense_tracker.py and expenses.json.
- VS Code Python extension (ms-python.python).
Keep everything running
No action needed. Choose this if you plan to keep building on your expense tracker or want to reference the code later.
- Your expense_tracker.py file and expenses.json data file stay in ~/expense-tracker/.
- The VS Code Python extension remains installed for future Python projects.
- Python 3 remains installed as part of your system.
Pause - I'll come back to this later
There are no running processes to stop. Your project is already paused by default because it only runs when you execute the script.
- Your files stay in ~/expense-tracker/ and your expense data remains in expenses.json.
- To resume later, open the folder in VS Code and run the script again.
Delete - I don't want to use this again
Remove all project files and the VS Code extension to start completely fresh.
- Delete the project folder by running this command in your terminal:
rm -rf ~/expense-tracker
- Confirm deletion by running:
ls ~/expense-tracker
You should see a message saying the directory does not exist.
- To uninstall the VS Code Python extension, open the Extensions panel (Ctrl+Shift+X), find Python by Microsoft, and click Uninstall.
Nice Work!
Nice Work!
You just built a fully functional command-line expense tracker in Python from scratch. You can log expenses, view summaries by category, and pick up right where you left off thanks to JSON file persistence.
You've learned how to:
- Built a complete CLI application using Python's core building blocks: functions, lists, dictionaries, loops, conditionals, f-strings, and type hints.
- Implemented file persistence with JSON to save and load expense data between sessions using json.dump() and json.load().
- Applied error handling with try/except to gracefully handle missing files, corrupted data, and invalid user input.
- Secret Mission: Added monthly budget tracking with date filtering and threshold warnings, combining all core concepts into a more complex feature.
Every concept you practised here (type hints, dictionaries, file I/O, error handling) shows up directly in backend frameworks like FastAPI. You now have the foundation to build real web APIs.
Ready to quiz yourself?