← Back to DevBytes

How to Create a Personal Finance Tracker with Python

What Is a Personal Finance Tracker?

A personal finance tracker is a software tool that helps you record, categorize, and analyze your income and expenses over time. Instead of relying on spreadsheets or expensive budgeting apps, you can build your own lightweight tracker in Python that logs every transaction, calculates running balances, and generates spending summaries. The core idea is simple: capture each financial event with a date, amount, category, and optional description, then aggregate that data to reveal patterns in your financial behavior.

Why Build One with Python

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Python is an ideal language for a personal finance tracker because it offers clean syntax for data manipulation, built-in support for CSV and JSON persistence, and powerful libraries like datetime, collections, and matplotlib for visualization. Building your own tracker gives you complete control over data privacy, custom feature development, and the ability to adapt the tool exactly to your workflow. Unlike commercial apps, you own every byte of your financial data and can extend the tracker as your needs evolve—adding investment tracking, bill reminders, or multi-currency support at any time.

Project Structure and Setup

Create a new directory for the project and set up the following files:

finance_tracker/
├── tracker.py          # Core logic and data models
├── storage.py          # Persistence layer (CSV/JSON)
├── reports.py          # Summary and analysis functions
├── cli.py              # Command-line interface
└── data/
    └── transactions.csv  # Default data file

All code examples use Python's standard library, so no external dependencies are required for the core functionality. If you want charts later, install matplotlib with pip install matplotlib.

Core Implementation

Step 1: Define the Data Model

Start by creating a Transaction class that represents a single financial entry. Use Python's dataclass to keep the code concise and readable. Each transaction needs a unique identifier, a date, a type (income or expense), a category, an amount, and an optional note.

# tracker.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Optional
import uuid

@dataclass
class Transaction:
    transaction_type: str  # 'income' or 'expense'
    category: str
    amount: float
    date: str = field(default_factory=lambda: datetime.now().strftime('%Y-%m-%d'))
    note: Optional[str] = None
    tx_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
    
    def __post_init__(self):
        if self.transaction_type not in ('income', 'expense'):
            raise ValueError("transaction_type must be 'income' or 'expense'")
        if self.amount <= 0:
            raise ValueError("amount must be positive")
    
    def to_dict(self) -> dict:
        return asdict(self)
    
    @staticmethod
    def from_dict(data: dict):
        return Transaction(
            transaction_type=data['transaction_type'],
            category=data['category'],
            amount=float(data['amount']),
            date=data['date'],
            note=data.get('note'),
            tx_id=data.get('tx_id', str(uuid.uuid4())[:8])
        )

Step 2: Build the Transaction Manager

The TransactionManager class holds a list of transactions and provides methods to add, delete, and query them. It also computes aggregate statistics like total income, total expenses, and net balance.

# tracker.py continued
from collections import defaultdict

class TransactionManager:
    def __init__(self):
        self.transactions: list[Transaction] = []
    
    def add_transaction(self, tx_type: str, category: str, amount: float, 
                        date: str = None, note: str = None):
        tx = Transaction(
            transaction_type=tx_type,
            category=category,
            amount=amount,
            date=date or datetime.now().strftime('%Y-%m-%d'),
            note=note
        )
        self.transactions.append(tx)
        return tx
    
    def delete_transaction(self, tx_id: str) -> bool:
        for i, tx in enumerate(self.transactions):
            if tx.tx_id == tx_id:
                self.transactions.pop(i)
                return True
        return False
    
    def get_all_transactions(self) -> list[Transaction]:
        return sorted(self.transactions, key=lambda tx: tx.date, reverse=True)
    
    def get_transactions_by_date_range(self, start_date: str, end_date: str) -> list[Transaction]:
        return [
            tx for tx in self.transactions
            if start_date <= tx.date <= end_date
        ]
    
    def get_transactions_by_category(self, category: str) -> list[Transaction]:
        return [tx for tx in self.transactions if tx.category == category]
    
    def total_income(self) -> float:
        return sum(tx.amount for tx in self.transactions if tx.transaction_type == 'income')
    
    def total_expenses(self) -> float:
        return sum(tx.amount for tx in self.transactions if tx.transaction_type == 'expense')
    
    def net_balance(self) -> float:
        return self.total_income() - self.total_expenses()
    
    def category_breakdown(self, tx_type: str = 'expense') -> dict:
        breakdown = defaultdict(float)
        for tx in self.transactions:
            if tx.transaction_type == tx_type:
                breakdown[tx.category] += tx.amount
        return dict(sorted(breakdown.items(), key=lambda item: item[1], reverse=True))
    
    def monthly_summary(self) -> dict:
        summary = defaultdict(lambda: {'income': 0.0, 'expenses': 0.0})
        for tx in self.transactions:
            month_key = tx.date[:7]  # YYYY-MM
            if tx.transaction_type == 'income':
                summary[month_key]['income'] += tx.amount
            else:
                summary[month_key]['expenses'] += tx.amount
        return dict(sorted(summary.items()))

