Build a Personal Expense Tracker
Build a Python CLI expense tracker with Git version control and GitHub.
Introduction
30 Second Summary
Everyone tracks their spending somehow. Whether it's a notes app, a spreadsheet, or receipts stuffed in a drawer, managing money is a universal challenge. What if you could build your own tool that does exactly what you need?
In this project, you will build a command-line expense tracker using Python. You'll add expenses, view them in formatted tables, filter by category, and save everything to a file that persists between sessions.
What You'll Build
A Python script that runs in your terminal, lets you manage expenses through an interactive menu, and stores data in a CSV file.
By the end of this project, you'll have:
- A working expense tracker (expense_tracker.py) with features for adding, viewing, filtering, and summarizing expenses.
- Persistent data storage using a CSV file (expenses.csv) that keeps your data between sessions.
- A Git repository with meaningful commit history, pushed to GitHub with a professional README.
- Secret Mission: Add monthly budget tracking and build a browser-based web dashboard with spending charts.
Are there any prerequisites?
None. This guide includes full setup instructions for Python, Git, and GitHub. You'll install everything you need in Step 1.
Before We Start
Before you write any code, you need the right tools in place. Python is the language you'll write your expense tracker in. Git tracks every change you make, and GitHub lets you publish your finished project for the world to see.
In this step, you'll verify these tools are installed, configure Git with your identity, and make sure you have a GitHub account ready to go.
In this step, get ready to:
- Install and verify Python 3 and Git on your machine.
- Configure Git with your name and email.
- Create a GitHub account and verify your text editor.
Verify Python and Git are installed
Most Linux distributions come with Python and Git pre-installed. Let's confirm they're available on your system.
- Open your terminal by pressing Ctrl+Alt+T (Linux), Cmd+Space then typing Terminal (macOS), or pressing the Windows key and typing Terminal (Windows).
- Check your Python version by running this command:
python3 --version
✔️ I see a version number
You should see something like Python 3.x.x printed in your terminal. Any version of Python 3 will work for this project. You're good to move on.
ⓧ Command not found
Python isn't installed yet. Install it using your system's package manager.
- If you're on Ubuntu or Debian, install Python by running:
sudo apt install python3
- If you're on Fedora, run this instead:
sudo dnf install python3
- After installing, run python3 --version again to confirm it works.
- Now check your Git version by running:
git --version
✔️ I see a version number
You should see something like git version 2.x.x in your terminal. Any recent version of Git will work.
ⓧ Command not found
Git isn't installed yet. Install it using your system's package manager.
- If you're on Ubuntu or Debian, install Git by running:
sudo apt-get install git
- If you're on Fedora, run this instead:
sudo dnf install git
- After installing, run git --version again to confirm it works.
Configure Git identity
Every Git commit records who made the change. You need to tell Git your name and email so your commits are properly attributed.
Why does Git need my name and email?
Git bakes your name and email into every commit you create. This is how collaborators (and your future self) know who made each change.
Use the same email you'll use for your GitHub account so your commits link to your profile.
- Set your Git username by running this command:
git config --global user.name "[[YOUR_GIT_NAME="Your Name"]]"
- Set your Git email by running:
git config --global user.email "[[YOUR_GIT_EMAIL="you@example.com"]]"
What do these commands do?
The --global flag means this setting applies to all Git repositories on your machine. You only need to do this once.
- Verify your configuration by running:
git config --list
You should see your user.name and user.email listed in the output.
Don't see your name or email?
Make sure you included the --global flag in both commands. Without it, the settings only apply to a specific repository (which you haven't created yet).
Re-run the git config --global commands above, then check again with git config --list.
Still stuck? Help me fix my Git config settings.
Create a GitHub account and verify your editor
You'll push your finished project to GitHub so it's visible on your profile. You also need a text editor to write your Python code in.
- If you don't have a GitHub account yet, go to github.com and sign up for a free account.
- If you already have an account, confirm you can sign in.
What is GitHub?
GitHub is a platform that hosts Git repositories online. It's where developers store, share, and collaborate on code. Your expense tracker will live here as a public project on your profile.
- Open your text editor. Press Ctrl+Alt+T (Linux), Cmd+Space (macOS), or the Windows key (Windows) to open search, then type Cursor or VS Code and press Enter.
- Confirm you can create and save a new file (you'll use this editor throughout the project).
Which editor should I use?
Cursor and VS Code both work great for this project. Cursor is recommended because it has built-in AI features that can help you understand code as you write it. If you don't have either installed, download Cursor from cursor.com or VS Code from code.visualstudio.com.
Your tools are ready. Next up, you'll create your project folder and make your first Git commit.
Create the Project and Initialize Git
Your development tools are ready. Now it's time to create a dedicated folder for your expense tracker and start tracking every change you make with Git.
Git takes snapshots of your project at moments you choose. Each snapshot is called a commit. By the end of this step, you'll have your project folder set up, Git watching it, and your first commit recorded.
In this step, get ready to:
- Create your project folder and initialize a Git repository.
- Create a .gitignore file to protect personal data.
- Make your first commit.
Create your project folder and initialize a Git repository
Every project needs its own folder. Initializing Git inside that folder means you can track every change from the very beginning.
- 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
- Initialize a Git repository inside this folder by running:
git init
What does git init do?
This command creates a hidden .git folder inside expense-tracker. That folder is where Git stores all its tracking information.
You'll never need to open .git directly. Git manages it behind the scenes every time you make a commit.
- Verify Git is active by running:
git status
You should see a message that includes "No commits yet". This confirms Git is initialized and watching your folder.
Seeing "not a git repository"?
This means you ran git status from outside the expense-tracker folder. Run cd ~/Desktop/expense-tracker to move into the correct folder, then try git status again.
Help me figure out what went wrong with my Git setup
Create a .gitignore file
Your expense tracker will save personal spending data to a CSV file. That file should never be shared publicly on GitHub. A .gitignore file tells Git which files to completely skip.
- In your text editor, open the expense-tracker folder on your Desktop.
- Create a new file called .gitignore in the expense-tracker folder.
- Add the following content to .gitignore:
expenses.csv
budget.txt
What does this file do?
Each line in .gitignore is a filename that Git will completely ignore. It won't track changes to these files, and they won't be uploaded to GitHub.
You're ignoring expenses.csv because it will contain your personal spending data. budget.txt stores a budget amount you'll create later in the Secret Mission.
- Save .gitignore.
- Back in your terminal, confirm Git sees the new file by running:
git status
You should see .gitignore listed under "Untracked files". This means Git sees the file but hasn't saved a snapshot of it yet.
Don't see .gitignore in the output?
Make sure you saved the file inside the expense-tracker folder, not somewhere else. Files starting with a dot are hidden by default in most file browsers, but Git still sees them.
Also check that the filename is exactly .gitignore with no extra extension like .txt.
Help me troubleshoot where my .gitignore file might have been saved
✔️ Awesome, I've got everything!
Great. Make sure you've saved .gitignore before moving on.
ⓧ I'd like to double check the full code
Here's what your .gitignore file should contain:
expenses.csv
budget.txt
Make your first commit
A commit is a snapshot of your project at a specific moment. Each commit has a message describing what changed. This gives you a history you can look back on.
- Stage your .gitignore file for commit by running:
git add .gitignore
- Create your first commit with a descriptive message by running:
git commit -m "Initial commit: add .gitignore"
What's the difference between add and commit?
Think of it like packing a box before shipping it. git add puts files into the box (called the "staging area"). git commit seals the box and labels it with your message.
This two-step process lets you choose exactly which changes go into each snapshot. You don't have to commit everything at once.
- Verify your commit was recorded by running:
git log
You should see your commit with the message "Initial commit: add .gitignore", along with your name, email, and a timestamp.
Seeing "please tell me who you are"?
This means Git doesn't have your identity configured. Run the config commands from Step 1:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
Replace the values with your actual name and the email tied to your GitHub account. Then run the git commit command again.
Help me fix my git commit error
Your project has a home and its first commit is recorded. Next up, you'll create the Python script and build the interactive menu that powers the expense tracker.
Build the Menu Loop and Core Structure
Your project folder is ready and Git is tracking changes. Now it's time to write actual Python code.
Every interactive program needs a way to talk to the user. In this step, you'll build the menu loop that drives your expense tracker and write the function that collects expense data from the user.
In this step, get ready to:
- Create the menu display and main program loop.
- Add the expense input function with amount validation.
- Commit your progress with Git.
Create the menu and main loop
The menu is the first thing users see when they run your program. It displays the available options and waits for input.
- Create a new file called expense_tracker.py in your expense-tracker folder.
- Add the show_menu() function by pasting this code:
def show_menu():
# Display the main menu options
print("\n===== Expense Tracker =====")
print("1. Add expense")
print("2. View all expenses")
print("3. Filter by category")
print("4. Spending summary")
print("5. Exit")
print("===========================")
What does this code do?
show_menu() is a function that prints the menu to the terminal every time it's called. Each option is numbered so the user can type a number to make a choice.
You won't see a change yet. The next chunk adds the main loop that calls this function.
- Below show_menu(), add the main() function and entry point by pasting this code:
def main():
# Start with an empty list of expenses
expenses = []
print("Welcome to Expense Tracker!")
# Keep showing the menu until the user chooses to exit
while True:
show_menu()
choice = input("Choose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
pass
elif choice == "3":
pass
elif choice == "4":
pass
elif choice == "5":
print("Goodbye! Your expenses have been saved.")
break
else:
print("Invalid option. Please enter 1-5.")
if __name__ == "__main__":
main()
What does this code do?
main() creates an empty list called expenses to hold all the expense data in memory. The while True loop keeps the program running until the user picks option 5.
The if __name__ == "__main__" line at the bottom is a standard Python pattern. It means "only run main() when this file is executed directly." Options 2-4 use pass as a placeholder. You'll fill them in later steps.
- Save expense_tracker.py.
- Test your menu by running this command:
python3 expense_tracker.py
- You should see the "Welcome to Expense Tracker!" message followed by the menu with 5 options.
- Type 5 and press Enter to test that Exit works. You should see "Goodbye! Your expenses have been saved."
Don't see the menu?
- Make sure you saved the file before running the command.
- Check that you're running the command from inside the expense-tracker folder. Run ls to confirm you can see expense_tracker.py listed.
- Look for any "SyntaxError" messages. These usually mean a missing colon or mismatched quotes.
Ask for help debugging your menu loop.
💡 Don't try option 1 yet
Option 1 calls add_expense() which doesn't exist yet. Selecting it will show a NameError. You'll write that function next.
Add the expense input function
The add_expense() function handles the interaction when a user wants to record a new expense. It needs to collect a date, category, description, and amount.
- At the very top of expense_tracker.py (above def show_menu()), add these imports and the data file constant:
import csv
import os
from datetime import datetime
DATA_FILE = "expenses.csv"
What do these imports do?
csv lets you read and write spreadsheet-style files. os provides file system utilities like checking if a file exists. datetime gives you today's date for automatic date-stamping.
DATA_FILE is a constant that stores the filename where expenses will be saved. Defining it once at the top means you only need to change it in one place if you ever rename the file.
You won't see a change yet. The next chunk uses these imports.
- Between DATA_FILE and show_menu(), add the add_expense() function:
def add_expense(expenses):
print("\n--- Add New Expense ---")
# Prompt for date, defaulting to today if left blank
date = input("Date (YYYY-MM-DD, or press Enter for today): ").strip()
if not date:
date = datetime.today().strftime("%Y-%m-%d")
category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
description = input("Description: ").strip()
# Keep asking for amount until the user enters a valid number
while True:
amount_input = input("Amount: $")
try:
amount = float(amount_input)
break
except ValueError:
print("Please enter a valid number.")
# Store the expense as a dictionary
expense = {
"date": date,
"category": category,
"description": description,
"amount": amount,
}
expenses.append(expense)
print(f"Added: {description} - ${amount:.2f} [{category}]")
What does this code do?
The function prompts the user for each piece of expense data. If the user presses Enter without typing a date, it uses today's date automatically via datetime.today().strftime("%Y-%m-%d").
The while True loop around the amount input uses try/except to catch invalid numbers. If the user types "abc" instead of a number, float() raises a ValueError and the loop asks again. Once a valid number is entered, break exits the loop.
Finally, the expense is stored as a dictionary and appended to the expenses list.
- Save expense_tracker.py.
- Run your program again:
python3 expense_tracker.py
- Choose option 1 to add an expense.
- Press Enter for today's date, type Food for the category, Coffee for description, and 4.50 for amount.
- You should see: Added: Coffee - $4.50 [Food].
- Try typing abc for the amount to confirm the validation catches it and asks again.
- Choose option 5 to exit.
Seeing a NameError or TypeError?
- If you see NameError: name 'add_expense' is not defined, make sure add_expense() is defined above main() in the file. Python reads top to bottom.
- If you see a TypeError, check that your function signature is def add_expense(expenses): with the expenses parameter.
- Make sure the imports are at the very top of the file, before any function definitions.
Help me fix an error in my expense tracker.
✔️ Awesome, I've got everything!
Great. Double check you've saved expense_tracker.py and that adding an expense works end to end.
ⓧ I'd like to double check the full code
Here's the complete expense_tracker.py at this point:
import csv
import os
from datetime import datetime
DATA_FILE = "expenses.csv"
def add_expense(expenses):
print("\n--- Add New Expense ---")
date = input("Date (YYYY-MM-DD, or press Enter for today): ").strip()
if not date:
date = datetime.today().strftime("%Y-%m-%d")
category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
description = input("Description: ").strip()
while True:
amount_input = input("Amount: $")
try:
amount = float(amount_input)
break
except ValueError:
print("Please enter a valid number.")
expense = {
"date": date,
"category": category,
"description": description,
"amount": amount,
}
expenses.append(expense)
print(f"Added: {description} - ${amount:.2f} [{category}]")
def show_menu():
print("\n===== Expense Tracker =====")
print("1. Add expense")
print("2. View all expenses")
print("3. Filter by category")
print("4. Spending summary")
print("5. Exit")
print("===========================")
def main():
expenses = []
print("Welcome to Expense Tracker!")
while True:
show_menu()
choice = input("Choose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
pass
elif choice == "3":
pass
elif choice == "4":
pass
elif choice == "5":
print("Goodbye! Your expenses have been saved.")
break
else:
print("Invalid option. Please enter 1-5.")
if __name__ == "__main__":
main()
Commit your progress
You have a working menu and a function that collects expense data. This is a good milestone to save with Git.
- Stage and commit your new file by running these commands:
git add expense_tracker.py
git commit -m "Add menu loop and expense input function"
Why commit now?
Each commit captures a working snapshot of your project. If you break something in a later step, you can always roll back to this point. Committing after each milestone is a professional habit.
- Verify your commit history by running:
git log --oneline
- You should see two commits listed: your initial commit and the new one with the message "Add menu loop and expense input function".
Commit failed?
- If Git says "nothing to commit", make sure you ran git add expense_tracker.py first.
- If you see an "Author identity unknown" message, run git config --global user.name "Your Name" and git config --global user.email "you@example.com" again.
Help me fix a Git commit error.
Your program can now collect expense data from the user. Next up, you'll display those expenses in a clean formatted table and add filtering by category.
Display and Filter Expenses
Your expense tracker can now accept expenses through the menu. The next step is giving users a way to actually see what they've entered.
In this step, you'll build two display functions. One shows all expenses in a neatly formatted table. The other filters expenses down to a single category so users can focus on where their money is going.
In this step, get ready to:
- Create a formatted table view of all expenses.
- Build a category filter using list comprehensions.
- Commit your progress with Git.
Create the view function
Right now, choosing option 2 in the menu does nothing. You need a function that prints all expenses in a readable table with aligned columns.
Python's f-strings support alignment syntax. {value:<12} left-aligns text in a 12-character-wide column, and {value:>8} right-aligns it in 8 characters.
- In expense_tracker.py, find the add_expense function. Add the following new function directly below it (after the last line of add_expense, before show_menu):
def view_expenses(expenses):
print("\n--- All Expenses ---")
# Handle empty list case
if not expenses:
print("No expenses recorded yet.")
return
# Print column headers with f-string alignment
print(f"{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>8}")
print("-" * 58)
# Print each expense row
for expense in expenses:
print(
f"{expense['date']:<12} {expense['category']:<15} "
f"{expense['description']:<20} ${expense['amount']:>7.2f}"
)
# Print total at the bottom
print("-" * 58)
total = sum(expense["amount"] for expense in expenses)
print(f"{'Total:':<49} ${total:>7.2f}")
What does this code do?
This function handles two scenarios. If the expenses list is empty, it prints a friendly message and returns early.
When expenses exist, it prints column headers using f-string alignment (left-align for text, right-align for amounts). Then it loops through each expense, printing it as a formatted row. A generator expression inside sum() calculates the total of all amounts at the bottom.
Now connect this function to the menu. In main(), find the line that handles option 2:
- Scroll down to the main() function and find this line:
elif choice == "2":
pass
- Replace pass with view_expenses(expenses), so the line now looks like this:
elif choice == "2":
view_expenses(expenses)
- Save expense_tracker.py.
- Run the program to test by running:
python3 expense_tracker.py
- Choose option 1 to add a test expense (any values work). Then choose option 2.
You should see a formatted table with column headers, your expense in a row, a line of dashes, and a total at the bottom.
Seeing a formatting error or misaligned columns?
- Make sure you copied the f-string alignment syntax exactly. The angle brackets (< and >) control alignment direction.
- Check that your dictionary key names match exactly: expense['date'], expense['category'], expense['description'], expense['amount'].
- If you see "No expenses recorded yet" even after adding one, make sure you added the expense in the same session (data doesn't persist yet).
Why isn't my expense table displaying correctly?
Create the filter function
Viewing all expenses is helpful, but users need a way to zoom in on a specific category. The filter function shows what categories exist, lets the user pick one, and displays only matching expenses.
- In expense_tracker.py, add the following function directly below view_expenses (before show_menu):
def filter_by_category(expenses):
print("\n--- Filter by Category ---")
if not expenses:
print("No expenses recorded yet.")
return
# Show available categories using a set comprehension
categories = sorted(set(expense["category"] for expense in expenses))
print("Available categories:", ", ".join(categories))
choice = input("Enter category to filter: ").strip()
# Filter with list comprehension and case-insensitive comparison
filtered = [e for e in expenses if e["category"].lower() == choice.lower()]
if not filtered:
print(f"No expenses found in category '{choice}'.")
return
# Display filtered results
print(f"\n{'Date':<12} {'Description':<20} {'Amount':>8}")
print("-" * 43)
for expense in filtered:
print(
f"{expense['date']:<12} {expense['description']:<20} "
f"${expense['amount']:>7.2f}"
)
print("-" * 43)
total = sum(expense["amount"] for expense in filtered)
print(f"{'Total:':<34} ${total:>7.2f}")
What does this code do?
- The set() comprehension extracts unique category names from all expenses. Wrapping it in sorted() puts them in alphabetical order.
- The list comprehension [e for e in expenses if ...] builds a new list containing only expenses whose category matches the user's input.
- Using .lower() on both sides means "Food" matches whether the user types "food", "Food", or "FOOD".
Now connect this function to the menu. In main(), find the handler for option 3:
- Find this line in main():
elif choice == "3":
pass
- Replace pass with filter_by_category(expenses), so it looks like this:
elif choice == "3":
filter_by_category(expenses)
- Save expense_tracker.py.
- Run the program again with python3 expense_tracker.py.
- Add two expenses with different categories (e.g., "Food" and "Transport"). Then choose option 3 and type one of the categories.
You should see only the expenses matching your chosen category, with a filtered total at the bottom.
Seeing "No expenses found" even though you added some?
- Make sure you typed the category name exactly as it appears in the "Available categories" list (capitalization doesn't matter, but spelling does).
- If you restarted the program between adding and filtering, the expenses are gone. Data only lives in memory for now. Add expenses and filter in the same session.
Help me debug my filter_by_category function.
Commit your progress
Both display functions work. Time to record this milestone in Git.
- Stage and commit your changes by running these commands:
git add expense_tracker.py
git commit -m "Add view and filter functions"
- Check your growing commit history by running:
git log --oneline
You should see three commits listed, with your most recent "Add view and filter functions" at the top.
Commit failed or nothing to commit?
- If you see "nothing to commit", make sure you saved expense_tracker.py before running git add.
- Run git status to see whether the file is tracked as modified.
Help me fix my git commit.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and your commit went through.
ⓧ I'd like to double check the full code
Here is the complete expense_tracker.py at this point:
import csv
import os
from datetime import datetime
DATA_FILE = "expenses.csv"
def add_expense(expenses):
print("\n--- Add New Expense ---")
date = input("Date (YYYY-MM-DD, or press Enter for today): ").strip()
if not date:
date = datetime.today().strftime("%Y-%m-%d")
category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
description = input("Description: ").strip()
while True:
amount_input = input("Amount: $")
try:
amount = float(amount_input)
break
except ValueError:
print("Please enter a valid number.")
expense = {
"date": date,
"category": category,
"description": description,
"amount": amount,
}
expenses.append(expense)
print(f"Added: {description} - ${amount:.2f} [{category}]")
def view_expenses(expenses):
print("\n--- All Expenses ---")
if not expenses:
print("No expenses recorded yet.")
return
print(f"{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>8}")
print("-" * 58)
for expense in expenses:
print(
f"{expense['date']:<12} {expense['category']:<15} "
f"{expense['description']:<20} ${expense['amount']:>7.2f}"
)
print("-" * 58)
total = sum(expense["amount"] for expense in expenses)
print(f"{'Total:':<49} ${total:>7.2f}")
def filter_by_category(expenses):
print("\n--- Filter by Category ---")
if not expenses:
print("No expenses recorded yet.")
return
categories = sorted(set(expense["category"] for expense in expenses))
print("Available categories:", ", ".join(categories))
choice = input("Enter category to filter: ").strip()
filtered = [e for e in expenses if e["category"].lower() == choice.lower()]
if not filtered:
print(f"No expenses found in category '{choice}'.")
return
print(f"\n{'Date':<12} {'Description':<20} {'Amount':>8}")
print("-" * 43)
for expense in filtered:
print(
f"{expense['date']:<12} {expense['description']:<20} "
f"${expense['amount']:>7.2f}"
)
print("-" * 43)
total = sum(expense["amount"] for expense in filtered)
print(f"{'Total:':<34} ${total:>7.2f}")
def show_menu():
print("\n===== Expense Tracker =====")
print("1. Add expense")
print("2. View all expenses")
print("3. Filter by category")
print("4. Spending summary")
print("5. Exit")
print("===========================")
def main():
expenses = []
print("Welcome to Expense Tracker!")
while True:
show_menu()
choice = input("Choose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
filter_by_category(expenses)
elif choice == "4":
pass
elif choice == "5":
print("Goodbye! Your expenses have been saved.")
break
else:
print("Invalid option. Please enter 1-5.")
if __name__ == "__main__":
main()
Your expense tracker now displays data beautifully and lets users drill into specific categories. Next up, you'll add CSV persistence so expenses survive between sessions.
Save and Load Data from CSV
Your expense tracker can display and filter expenses, but everything disappears the moment you close the program. That's because the data only lives in memory.
In this step, you'll write your expenses to a CSV file so they persist between sessions. You'll also write a function that loads them back in when the program starts.
In this step, get ready to:
- Write a save function that writes expenses to a CSV file.
- Write a load function that reads expenses back from CSV.
- Connect both functions to the main program and commit your progress.
Write the save function
Python's csv module has a DictWriter class that can write a list of dictionaries straight to a file as rows. Each dictionary key becomes a column.
- In expense_tracker.py, find the line that says DATA_FILE = "expenses.csv".
- Below that line (and above def add_expense(expenses):), add the following function:
def save_expenses(expenses):
with open(DATA_FILE, "w", newline="") as file:
fieldnames = ["date", "category", "description", "amount"]
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for expense in expenses:
writer.writerow(expense)
What does this code do?
- open(DATA_FILE, "w", newline="") opens the CSV file for writing. The newline="" argument prevents extra blank lines between rows on some systems.
- csv.DictWriter takes your list of dictionaries and writes them as CSV rows, using fieldnames to control which keys become columns.
- writeheader() writes the column names as the first row, so the file is self-describing.
- Save the file. You won't see a change yet. The next chunk will add a load function, and then you'll connect both to the main program.
Write the load function
The load function needs to do the reverse: read rows from the CSV and convert them back into dictionaries. It also needs to handle the case where the file doesn't exist yet (first run).
- In expense_tracker.py, add the following function above your save_expenses function (between DATA_FILE and save_expenses):
def load_expenses():
expenses = []
if not os.path.exists(DATA_FILE):
return expenses
with open(DATA_FILE, "r", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
row["amount"] = float(row["amount"])
expenses.append(row)
return expenses
What does this code do?
- os.path.exists(DATA_FILE) checks if the CSV file exists before trying to read it. On the first run, there's no file yet, so the function returns an empty list.
- csv.DictReader reads each row as a dictionary, automatically using the first row (the header) as keys.
- row["amount"] = float(row["amount"]) converts the amount back to a number. CSV files store everything as strings, so you need to cast numeric values explicitly.
- Save the file. You won't see a change yet. The next chunk connects both functions to the main program.
Connect persistence and commit
The save and load functions exist, but nothing calls them yet. You need to load expenses when the program starts and save after every new expense is added.
- In expense_tracker.py, scroll down to the main() function.
- Find this line:
expenses = []
- Replace [] with load_expenses(), so the line now looks like this:
expenses = load_expenses()
- Now scroll up to the add_expense() function.
- Find this line:
expenses.append(expense)
print(f"Added: {description} - ${amount:.2f} [{category}]")
- Add save_expenses(expenses) on a new line between expenses.append(expense) and the print line, so it looks like this:
expenses.append(expense)
save_expenses(expenses)
print(f"Added: {description} - ${amount:.2f} [{category}]")
Why save after every expense?
By calling save_expenses() immediately after appending, you guarantee that no data is lost even if the program crashes later. The tradeoff is a tiny disk write on every add, which is fine for a personal tool.
- Save expense_tracker.py.
- Test persistence by running the program:
python3 expense_tracker.py
- Add an expense (choose option 1 and fill in the prompts).
- Exit the program (choose option 5).
- Run the program again with the same command.
- Choose option 2 to view all expenses. You should see the expense you added in the previous session.
Expenses not showing after restart?
- Make sure save_expenses(expenses) is inside add_expense() and is called after expenses.append(expense).
- Check that main() uses expenses = load_expenses() instead of expenses = [].
- Verify that expenses.csv was created in your project folder after adding an expense. If it's missing, the save function isn't being called.
Why aren't my expenses persisting between sessions?
- Commit your progress by running these commands:
git add expense_tracker.py
git commit -m "Add CSV persistence (save and load)"
What should I see?
Git confirms the commit with a message showing your commit hash and the message "Add CSV persistence (save and load)". Your project now has four commits in its history.
✔️ 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 full expense_tracker.py file at this point:
import csv
import os
from datetime import datetime
DATA_FILE = "expenses.csv"
def load_expenses():
expenses = []
if not os.path.exists(DATA_FILE):
return expenses
with open(DATA_FILE, "r", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
row["amount"] = float(row["amount"])
expenses.append(row)
return expenses
def save_expenses(expenses):
with open(DATA_FILE, "w", newline="") as file:
fieldnames = ["date", "category", "description", "amount"]
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for expense in expenses:
writer.writerow(expense)
def add_expense(expenses):
print("\n--- Add New Expense ---")
date = input("Date (YYYY-MM-DD, or press Enter for today): ").strip()
if not date:
date = datetime.today().strftime("%Y-%m-%d")
category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
description = input("Description: ").strip()
while True:
amount_input = input("Amount: $")
try:
amount = float(amount_input)
break
except ValueError:
print("Please enter a valid number.")
expense = {
"date": date,
"category": category,
"description": description,
"amount": amount,
}
expenses.append(expense)
save_expenses(expenses)
print(f"Added: {description} - ${amount:.2f} [{category}]")
def view_expenses(expenses):
print("\n--- All Expenses ---")
if not expenses:
print("No expenses recorded yet.")
return
print(f"{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>8}")
print("-" * 58)
for expense in expenses:
print(
f"{expense['date']:<12} {expense['category']:<15} "
f"{expense['description']:<20} ${expense['amount']:>7.2f}"
)
print("-" * 58)
total = sum(expense["amount"] for expense in expenses)
print(f"{'Total:':<49} ${total:>7.2f}")
def filter_by_category(expenses):
print("\n--- Filter by Category ---")
if not expenses:
print("No expenses recorded yet.")
return
categories = sorted(set(expense["category"] for expense in expenses))
print("Available categories:", ", ".join(categories))
choice = input("Enter category to filter: ").strip()
filtered = [e for e in expenses if e["category"].lower() == choice.lower()]
if not filtered:
print(f"No expenses found in category '{choice}'.")
return
print(f"\n{'Date':<12} {'Description':<20} {'Amount':>8}")
print("-" * 43)
for expense in filtered:
print(
f"{expense['date']:<12} {expense['description']:<20} "
f"${expense['amount']:>7.2f}"
)
print("-" * 43)
total = sum(expense["amount"] for expense in filtered)
print(f"{'Total:':<34} ${total:>7.2f}")
def show_menu():
print("\n===== Expense Tracker =====")
print("1. Add expense")
print("2. View all expenses")
print("3. Filter by category")
print("4. Spending summary")
print("5. Exit")
print("===========================")
def main():
expenses = load_expenses()
print("Welcome to Expense Tracker!")
while True:
show_menu()
choice = input("Choose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
filter_by_category(expenses)
elif choice == "4":
pass
elif choice == "5":
print("Goodbye! Your expenses have been saved.")
break
else:
print("Invalid option. Please enter 1-5.")
if __name__ == "__main__":
main()
Your expense tracker now remembers everything between sessions. Next up, you'll build a spending summary that calculates totals and breaks down spending by category.
Calculate Spending Summaries
Your expense tracker can now save and load data between sessions. The core recording workflow is solid.
Tracking expenses is only useful if you can learn from them. In this step, you'll build a summary feature that answers the big questions: how much have I spent total, what's my average expense, and where is most of my money going?
In this step, get ready to:
- Write a summary function that calculates total, count, and average.
- Add a category breakdown sorted by spending.
- Commit your progress to Git.
Create the summary function
Python's built-in sum() function can work with a generator expression to calculate totals from a list of dictionaries in a single line.
- In expense_tracker.py, find the filter_by_category() function. Copy the following code below it:
def show_summary(expenses):
print("\n--- Spending Summary ---")
if not expenses:
print("No expenses recorded yet.")
return
# Calculate overall statistics
total = sum(expense["amount"] for expense in expenses)
print(f"Total expenses: ${total:.2f}")
print(f"Number of transactions: {len(expenses)}")
print(f"Average expense: ${total / len(expenses):.2f}")
What does this code do?
This function uses a generator expression inside sum() to add up every expense amount without creating a separate list. It pulls each amount value from the list of dictionaries in one pass.
The len() function counts how many items are in the list. Dividing the total by the count gives the average.
Now you need to connect this function to your menu so option 4 actually calls it.
- In expense_tracker.py, scroll down to the main() function.
- Find this line:
elif choice == "4":
pass
- Replace pass with show_summary(expenses), so the line now looks like this:
elif choice == "4":
show_summary(expenses)
- Save expense_tracker.py.
- Run your program to test it by running:
python3 expense_tracker.py
- Add a couple of test expenses using option 1, then choose option 4. You should see the total, transaction count, and average displayed with dollar formatting.
Seeing a NameError?
Make sure your show_summary function is defined above the main() function. Python reads files top-to-bottom, so functions must be defined before they are called.
Also check that the function name in your elif block matches exactly: show_summary(expenses).
Help me debug this NameError in my expense tracker.
Add category breakdown
Knowing your total is helpful, but knowing where the money goes is more actionable. A category breakdown shows you which spending areas dominate your budget.
- In expense_tracker.py, find the last line of your show_summary() function (the print(f"Average expense: ...") line). Add the following code below it, inside the same function:
# Break down spending by category
print("\nSpending by category:")
categories = {}
for expense in expenses:
cat = expense["category"]
categories[cat] = categories.get(cat, 0) + expense["amount"]
# Sort by amount spent, highest first
for cat, amount in sorted(categories.items(), key=lambda x: x[1], reverse=True):
print(f" {cat:<15} ${amount:>7.2f}")
What does this code do?
The categories dictionary accumulates totals per category. The .get(cat, 0) method returns the current total for that category (or 0 if it hasn't been seen yet), then adds the new amount on top.
The sorted() function with key=lambda x: x[1] sorts the dictionary items by value (the spending amount) instead of by key (the category name). The reverse=True puts the highest spending category first.
- Save expense_tracker.py.
- Run the program end-to-end by running:
python3 expense_tracker.py
- Test all five menu options: add a few expenses across different categories, view them all, filter by one category, check the spending summary, and exit. When you choose option 4, you should see the total, transaction count, average, and a sorted category breakdown.
Categories not showing up in the breakdown?
Make sure the category breakdown code is indented inside the show_summary() function (one level of indentation, four spaces). If it's at the top level of the file, Python treats it as separate code that runs on import.
Also check that you placed it after the return guard. The if not expenses: return block should remain near the top of the function.
Help me fix my show_summary category breakdown.
- Commit your progress by running:
git add expense_tracker.py
git commit -m "Add spending summary with category breakdown"
- Verify your commit history by running:
git log --oneline
- You should see five commits listed, with the most recent one showing your new summary message.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the full expense_tracker.py at this point:
import csv
import os
from datetime import datetime
DATA_FILE = "expenses.csv"
def load_expenses():
expenses = []
if not os.path.exists(DATA_FILE):
return expenses
with open(DATA_FILE, "r", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
row["amount"] = float(row["amount"])
expenses.append(row)
return expenses
def save_expenses(expenses):
with open(DATA_FILE, "w", newline="") as file:
fieldnames = ["date", "category", "description", "amount"]
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for expense in expenses:
writer.writerow(expense)
def add_expense(expenses):
print("\n--- Add New Expense ---")
date = input("Date (YYYY-MM-DD, or press Enter for today): ").strip()
if not date:
date = datetime.today().strftime("%Y-%m-%d")
category = input("Category (e.g., Food, Transport, Entertainment): ").strip()
description = input("Description: ").strip()
while True:
amount_input = input("Amount: $")
try:
amount = float(amount_input)
break
except ValueError:
print("Please enter a valid number.")
expense = {
"date": date,
"category": category,
"description": description,
"amount": amount,
}
expenses.append(expense)
save_expenses(expenses)
print(f"Added: {description} - ${amount:.2f} [{category}]")
def view_expenses(expenses):
print("\n--- All Expenses ---")
if not expenses:
print("No expenses recorded yet.")
return
print(f"{'Date':<12} {'Category':<15} {'Description':<20} {'Amount':>8}")
print("-" * 58)
for expense in expenses:
print(
f"{expense['date']:<12} {expense['category']:<15} "
f"{expense['description']:<20} ${expense['amount']:>7.2f}"
)
print("-" * 58)
total = sum(expense["amount"] for expense in expenses)
print(f"{'Total:':<49} ${total:>7.2f}")
def filter_by_category(expenses):
print("\n--- Filter by Category ---")
if not expenses:
print("No expenses recorded yet.")
return
categories = sorted(set(expense["category"] for expense in expenses))
print("Available categories:", ", ".join(categories))
choice = input("Enter category to filter: ").strip()
filtered = [e for e in expenses if e["category"].lower() == choice.lower()]
if not filtered:
print(f"No expenses found in category '{choice}'.")
return
print(f"\n{'Date':<12} {'Description':<20} {'Amount':>8}")
print("-" * 43)
for expense in filtered:
print(
f"{expense['date']:<12} {expense['description']:<20} "
f"${expense['amount']:>7.2f}"
)
print("-" * 43)
total = sum(expense["amount"] for expense in filtered)
print(f"{'Total:':<34} ${total:>7.2f}")
def show_summary(expenses):
print("\n--- Spending Summary ---")
if not expenses:
print("No expenses recorded yet.")
return
total = sum(expense["amount"] for expense in expenses)
print(f"Total expenses: ${total:.2f}")
print(f"Number of transactions: {len(expenses)}")
print(f"Average expense: ${total / len(expenses):.2f}")
print("\nSpending by category:")
categories = {}
for expense in expenses:
cat = expense["category"]
categories[cat] = categories.get(cat, 0) + expense["amount"]
for cat, amount in sorted(categories.items(), key=lambda x: x[1], reverse=True):
print(f" {cat:<15} ${amount:>7.2f}")
def show_menu():
print("\n===== Expense Tracker =====")
print("1. Add expense")
print("2. View all expenses")
print("3. Filter by category")
print("4. Spending summary")
print("5. Exit")
print("===========================")
def main():
expenses = load_expenses()
print("Welcome to Expense Tracker!")
while True:
show_menu()
choice = input("Choose an option (1-5): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
filter_by_category(expenses)
elif choice == "4":
show_summary(expenses)
elif choice == "5":
print("Goodbye! Your expenses have been saved.")
break
else:
print("Invalid option. Please enter 1-5.")
if __name__ == "__main__":
main()
All five features of your expense tracker are working. Next up, you'll publish your project to GitHub so it lives on your profile as a portfolio piece.
Push to GitHub
Your expense tracker is feature-complete and tracked with five meaningful commits. The final step is publishing your work to GitHub so it lives online and anyone can see what you built.
GitHub is where developers share and collaborate on code. Pushing your project there gives you a public portfolio piece and proves you can use a professional workflow.
In this step, get ready to:
- Create a public repository on GitHub.
- Write a README file that describes your project.
- Push all your commits to GitHub.
Create a GitHub repository
You need a remote repository on GitHub to receive your local commits. You'll create an empty one so there are no conflicts with your existing Git history.
- Open your browser and go to github.com.
- Click the + button in the top-right corner of the page.
- Click New repository.
- In the Repository name field, enter expense-tracker.
- In the Description field, enter CLI expense tracker built with Python.
- Select Public for the repository visibility.
- Leave the Add a README file checkbox unchecked.
- Click Create repository.
Why no auto-generated README?
If GitHub generates a README for you, the remote repository will have a commit your local repository does not. This causes a conflict when you try to push. Creating an empty repository avoids that problem entirely.
After clicking Create repository, GitHub shows you a page with setup commands. Keep this page open because you will use the commands shortly.
Write a README
A README is the front page of your repository. It tells visitors what your project does and how to use it.
- Create a file called README.md inside your expense-tracker folder by pasting the following content:
# Expense Tracker
A command-line expense tracker built with Python. Track your spending, filter by category, and view summaries.
## How to Run
```bash
python3 expense_tracker.py
```
## Features
- Add expenses with date, category, description, and amount
- View all expenses in a formatted table
- Filter expenses by category
- View spending summaries with category breakdown
- Data persists between sessions (saved to CSV)
What does this README include?
The # and ## lines are Markdown headings. GitHub renders them as large, styled text.
The fenced code block (between the triple backticks) tells readers the exact command to run your script. The bullet list summarizes your tracker's features at a glance.
- Save README.md.
- Stage and commit the README by running these commands:
git add README.md
git commit -m "Add README"
- Verify the commit by running:
git log --oneline
You should see six commits listed, with the most recent one saying Add README at the top.
Not seeing six commits?
Make sure you saved README.md before running git add. If you see "nothing to commit", the file was not saved or was not in the expense-tracker folder.
help me figure out why my git commit didn't work
✔️ Awesome, I've got everything!
Great. Make sure README.md is saved and your commit log shows six entries.
ⓧ I'd like to double check the full code
Here is the complete README.md file:
# Expense Tracker
A command-line expense tracker built with Python. Track your spending, filter by category, and view summaries.
## How to Run
```bash
python3 expense_tracker.py
```
## Features
- Add expenses with date, category, description, and amount
- View all expenses in a formatted table
- Filter expenses by category
- View spending summaries with category breakdown
- Data persists between sessions (saved to CSV)
Connect and push to GitHub
Now you will link your local repository to the remote one on GitHub and upload all your commits.
- Connect your local repository to GitHub by running these commands:
git remote add origin https://github.com/[[GH_USERNAME="YOUR-USERNAME"]]/expense-tracker.git
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin tells Git where your GitHub repository lives. Replace the username with your actual GitHub username.
- git branch -M main renames your current branch to main, which is the default branch name GitHub expects.
- git push -u origin main uploads all your local commits to GitHub. The -u flag sets origin main as the default remote, so future pushes only need git push.
💡 Git will ask you to authenticate
When you run git push over HTTPS for the first time, Git will prompt you for your GitHub credentials. GitHub no longer accepts passwords for Git operations.
When prompted for your password, enter a personal access token instead. You can generate one at github.com/settings/tokens. Select the repo scope when creating it.
- After the push completes, refresh your GitHub repository page in the browser.
You should see expense_tracker.py, .gitignore, and README.md listed as files. Below the file list, GitHub renders your README with the project title, run command, and features list.
Push rejected or authentication failed?
- If you see a password authentication error, make sure you are using a personal access token (not your GitHub password) when prompted.
- If the push is rejected because the remote contains work you do not have locally, you may have checked the Add a README file box on GitHub. Delete the repository on GitHub, recreate it without a README, and try again.
- Double check that the URL in your git remote add origin command matches your actual GitHub username.
help me push my code to GitHub
Secret mission
Monthly Budget Tracking and Web Dashboard
Your expense tracker records every purchase, but it never tells you when you're spending too much. What if it could warn you before you blow through your monthly budget? And what if you could see all your spending in a visual web dashboard with charts? In this mission, you'll add budget tracking with a progress bar, then build a browser-based dashboard using HTML, CSS, and JavaScript.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses a free GitHub account, so there are no ongoing costs. Decide whether to keep your resources as they are, or delete them entirely.
Resources you used:
- Local project folder (expense-tracker on your Desktop).
- GitHub repository (expense-tracker).
- Local data files (expenses.csv and budget.txt inside your project folder).
Keep everything running
No action needed. Choose this if you want to keep building on this project or reference the code later.
- Your expense-tracker folder stays on your Desktop.
- Your GitHub repository remains public at your profile.
- Python and Git remain installed on your system (these are standard development tools you will use again).
Pause - I'll come back to this later
Since this project has no running processes or cloud services, pausing is the same as keeping. Your files sit on disk and cost nothing.
- No action needed.
- When you come back, open your terminal, navigate to ~/Desktop/expense-tracker, and run python3 expense_tracker.py to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete the local project folder by running:
rm -rf ~/Desktop/expense-tracker
- Confirm the folder is gone by running:
ls ~/Desktop | grep expense-tracker
You should see no output, confirming the folder has been removed.
- Delete the GitHub repository by navigating to your expense-tracker repository on github.com.
- Click Settings in the repository navigation bar.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
You built a real Python tool from scratch and shipped it to GitHub. Your expense tracker handles user input, persists data between sessions, and lives on your public profile for anyone to see.
You've learned how to:
- Build an interactive CLI application with a menu-driven interface, functions that validate user input, and formatted terminal output using f-strings.
- Use Python's csv module to persist data between sessions, applying core concepts like loops, conditionals, lists, dictionaries, and list comprehensions.
- Track your work with Git commits at each milestone and publish your project to GitHub with a professional README.
- Added monthly budget tracking with a visual progress bar, and built a web dashboard using HTML, CSS, JavaScript, and Chart.js.
Ready to quiz yourself?