Overview
An expense tracker is where Python's dictionaries really start to earn their keep. A single expense is naturally a small record — a date, a category, an amount, a note — and a whole month of spending is naturally a collection of those records grouped by category, which is exactly the shape a `dict` mapping category names to running totals is built for. This project also introduces Python's `csv` module, the standard way to read and write tabular data as plain text that opens cleanly in Excel, Google Sheets, or any other spreadsheet tool.
By the end of this tutorial you will have a console app that records expenses to a CSV file, and two different ways of summarizing them: a running total per category across all recorded history, and a monthly summary that filters expenses down to one specific month before totaling. Both summaries are built from the same handful of dictionary operations, which is the main thing this project is meant to teach — once you can group data into a dict by a key, computing totals, counts, or averages from that grouping is nearly free.
- A `load_expenses()`/`save_expenses()` pair built on `csv.DictReader`/`csv.DictWriter`.
- An `add_expense()` function that records the date automatically using the `datetime` module.
- A `total_by_category()` function that groups expenses into a `{category: total}` dict.
- A `monthly_summary()` function that filters expenses to one month before totaling by category.
- A menu loop for adding expenses, viewing all-time totals, and viewing a chosen month's summary.
Prerequisites
- Dictionaries — reading, writing, and iterating with `.items()`, plus `.get()` and `.setdefault()`.
- File handling — the `csv` module's `DictReader`/`DictWriter`, which read and write dict-shaped rows.
- Functions — parameters, return values, and list comprehensions used to filter data before summarizing it.
- The `datetime` module basics — getting today's date and formatting it as a string with `strftime()`.
- Exception handling basics — using `try`/`except ValueError` to guard against bad numeric input.
Project Structure
Everything lives in one file, `expense_tracker.py`. Each expense is a dict with four keys — `date`, `category`, `amount`, and `note` — held together in a single list called `expenses`, the same "list of dicts, passed around by functions" shape used by the Todo List CLI project. The one addition here is a fixed `FIELDNAMES` list, which both `csv.DictWriter` (writing) and the header row it produces (reading back with `csv.DictReader`) rely on to know which columns exist and in what order.
CSV stores every value as plain text, including numbers — that is worth remembering, because it means `expense["amount"]` comes back from `csv.DictReader` as the string `"250.0"`, not the float `250.0`, until `load_expenses()` explicitly converts it in Step 1. Forgetting that conversion is one of the most common CSV bugs: totals silently become string concatenation ("10" + "20" = "1020" style bugs do not happen with `+` on strings, but sorting and comparisons behave in surprising, hard-to-notice ways) instead of numeric addition.
Step 1: Set Up CSV Storage
Both functions open the file with `newline=""`, which the `csv` module's own documentation recommends — without it, Windows can insert extra blank rows because of how it represents line endings. `load_expenses()` converts each row's `amount` field back into a `float` immediately after reading it, so every other function in this program can assume `expense["amount"]` is always a real number, never a string.
import csv # standard library module for reading/writing tabular CSV dataimport os # to check whether the expenses file exists before trying to read itfrom datetime import datetime # to stamp each new expense with today's date automatically
EXPENSES_FILE = "expenses.csv" # single constant so the filename only changes in one placeFIELDNAMES = ["date", "category", "amount", "note"] # column order used by both the writer and the header row
def load_expenses(): """Load all expenses from disk, returning an empty list if no file exists yet.""" if not os.path.exists(EXPENSES_FILE): # first run: nothing recorded yet return [] with open(EXPENSES_FILE, "r", newline="") as f: # newline="" avoids extra blank rows on Windows, per the csv docs reader = csv.DictReader(f) # reads each row as a dict keyed by the header row's column names expenses = [] for row in reader: row["amount"] = float(row["amount"]) # CSV stores everything as text; convert amount back into a real number expenses.append(row) return expenses
def save_expenses(expenses): """Write the current expense list to disk as CSV, overwriting whatever was there before.""" with open(EXPENSES_FILE, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) # FIELDNAMES fixes the column order for every row writer.writeheader() # first line: date,category,amount,note writer.writerows(expenses) # one CSV line per expense dictStep 2: Add an Expense
`add_expense()` fills in `date` automatically with `datetime.now()` rather than asking the user to type it, which both saves a step and guarantees the stored format is always consistent enough for `monthly_summary()` in Step 4 to filter on later. `note` is given a default value of an empty string so callers are never forced to supply one.
def add_expense(expenses, category, amount, note=""): """Create a new expense dict, append it to expenses, and return it.""" expense = { "date": datetime.now().strftime("%Y-%m-%d"), # e.g. "2026-08-09"; sortable and easy to filter by month prefix "category": category, "amount": amount, "note": note, } expenses.append(expense) return expenseClick Run to see what this code prints.
Step 3: Group Expenses by Category
`total_by_category()` is the core grouping pattern this whole project is built around: walk the list once, and for each expense either start a new running total for its category or add to the one already there. `totals.get(category, 0)` is what makes that a one-liner — it returns the existing total if `category` is already a key, or `0` if this is the first expense seen in that category, so there is never a need for a separate `if category not in totals:` check beforehand.
def total_by_category(expenses): """Return a dict mapping each category to the sum of amounts spent in it.""" totals = {} for expense in expenses: category = expense["category"] totals[category] = totals.get(category, 0) + expense["amount"] # .get(key, 0) handles a category's first appearance return totalsStep 4: Build a Monthly Summary
`monthly_summary()` deliberately does not duplicate the grouping logic from Step 3 — it filters `expenses` down to just the entries whose `date` starts with the requested year and month, using a list comprehension, and then hands that smaller list straight to `total_by_category()`. Because every date was stored as `"YYYY-MM-DD"` in Step 2, a simple `str.startswith("2026-08")` is enough to match every expense from August 2026 without needing to parse the date into a real `datetime` object at all.
def monthly_summary(expenses, year_month): """Return {category: total} for just the given month, e.g. year_month="2026-08".""" month_expenses = [e for e in expenses if e["date"].startswith(year_month)] # keep only matching-month expenses return total_by_category(month_expenses) # reuse Step 3's grouping logic instead of duplicating itStep 5: Build the Menu Loop
`main()` loads any existing history, then loops on a menu that can add an expense, print all-time totals by category, or print one month's summary. `print_totals()` is a small shared helper so the "all-time" and "monthly" menu options do not each need their own formatting code — they just compute a different dict and hand it to the same printer.
def print_totals(totals, heading): """Print a {category: total} dict as a formatted, sorted list with a running grand total.""" print(f"\n--- {heading} ---") if not totals: print("No expenses recorded.") return for category, total in sorted(totals.items()): # sorted() on .items() orders alphabetically by category name print(f"{category:<15} ${total:.2f}") print(f"{'TOTAL':<15} ${sum(totals.values()):.2f}")
def print_menu(): print("\n===== EXPENSE TRACKER =====") print("1. Add Expense") print("2. View All-Time Totals by Category") print("3. View Monthly Summary") print("4. Exit")
def main(): expenses = load_expenses() # restore whatever was saved from the previous run, or [] on the first run
while True: print_menu() choice = input("Enter your choice: ").strip()
if choice == "1": category = input("Category: ").strip() try: amount = float(input("Amount: ")) except ValueError: # guards against non-numeric input like "twenty" print("Please enter a valid number.") continue note = input("Note (optional): ").strip() add_expense(expenses, category, amount, note) save_expenses(expenses) # persist immediately so this expense survives a crash print("Expense recorded.")
elif choice == "2": print_totals(total_by_category(expenses), "All-Time Totals by Category")
elif choice == "3": year_month = input("Enter month as YYYY-MM (e.g. 2026-08): ").strip() print_totals(monthly_summary(expenses, year_month), f"Summary for {year_month}")
elif choice == "4": print("Goodbye!") break
else: print("Invalid choice, try again.")
if __name__ == "__main__": main()Complete Code
Here is the full program, ready to run with `python expense_tracker.py`.
import csvimport osfrom datetime import datetime
EXPENSES_FILE = "expenses.csv"FIELDNAMES = ["date", "category", "amount", "note"]
def load_expenses(): """Load all expenses from disk, returning an empty list if no file exists yet.""" if not os.path.exists(EXPENSES_FILE): return [] with open(EXPENSES_FILE, "r", newline="") as f: reader = csv.DictReader(f) expenses = [] for row in reader: row["amount"] = float(row["amount"]) expenses.append(row) return expenses
def save_expenses(expenses): """Write the current expense list to disk as CSV, overwriting whatever was there before.""" with open(EXPENSES_FILE, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() writer.writerows(expenses)
def add_expense(expenses, category, amount, note=""): """Create a new expense dict, append it to expenses, and return it.""" expense = { "date": datetime.now().strftime("%Y-%m-%d"), "category": category, "amount": amount, "note": note, } expenses.append(expense) return expense
def total_by_category(expenses): """Return a dict mapping each category to the sum of amounts spent in it.""" totals = {} for expense in expenses: category = expense["category"] totals[category] = totals.get(category, 0) + expense["amount"] return totals
def monthly_summary(expenses, year_month): """Return {category: total} for just the given month, e.g. year_month="2026-08".""" month_expenses = [e for e in expenses if e["date"].startswith(year_month)] return total_by_category(month_expenses)
def print_totals(totals, heading): """Print a {category: total} dict as a formatted, sorted list with a running grand total.""" print(f"\n--- {heading} ---") if not totals: print("No expenses recorded.") return for category, total in sorted(totals.items()): print(f"{category:<15} ${total:.2f}") print(f"{'TOTAL':<15} ${sum(totals.values()):.2f}")
def print_menu(): print("\n===== EXPENSE TRACKER =====") print("1. Add Expense") print("2. View All-Time Totals by Category") print("3. View Monthly Summary") print("4. Exit")
def main(): expenses = load_expenses()
while True: print_menu() choice = input("Enter your choice: ").strip()
if choice == "1": category = input("Category: ").strip() try: amount = float(input("Amount: ")) except ValueError: print("Please enter a valid number.") continue note = input("Note (optional): ").strip() add_expense(expenses, category, amount, note) save_expenses(expenses) print("Expense recorded.")
elif choice == "2": print_totals(total_by_category(expenses), "All-Time Totals by Category")
elif choice == "3": year_month = input("Enter month as YYYY-MM (e.g. 2026-08): ").strip() print_totals(monthly_summary(expenses, year_month), f"Summary for {year_month}")
elif choice == "4": print("Goodbye!") break
else: print("Invalid choice, try again.")
if __name__ == "__main__": main()Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a monthly budget per category and print a warning when `total_by_category()` exceeds it.
- Support multiple currencies by adding a `currency` column and converting to a base currency before totaling.
- Chart the monthly summary with `matplotlib` as a simple bar chart of spending by category.
- Add an `edit_expense()`/`delete_expense()` pair keyed by row index, matching the todo list project's pattern.
- Export a formatted summary report to a text or PDF file instead of only printing it to the console.
Summary
You built an expense tracker that reads and writes CSV with `csv.DictReader`/`csv.DictWriter`, and computes both all-time and monthly summaries from the same core grouping pattern: walk a list once, and accumulate a running value per key in a dict using `.get(key, 0)`. That pattern is not specific to money — the same three lines work for grouping any list of records by any field, which makes it one of the most reusable techniques in this entire tutorial series.