Step 3: Implement Data Persistence

To keep transactions between sessions, save them to a CSV file. The Storage class handles reading and writing, ensuring data integrity with atomic writes. CSV is chosen for its simplicity and compatibility with spreadsheet applications.

# storage.py
import csv
import os
import tempfile
from tracker import Transaction, TransactionManager

class Storage:
    def __init__(self, filepath: str = 'data/transactions.csv'):
        self.filepath = filepath
        self._ensure_directory()
    
    def _ensure_directory(self):
        os.makedirs(os.path.dirname(self.filepath), exist_ok=True)
    
    def save_transactions(self, manager: TransactionManager) -> int:
        """Save all transactions to CSV. Returns count of saved records."""
        fieldnames = ['tx_id', 'date', 'transaction_type', 'category', 'amount', 'note']
        temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False, newline='')
        try:
            writer = csv.DictWriter(temp_file, fieldnames=fieldnames)
            writer.writeheader()
            for tx in manager.transactions:
                writer.writerow(tx.to_dict())
            temp_file.close()
            os.replace(temp_file.name, self.filepath)
        except Exception:
            os.unlink(temp_file.name)
            raise
        return len(manager.transactions)
    
    def load_transactions(self, manager: TransactionManager) -> int:
        """Load transactions from CSV into manager. Returns count of loaded records."""
        if not os.path.exists(self.filepath):
            return 0
        with open(self.filepath, 'r') as f:
            reader = csv.DictReader(f)
            count = 0
            for row in reader:
                tx = Transaction.from_dict(row)
                manager.transactions.append(tx)
                count += 1
        return count

Step 4: Create Report Functions

Reports transform raw transaction data into actionable insights. This module provides formatted console output for summaries, category breakdowns, and monthly comparisons.

# reports.py
from tracker import TransactionManager
from datetime import datetime

def print_balance_sheet(manager: TransactionManager):
    income = manager.total_income()
    expenses = manager.total_expenses()
    net = manager.net_balance()
    print("\n" + "="*50)
    print("              BALANCE SHEET")
    print("="*50)
    print(f"  Total Income:     ${income:>10.2f}")
    print(f"  Total Expenses:   ${expenses:>10.2f}")
    print(f"  " + "-"*40)
    print(f"  Net Balance:      ${net:>10.2f}")
    print("="*50)

def print_category_report(manager: TransactionManager, top_n: int = 5):
    breakdown = manager.category_breakdown('expense')
    if not breakdown:
        print("\nNo expenses recorded yet.")
        return
    print("\n" + "="*50)
    print(f"         TOP {top_n} EXPENSE CATEGORIES")
    print("="*50)
    total_expenses = manager.total_expenses()
    for i, (category, amount) in enumerate(list(breakdown.items())[:top_n]):
        percentage = (amount / total_expenses) * 100
        print(f"  {i+1}. {category:<20} ${amount:>8.2f}  ({percentage:5.1f}%)")
    print("="*50)

