Build a personal expense tracker in Python with CSV storage, categories, monthly summaries, budget alerts, and a data visualization dashboard. Full step-by-step OOP tutorial with pandas and matplotlib.
Build a complete personal finance tracker that records expenses, generates reports, and visualizes your spending patterns.
Project Overview
What you'll build: An expense tracker with categories, budgets, and charts
Skills practiced: OOP, file I/O, Pandas, Matplotlib, CLI design
Estimated time: 4–5 hours
Project Structure
expense-tracker/
├── main.py # Entry point
├── tracker.py # ExpenseTracker class
├── models.py # Expense data model
├── reports.py # Report generation
├── visualizer.py # Charts and plots
├── data/
│ └── expenses.csv # Data storage
└── requirements.txt
Step 1: Data Model
# models.py
from dataclasses import dataclass, field, asdict
from datetime import date, datetime
from enum import Enum
import uuid
class Category(Enum):
FOOD = "Food"
TRANSPORT = "Transport"
SHOPPING = "Shopping"
ENTERTAINMENT = "Entertainment"
HEALTH = "Health"
UTILITIES = "Utilities"
EDUCATION = "Education"
HOUSING = "Housing"
SAVINGS = "Savings"
OTHER = "Other"
@dataclass
class Expense:
amount: float
category: str
description: str
date: date = field(default_factory=date.today)
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
def __post_init__(self):
if self.amount <= 0:
raise ValueError("Amount must be positive")
if isinstance(self.date, str):
self.date = datetime.strptime(self.date, '%Y-%m-%d').date()
def to_dict(self):
d = asdict(self)
d['date'] = str(d['date'])
return d
@classmethod
def from_dict(cls, data):
return cls(
id=data.get('id', str(uuid.uuid4())[:8]),
amount=float(data['amount']),
category=data['category'],
description=data['description'],
date=data['date'],
)
def __str__(self):
return (f"[{self.id}] {self.date} | {self.category:15s} | "
f"${self.amount:>8.2f} | {self.description}")
# Category emoji mapping
CATEGORY_ICONS = {
'Food': '🍔', 'Transport': '🚗', 'Shopping': '🛍️',
'Entertainment': '🎬', 'Health': '💊', 'Utilities': '💡',
'Education': '📚', 'Housing': '🏠', 'Savings': '💰', 'Other': '📦'
}
Step 2: Core Tracker Class
# tracker.py
import csv
import os
import json
from pathlib import Path
from datetime import date, datetime
import pandas as pd
from models import Expense, Category, CATEGORY_ICONS
class ExpenseTracker:
"""Core expense tracking engine."""
DATA_DIR = Path('data')
EXPENSES_FILE = DATA_DIR / 'expenses.csv'
BUDGETS_FILE = DATA_DIR / 'budgets.json'
def __init__(self):
self.DATA_DIR.mkdir(exist_ok=True)
self.expenses = []
self.budgets = {}
self._load_expenses()
self._load_budgets()
# ─── CRUD Operations ───────────────────────────────────────
def add_expense(self, amount, category, description, expense_date=None):
"""Add a new expense."""
expense = Expense(
amount=float(amount),
category=category,
description=description,
date=expense_date or date.today()
)
self.expenses.append(expense)
self._save_expenses()
print(f" ✅ Added: ${amount:.2f} [{category}] — {description}")
# Check budget warning
self._check_budget_alert(category)
return expense
def delete_expense(self, expense_id):
"""Delete an expense by ID."""
initial_count = len(self.expenses)
self.expenses = [e for e in self.expenses if e.id != expense_id]
if len(self.expenses) < initial_count:
self._save_expenses()
print(f" ✅ Deleted expense {expense_id}")
return True
print(f" ❌ Expense {expense_id} not found")
return False
def update_expense(self, expense_id, **kwargs):
"""Update an expense field."""
for expense in self.expenses:
if expense.id == expense_id:
for key, value in kwargs.items():
if hasattr(expense, key):
setattr(expense, key, value)
self._save_expenses()
print(f" ✅ Updated expense {expense_id}")
return True
print(f" ❌ Expense {expense_id} not found")
return False
# ─── Query Methods ─────────────────────────────────────────
def get_expenses_df(self):
"""Return expenses as a Pandas DataFrame."""
if not self.expenses:
return pd.DataFrame(columns=['id', 'date', 'category', 'description', 'amount'])
data = [e.to_dict() for e in self.expenses]
df = pd.DataFrame(data)
df['date'] = pd.to_datetime(df['date'])
df['amount'] = df['amount'].astype(float)
df['month'] = df['date'].dt.to_period('M')
df['year'] = df['date'].dt.year
return df
def filter_expenses(self, category=None, month=None, year=None,
start_date=None, end_date=None, min_amount=None, max_amount=None):
"""Filter expenses with multiple criteria."""
df = self.get_expenses_df()
if df.empty:
return df
if category:
df = df[df['category'].str.lower() == category.lower()]
if month:
df = df[df['date'].dt.month == month]
if year:
df = df[df['date'].dt.year == year]
if start_date:
df = df[df['date'] >= pd.to_datetime(start_date)]
if end_date:
df = df[df['date'] <= pd.to_datetime(end_date)]
if min_amount:
df = df[df['amount'] >= min_amount]
if max_amount:
df = df[df['amount'] <= max_amount]
return df.sort_values('date', ascending=False)
# ─── Budget Management ─────────────────────────────────────
def set_budget(self, category, monthly_amount):
"""Set a monthly budget for a category."""
self.budgets[category] = float(monthly_amount)
self._save_budgets()
print(f" ✅ Budget set: {category} = ${monthly_amount:.2f}/month")
def _check_budget_alert(self, category):
"""Check if spending is approaching or over budget."""
if category not in self.budgets:
return
budget = self.budgets[category]
current_month = date.today().month
current_year = date.today().year
df = self.filter_expenses(category=category, month=current_month, year=current_year)
spent = df['amount'].sum() if not df.empty else 0
if spent >= budget:
print(f" 🚨 ALERT: {category} budget EXCEEDED! Spent ${spent:.2f} / ${budget:.2f}")
elif spent >= budget * 0.8:
print(f" ⚠️ WARNING: {category} at {spent/budget*100:.0f}% of budget (${spent:.2f} / ${budget:.2f})")
# ─── Stats ─────────────────────────────────────────────────
def get_summary(self, month=None, year=None):
"""Get spending summary."""
df = self.filter_expenses(month=month, year=year)
if df.empty:
return None
summary = {
'total': df['amount'].sum(),
'count': len(df),
'average': df['amount'].mean(),
'max_expense': df.loc[df['amount'].idxmax()].to_dict(),
'by_category': df.groupby('category')['amount'].agg(['sum', 'count', 'mean']).round(2).to_dict(),
'by_month': df.groupby(df['date'].dt.to_period('M'))['amount'].sum().round(2).to_dict() if not month else {},
}
return summary
# ─── File I/O ──────────────────────────────────────────────
def _save_expenses(self):
with open(self.EXPENSES_FILE, 'w', newline='') as f:
if not self.expenses:
return
writer = csv.DictWriter(f, fieldnames=['id', 'date', 'amount', 'category', 'description'])
writer.writeheader()
writer.writerows(e.to_dict() for e in self.expenses)
def _load_expenses(self):
if not self.EXPENSES_FILE.exists():
return
try:
with open(self.EXPENSES_FILE) as f:
reader = csv.DictReader(f)
self.expenses = [Expense.from_dict(row) for row in reader]
except Exception as e:
print(f" ⚠️ Could not load expenses: {e}")
def _save_budgets(self):
with open(self.BUDGETS_FILE, 'w') as f:
json.dump(self.budgets, f, indent=2)
def _load_budgets(self):
if not self.BUDGETS_FILE.exists():
return
with open(self.BUDGETS_FILE) as f:
self.budgets = json.load(f)
Step 3: Reports Module
# reports.py
from datetime import date
import pandas as pd
from models import CATEGORY_ICONS
def print_expense_list(df, title="Expenses"):
"""Print a formatted expense list."""
print(f"\n{'='*65}")
print(f" {title} ({len(df)} items)")
print(f"{'='*65}")
if df.empty:
print(" No expenses found.")
return
print(f" {'DATE':<12} {'CATEGORY':<16} {'AMOUNT':>9} {'DESCRIPTION'}")
print(f" {'-'*61}")
total = 0
for _, row in df.iterrows():
icon = CATEGORY_ICONS.get(row['category'], '📦')
print(f" {str(row['date'].date()):<12} "
f"{icon} {row['category']:<14} "
f"${row['amount']:>8.2f} "
f"{str(row['description'])[:30]}")
total += row['amount']
print(f" {'-'*61}")
print(f" {'TOTAL':>40} ${total:>8.2f}")
def print_monthly_report(tracker, month=None, year=None):
"""Print comprehensive monthly report."""
if not month:
month = date.today().month
if not year:
year = date.today().year
MONTH_NAMES = {1:'Jan',2:'Feb',3:'Mar',4:'Apr',5:'May',6:'Jun',
7:'Jul',8:'Aug',9:'Sep',10:'Oct',11:'Nov',12:'Dec'}
df = tracker.filter_expenses(month=month, year=year)
print(f"\n{'='*65}")
print(f" MONTHLY REPORT — {MONTH_NAMES.get(month, month)} {year}")
print(f"{'='*65}")
if df.empty:
print(" No expenses this month.")
return
total = df['amount'].sum()
print(f"\n 📊 Overview")
print(f" Total spent: ${total:,.2f}")
print(f" Transactions: {len(df)}")
print(f" Average/transaction: ${df['amount'].mean():.2f}")
print(f" Largest: ${df['amount'].max():.2f} ({df.loc[df['amount'].idxmax(), 'description']})")
# Category breakdown
print(f"\n 📋 By Category")
print(f" {'CATEGORY':<20} {'SPENT':>10} {'%TOTAL':>8} {'#':>5} {'BUDGET STATUS'}")
print(f" {'-'*60}")
cat_totals = df.groupby('category')['amount'].sum().sort_values(ascending=False)
for cat, spent in cat_totals.items():
icon = CATEGORY_ICONS.get(cat, '📦')
pct = spent / total * 100
budget = tracker.budgets.get(cat)
if budget:
status = f"${spent:.0f}/${budget:.0f} {'🔴' if spent > budget else '🟢'}"
else:
status = "No budget set"
count = len(df[df['category'] == cat])
print(f" {icon} {cat:<19} ${spent:>8.2f} {pct:>7.1f}% {count:>4} {status}")
print(f" {'-'*60}")
print(f" {'TOTAL':<20} ${total:>9.2f} 100.0%")
# Daily breakdown
print(f"\n 📅 Daily Totals (Top 5 Days)")
daily = df.groupby(df['date'].dt.day)['amount'].sum().sort_values(ascending=False).head(5)
for day, amount in daily.items():
print(f" Day {day:2d}: ${amount:.2f}")
Step 4: Visualization
# visualizer.py
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import pandas as pd
import numpy as np
import seaborn as sns
from datetime import date
def plot_monthly_dashboard(tracker, month=None, year=None, save=True):
"""Create a comprehensive monthly expense dashboard."""
if not month:
month = date.today().month
if not year:
year = date.today().year
df = tracker.filter_expenses(month=month, year=year)
if df.empty:
print("No data to visualize.")
return
MONTH_NAMES = ['', 'Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec']
sns.set_theme(style='whitegrid')
fig = plt.figure(figsize=(16, 10))
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.3)
cat_totals = df.groupby('category')['amount'].sum().sort_values(ascending=False)
total = df['amount'].sum()
colors = plt.cm.Set3(np.linspace(0, 1, len(cat_totals)))
# 1. Pie chart
ax1 = fig.add_subplot(gs[0, 0])
wedges, texts, autotexts = ax1.pie(cat_totals.values, labels=cat_totals.index,
colors=colors, autopct='%1.1f%%', startangle=90,
wedgeprops={'edgecolor': 'white', 'linewidth': 1.5})
for at in autotexts:
at.set_fontsize(8)
ax1.set_title(f'Spending by Category\n{MONTH_NAMES[month]} {year}', fontweight='bold')
# 2. Bar chart
ax2 = fig.add_subplot(gs[0, 1])
bars = ax2.barh(cat_totals.index, cat_totals.values, color=colors, alpha=0.85, edgecolor='white')
ax2.set_xlabel('Amount ($)')
ax2.set_title('Category Ranking', fontweight='bold')
ax2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:.0f}'))
for bar, val in zip(bars, cat_totals.values):
ax2.text(val + total*0.01, bar.get_y() + bar.get_height()/2,
f'${val:.0f}', va='center', fontsize=8)
# 3. Daily trend
ax3 = fig.add_subplot(gs[0, 2])
daily = df.groupby(df['date'].dt.day)['amount'].sum()
ax3.fill_between(daily.index, daily.values, alpha=0.4, color='steelblue')
ax3.plot(daily.index, daily.values, 'b-o', linewidth=1.5, markersize=5)
ax3.set_xlabel('Day of Month')
ax3.set_ylabel('Amount ($)')
ax3.set_title('Daily Spending Trend', fontweight='bold')
ax3.grid(True, alpha=0.3)
# 4. Budget vs Actual
ax4 = fig.add_subplot(gs[1, 0:2])
budget_data = {cat: tracker.budgets.get(cat, 0) for cat in cat_totals.index}
x = np.arange(len(cat_totals))
w = 0.35
actual_bars = ax4.bar(x - w/2, cat_totals.values, w, label='Actual', color='steelblue', alpha=0.8)
budget_bars = ax4.bar(x + w/2, list(budget_data.values()), w, label='Budget', color='orange', alpha=0.8)
ax4.set_xticks(x)
ax4.set_xticklabels(cat_totals.index, rotation=20, ha='right', fontsize=9)
ax4.set_ylabel('Amount ($)')
ax4.set_title('Budget vs Actual Spending', fontweight='bold')
ax4.legend()
ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:.0f}'))
# 5. Summary stats
ax5 = fig.add_subplot(gs[1, 2])
ax5.axis('off')
stats_text = (
f"SUMMARY — {MONTH_NAMES[month]} {year}\n\n"
f"Total Spent: ${total:,.2f}\n"
f"Transactions: {len(df)}\n"
f"Avg/Transaction: ${df['amount'].mean():.2f}\n"
f"Largest Expense: ${df['amount'].max():.2f}\n"
f"Smallest Expense: ${df['amount'].min():.2f}\n"
f"Categories Used: {len(cat_totals)}\n\n"
f"Top Category: {cat_totals.index[0]}\n"
f" ${cat_totals.values[0]:.2f} ({cat_totals.values[0]/total*100:.1f}%)"
)
ax5.text(0.05, 0.95, stats_text, transform=ax5.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle='round', facecolor='lightblue', alpha=0.3))
plt.suptitle(f'Expense Dashboard — {MONTH_NAMES[month]} {year}',
fontsize=16, fontweight='bold')
if save:
filename = f'expense_report_{year}_{month:02d}.png'
plt.savefig(filename, dpi=150, bbox_inches='tight')
print(f" 📊 Dashboard saved to {filename}")
plt.show()
Step 5: Main CLI Application
# main.py — Main CLI entry point
from tracker import ExpenseTracker
from reports import print_expense_list, print_monthly_report
from visualizer import plot_monthly_dashboard
from models import Category, CATEGORY_ICONS
from datetime import date
def get_category():
"""Interactive category selection."""
cats = [c.value for c in Category]
print("\n Categories:")
for i, cat in enumerate(cats, 1):
icon = CATEGORY_ICONS.get(cat, '📦')
print(f" {i:2}. {icon} {cat}")
while True:
choice = input("\n Select category (1-10): ").strip()
try:
idx = int(choice) - 1
if 0 <= idx < len(cats):
return cats[idx]
except ValueError:
pass
print(" ❌ Invalid choice")
def main():
tracker = ExpenseTracker()
print("\n" + "=" * 55)
print(" 💰 Personal Expense Tracker")
print("=" * 55)
while True:
print("\n Main Menu:")
print(" 1. Add Expense")
print(" 2. View All Expenses")
print(" 3. Monthly Report")
print(" 4. View by Category")
print(" 5. Set Budget")
print(" 6. View Dashboard (Chart)")
print(" 7. Delete Expense")
print(" 8. Quit")
choice = input("\n > ").strip()
if choice == '1':
print("\n --- Add Expense ---")
try:
amount = float(input(" Amount: $").strip())
category = get_category()
description = input(" Description: ").strip()
date_str = input(f" Date (YYYY-MM-DD) [{date.today()}]: ").strip()
expense_date = date_str if date_str else date.today()
tracker.add_expense(amount, category, description, expense_date)
except ValueError as e:
print(f" ❌ Invalid input: {e}")
elif choice == '2':
df = tracker.get_expenses_df()
print_expense_list(df, "All Expenses")
elif choice == '3':
month = int(input(" Month (1-12) or Enter for current: ").strip() or date.today().month)
year = int(input(" Year or Enter for current: ").strip() or date.today().year)
print_monthly_report(tracker, month, year)
elif choice == '4':
category = get_category()
df = tracker.filter_expenses(category=category)
print_expense_list(df, f"{category} Expenses")
elif choice == '5':
category = get_category()
try:
budget = float(input(" Monthly budget: $").strip())
tracker.set_budget(category, budget)
except ValueError:
print(" ❌ Invalid amount")
elif choice == '6':
month = int(input(" Month (1-12) or Enter for current: ").strip() or date.today().month)
year = int(input(" Year or Enter for current: ").strip() or date.today().year)
plot_monthly_dashboard(tracker, month, year)
elif choice == '7':
expense_id = input(" Expense ID to delete: ").strip()
tracker.delete_expense(expense_id)
elif choice == '8':
print(" 👋 Goodbye! Keep tracking! 💰")
break
else:
print(" ❌ Invalid choice")
if __name__ == '__main__':
main()
Summary
In this project, you built:
- ✅ Data model with
@dataclass and validation - ✅
ExpenseTracker class with full CRUD operations - ✅ CSV persistence with Python's
csv module - ✅ Pandas-powered filtering and aggregation
- ✅ Budget tracking with alerts
- ✅ Monthly text reports
- ✅ 5-panel Matplotlib dashboard
- ✅ Full CLI with category menus
Key skills practiced:
@dataclass decorator- CSV reading/writing
- Pandas DataFrame filtering and groupby
- Matplotlib subplots with GridSpec
- File persistence (CSV + JSON)
- CLI menu design
*Next Project: Chatbot →*