def print_monthly_report(manager: TransactionManager, months: int = 6):
    summary = manager.monthly_summary()
    if not summary:
        print("\nNo transactions recorded yet.")
        return
    recent_months = list(summary.keys())[-months:]
    print("\n" + "="*60)
    print("              MONTHLY REPORT")
    print("="*60)
    print(f"  {'Month':<10} {'Income':>10} {'Expenses':>10} {'Net':>10}")
    print("  " + "-"*50)
    for month in recent_months:
        m_data = summary[month]
        net = m_data['income'] - m_data['expenses']
        print(f"  {month:<10} ${m_data['income']:>9.2f} ${m_data['expenses']:>9.2f} ${net:>9.2f}")
    print("="*60)

def print_recent_transactions(manager: TransactionManager, count: int = 10):
    transactions = manager.get_all_transactions()[:count]
    if not transactions:
        print("\nNo transactions recorded yet.")
        return
    print("\n" + "="*70)
    print(f"              RECENT {count} TRANSACTIONS")
    print("="*70)
    print(f"  {'ID':<10} {'Date':<12} {'Type':<8} {'Category':<15} {'Amount':>10}")
    print("  " + "-"*65)
    for tx in transactions:
        sign = "+" if tx.transaction_type == 'income' else "-"
        print(f"  {tx.tx_id:<10} {tx.date:<12} {tx.transaction_type:<8} {tx.category:<15} {sign}${tx.amount:>9.2f}")
    print("="*70)

Step 5: Build the Command-Line Interface

The CLI ties everything together with an interactive menu. Users can add transactions, view reports, and manage their data through simple numbered choices. This interface uses a loop that persists until the user chooses to exit.

# cli.py
from tracker import TransactionManager
from storage import Storage
from reports import (
    print_balance_sheet,
    print_category_report,
    print_monthly_report,
    print_recent_transactions
)
import os

CATEGORIES = [
    'Housing', 'Transportation', 'Food', 'Utilities',
    'Healthcare', 'Entertainment', 'Shopping', 'Education',
    'Salary', 'Freelance', 'Investment', 'Gift', 'Other'
]

def clear_screen():
    os.system('cls' if os.name == 'nt' else 'clear')

def get_input(prompt: str, default: str = None) -> str:
    if default:
        response = input(f"{prompt} [{default}]: ")
        return response if response.strip() else default
    return input(f"{prompt}: ")

def add_transaction_flow(manager: TransactionManager):
    clear_screen()
    print("\n--- Add Transaction ---")
    print("Type: (1) Income  (2) Expense")
    type_choice = input("Choose [1/2]: ").strip()
    if type_choice == '1':
        tx_type = 'income'
    elif type_choice == '2':
        tx_type = 'expense'
    else:
        print("Invalid choice. Returning to menu.")
        return
    
    print("\nCategories:")
    relevant_categories = [c for c in CATEGORIES if (
        tx_type == 'income' and c in ('Salary', 'Freelance', 'Investment', 'Gift', 'Other')
    ) or (
        tx_type == 'expense' and c in ('Housing', 'Transportation', 'Food', 'Utilities',
                                         'Healthcare', 'Entertainment', 'Shopping', 'Education', 'Other')
    )]
    for i, cat in enumerate(relevant_categories, 1):
        print(f"  ({i}) {cat}")
    cat_choice = input("Choose category number: ").strip()
    try:
        category = relevant_categories[int(cat_choice) - 1]
    except (ValueError, IndexError):
        print("Invalid category. Returning to menu.")
        return
    
    try:
        amount = float(input("Amount: $").strip())
    except ValueError:
        print("Invalid amount. Returning to menu.")
        return
    
    date = get_input("Date (YYYY-MM-DD)", default=None)
    note = get_input("Note (optional)", default="")
    
    tx = manager.add_transaction(
        tx_type=tx_type,
        category=category,
        amount=amount,
        date=date if date else None,
        note=note if note else None
    )
    print(f"\n✓ Transaction {tx.tx_id} added successfully!")

def main():
    manager = TransactionManager()
    storage = Storage()
    count = storage.load_transactions(manager)
    if count > 0:
        print(f"Loaded {count} transactions from {storage.filepath}")
    
    while True:
        clear_screen()
        print("\n" + "="*50)
        print("        PERSONAL FINANCE TRACKER")
        print("="*50)
        print(f"  Net Balance: ${manager.net_balance():.2f}")
        print("  " + "-"*40)
        print("  1. Add Transaction")
        print("  2. View Recent Transactions")
        print("  3. Balance Sheet")
        print("  4. Category Breakdown")
        print("  5. Monthly Report")
        print("  6. Delete Transaction")
        print("  7. Save & Exit")
        print("  8. Exit without Saving")
        print("="*50)
        
        choice = input("Choose an option [1-8]: ").strip()
        
        if choice == '1':
            add_transaction_flow(manager)
            input("\nPress Enter to continue...")
        elif choice == '2':
            clear_screen()
            print_recent_transactions(manager)
            input("\nPress Enter to continue...")
        elif choice == '3':
            clear_screen()
            print_balance_sheet(manager)
            input("\nPress Enter to continue...")
        elif choice == '4':
            clear_screen()
            print_category_report(manager)
            input("\nPress Enter to continue...")
        elif choice == '5':
            clear_screen()
            print_monthly_report(manager)
            input("\nPress Enter to continue...")
        elif choice == '6':
            clear_screen()
            transactions = manager.get_all_transactions()[:20]
            if not transactions:
                print("No transactions to delete.")
            else:
                print_recent_transactions(manager, 20)
                tx_id = input("\nEnter transaction ID to delete: ").strip()
                if manager.delete_transaction(tx_id):
                    print(f"Transaction {tx_id} deleted.")
                else:
                    print("Transaction not found.")
            input("\nPress Enter to continue...")
        elif choice == '7':
            saved = storage.save_transactions(manager)
            print(f"\n✓ {saved} transactions saved to {storage.filepath}")
            print("Goodbye!")
            break
        elif choice == '8':
            print("\nExiting without saving. Changes will be lost.")
            confirm = input("Are you sure? (y/n): ").strip().lower()
            if confirm == 'y':
                print("Goodbye!")
                break
        else:
            print("Invalid option. Try again.")
            input("Press Enter to continue...")

if __name__ == '__main__':
    main()

Complete Single-File Version

If you prefer a single script that combines all modules, here is a compact but fully functional version. Save it as finance_tracker.py and run it directly with python finance_tracker.py.

#!/usr/bin/env python3
"""Personal Finance Tracker - Single File Version"""

from dataclasses import dataclass, field, asdict
from datetime import datetime
from collections import defaultdict
import csv
import os
import tempfile
import uuid

@dataclass
class Transaction:
    transaction_type: str
    category: str
    amount: float
    date: str = field(default_factory=lambda: datetime.now().strftime('%Y-%m-%d'))
    note: str = None
    tx_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
    
    def __post_init__(self):
        if self.transaction_type not in ('income', 'expense'):
            raise ValueError("Must be 'income' or 'expense'")
        if self.amount <= 0:
            raise ValueError("Amount must be positive")
    
    def to_dict(self):
        return asdict(self)
    
    @staticmethod
    def from_dict(data):
        return Transaction(
            transaction_type=data['transaction_type'],
            category=data['category'],
            amount=float(data['amount']),
            date=data['date'],
            note=data.get('note'),
            tx_id=data.get('tx_id', str(uuid.uuid4())[:8])
        )

class TransactionManager:
    def __init__(self):
        self.transactions = []
    
    def add(self, tx_type, category, amount, date=None, note=None):
        tx = Transaction(tx_type, category, amount, date or datetime.now().strftime('%Y-%m-%d'), note)
        self.transactions.append(tx)
        return tx
    
    def delete(self, tx_id):
        for i, tx in enumerate(self.transactions):
            if tx.tx_id == tx_id:
                self.transactions.pop(i)
                return True
        return False
    
    def all(self):
        return sorted(self.transactions, key=lambda tx: tx.date, reverse=True)
    
    def total_income(self):
        return sum(tx.amount for tx in self.transactions if tx.transaction_type == 'income')
    
    def total_expenses(self):
        return sum(tx.amount for tx in self.transactions if tx.transaction_type == 'expense')
    
    def net(self):
        return self.total_income() - self.total_expenses()
    
    def category_breakdown(self, tx_type='expense'):
        bd = defaultdict(float)
        for tx in self.transactions:
            if tx.transaction_type == tx_type:
                bd[tx.category] += tx.amount
        return dict(sorted(bd.items(), key=lambda x: x[1], reverse=True))
    
    def monthly_summary(self):
        s = defaultdict(lambda: {'income': 0.0, 'expenses': 0.0})
        for tx in self.transactions:
            month = tx.date[:7]
            if tx.transaction_type == 'income':
                s[month]['income'] += tx.amount
            else:
                s[month]['expenses'] += tx.amount
        return dict(sorted(s.items()))

class Storage:
    def __init__(self, path='transactions.csv'):
        self.path = path
    
    def save(self, manager):
        fields = ['tx_id', 'date', 'transaction_type', 'category', 'amount', 'note']
        tmp = tempfile.NamedTemporaryFile(mode='w', delete=False, newline='')
        try:
            w = csv.DictWriter(tmp, fieldnames=fields)
            w.writeheader()
            for tx in manager.transactions:
                w.writerow(tx.to_dict())
            tmp.close()
            os.replace(tmp.name, self.path)
        except Exception:
            os.unlink(tmp.name)
            raise
        return len(manager.transactions)
    
    def load(self, manager):
        if not os.path.exists(self.path):
            return 0
        with open(self.path, 'r') as f:
            reader = csv.DictReader(f)
            count = 0
            for row in reader:
                manager.transactions.append(Transaction.from_dict(row))
                count += 1
        return count

def print_divider(char='=', width=50):
    print(char * width)

def show_balance(manager):
    print("\n")
    print_divider()
    print("BALANCE SHEET".center(50))
    print_divider()
    print(f"  Total Income:     ${manager.total_income():>10.2f}")
    print(f"  Total Expenses:   ${manager.total_expenses():>10.2f}")
    print("  " + "-"*40)
    print(f"  Net Balance:      ${manager.net():>10.2f}")
    print_divider()

def show_categories(manager, top=5):
    bd = manager.category_breakdown('expense')
    if not bd:
        print("\nNo expenses yet.")
        return
    print("\n")
    print_divider()
    print(f"TOP {top} EXPENSE CATEGORIES".center(50))
    print_divider()
    total = manager.total_expenses()
    for i, (cat, amt) in enumerate(list(bd.items())[:top]):
        pct = (amt / total) * 100
        print(f"  {i+1}. {cat:<20} ${amt:>8.2f}  ({pct:5.1f}%)")
    print_divider()

def show_monthly(manager, months=6):
    s = manager.monthly_summary()
    if not s:
        print("\nNo transactions yet.")
        return
    recent = list(s.keys())[-months:]
    print("\n")
    print_divider('=', 60)
    print("MONTHLY REPORT".center(60))
    print_divider('=', 60)
    print(f"  {'Month':<10} {'Income':>10} {'Expenses':>10} {'Net':>10}")
    print("  " + "-"*50)
    for m in recent:
        d = s[m]
        net = d['income'] - d['expenses']
        print(f"  {m:<10} ${d['income']:>9.2f} ${d['expenses']:>9.2f} ${net:>9.2f}")
    print_divider('=', 60)

def show_recent(manager, count=10):
    txns = manager.all()[:count]
    if not txns:
        print("\nNo transactions yet.")
        return
    print("\n")
    print_divider('=', 70)
    print(f"RECENT {count} TRANSACTIONS".center(70))
    print_divider('=', 70)
    print(f"  {'ID':<10} {'Date':<12} {'Type':<8} {'Category':<15} {'Amount':>10}")
    print("  " + "-"*65)
    for tx in txns:
        sign = "+" if tx.transaction_type == 'income' else "-"
        print(f"  {tx.tx_id:<10} {tx.date:<12} {tx.transaction_type:<8} {tx.category:<15} {sign}${tx.amount:>9.2f}")
    print_divider('=', 70)

CATEGORIES = [
    'Housing', 'Transportation', 'Food', 'Utilities',
    'Healthcare', 'Entertainment', 'Shopping', 'Education',
    'Salary', 'Freelance', 'Investment', 'Gift', 'Other'
]

def add_transaction(manager):
    print("\n--- Add Transaction ---")
    print("Type: (1) Income  (2) Expense")
    tc = input("Choose [1/2]: ").strip()
    if tc == '1':
        ttype = 'income'
        cats = ['Salary', 'Freelance', 'Investment', 'Gift', 'Other']
    elif tc == '2':
        ttype = 'expense'
        cats = ['Housing', 'Transportation', 'Food', 'Utilities',
                'Healthcare', 'Entertainment', 'Shopping', 'Education', 'Other']
    else:
        print("Invalid choice.")
        return
    
    print("\nCategories:")
    for i, c in enumerate(cats, 1):
        print(f"  ({i}) {c}")
    try:
        cat = cats[int(input("Choose: ").strip()) - 1]
    except (ValueError, IndexError):
        print("Invalid category.")
        return
    
    try:
        amt = float(input("Amount: $").strip())
    except ValueError:
        print("Invalid amount.")
        return
    
    date = input("Date (YYYY-MM-DD, blank for today): ").strip() or None
    note = input("Note (optional): ").strip() or None
    
    tx = manager.add(ttype, cat, amt, date, note)
    print(f"\n✓ Transaction {tx.tx_id} added!")

def main():
    manager = TransactionManager()
    storage = Storage()
    loaded = storage.load(manager)
    if loaded:
        print(f"Loaded {loaded} transactions.")
    
    while True:
        print("\n" + "="*50)
        print("PERSONAL FINANCE TRACKER".center(50))
        print("="*50)
        print(f"  Net Balance: ${manager.net():.2f}")
        print("  " + "-"*40)
        print("  1. Add Transaction")
        print("  2. Recent Transactions")
        print("  3. Balance Sheet")
        print("  4. Category Breakdown")
        print("  5. Monthly Report")
        print("  6. Delete Transaction")
        print("  7. Save & Exit")
        print("  8. Exit without Saving")
        print("="*50)
        
        choice = input("Choose [1-8]: ").strip()
        
        if choice == '1':
            add_transaction(manager)
            input("Press Enter to continue...")
        elif choice == '2':
            show_recent(manager)
            input("Press Enter to continue...")
        elif choice == '3':
            show_balance(manager)
            input("Press Enter to continue...")
        elif choice == '4':
            show_categories(manager)
            input("Press Enter to continue...")
        elif choice == '5':
            show_monthly(manager)
            input("Press Enter to continue...")
        elif choice == '6':
            txns = manager.all()[:20]
            if not txns:
                print("No transactions to delete.")
            else:
                show_recent(manager, 20)
                tid = input("Enter ID to delete: ").strip()
                if manager.delete(tid):
                    print(f"Deleted {tid}.")
                else:
                    print("Not found.")
            input("Press Enter to continue...")
        elif choice == '7':
            saved = storage.save(manager)
            print(f"✓ {saved} transactions saved. Goodbye!")
            break
        elif choice == '8':
            confirm = input("Exit without saving? (y/n): ").strip().lower()
            if confirm == 'y':
                print("Goodbye!")
                break
        else:
            print("Invalid option.")
            input("Press Enter to continue...")

if __name__ == '__main__':
    main()

How to Use the Tracker

Run the script from your terminal:

python finance_tracker.py

You will see a menu with your current net balance and eight options. Start by adding a few transactions—record your monthly salary as income, then log expenses like groceries, rent, and utilities. After accumulating data, explore the reports. The balance sheet shows your overall financial picture, the category breakdown reveals where your money goes, and the monthly report tracks income-versus-expense trends over time. All data persists automatically when you choose "Save & Exit," and reloads the next time you launch the program.

Extending the Tracker

This foundation is designed for expansion. Here are common enhancements you can implement:

Best Practices for Your Finance Tracker

Conclusion

Building a personal finance tracker in Python gives you a powerful, customizable tool that grows with your financial awareness. The implementation covered here provides a complete foundation: a clean data model, reliable CSV persistence, insightful reports, and an intuitive command-line interface. By starting with these core components and gradually adding features like visualization, budget alerts, or a web dashboard, you create a system perfectly tailored to your financial goals. The code is self-contained, uses only the standard library, and can be extended in any direction you choose. Take this foundation, run it daily, and watch as consistent tracking transforms raw numbers into clear, actionable financial insight.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles