Personal Finance Tracker
Track expenses and balance using OOP, JSON, datetime, and error handling.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Project Overview
You are building a ledger. Every time money moves — a salary lands, rent goes out, a coffee is bought — you record one entry with an amount, a description, a category and a date. The program keeps a running balance, prints your recent history in aligned columns, and totals a month into income, expenses and net. Close the program, open it again tomorrow, and everything is still there, because each entry is written to a small JSON file the moment you add it.
That is the whole application, and it is genuinely useful software. A freelancer who needs to know what they actually earned last month, a student splitting a rent payment, anyone who has ever opened a banking app and thought "but where did it all go" — this answers that question with data they entered themselves and can read in a text editor. It is also the smallest honest version of what commercial tools like YNAB or Money Manager do: an append-only list of transactions, plus arithmetic over that list.
What makes this an advanced project is not the maths. It is that the data has to survive the program exiting. The moment you save to a file, you inherit a set of problems a script that only lives in memory never has: the file might not exist yet, it might be half-written from a crash, it might have been edited by hand into something invalid, and it definitely will not go away just because you re-ran your code. Most of the design below exists to handle those cases.
Goal: Track income, expenses, and generate reports.
Concepts: File I/O, datetime, JSON, formatting, error handling.
Key Features:
- • Add income and expenses
- • Categorize transactions
- • Generate monthly reports
- • Calculate savings rate
- • Budget warnings
- • Data visualization (optional)
Core Concepts
Six ideas carry this project. Read them before the code and the code stops looking like a wall of methods and starts looking like a set of decisions.
1. One signed number, not two lists
Income is a positive amount and an expense is a negative one, stored in the same list. This single choice removes most of the code you would otherwise write. The balance becomes a running sum instead of income-minus-expenses bookkeeping, and the monthly report separates the two with a filter on sign rather than two parallel data structures that can drift apart. The price you pay is that the caller has to remember the minus: add_transaction(-50, "Groceries") is a purchase, add_transaction(50, "Groceries") is a refund, and nothing in the code will stop you confusing them.
2. A class so the data and the operations travel together
Everything hangs off self. The constructor sets self.filename and then loads self.data, so every method afterwards is reading and writing the same ledger without a single argument being passed around. If you wrote this as loose functions you would be threading the same dictionary and the same filename through six signatures, and the first time you forgot one, you would be saving to the wrong place. A class is the smallest tool that makes that mistake impossible.
3. JSON as the save format, and what it refuses to store
json.dump turns your dictionary into text; json.load turns that text back into a dictionary. It is a good choice here because the file stays human-readable — you can open finance.json, see your transactions and fix a typo. But JSON only knows its own handful of types: dictionaries, lists, strings, numbers, true/false and null. A Python date object is not one of them, which is exactly why the code stores datetime.date.today().isoformat() and gets back the plain string "2026-09-03".
4. ISO date strings filter without any date parsing
Because ISO format puts the year first, then the month, then the day, those strings sort chronologically as ordinary text, and every date in September 2026 starts with the same six characters. That is what makes t["date"].startswith(month) a complete month filter. No strptime, no timezone thinking, no comparing date objects — one string prefix test. Store dates as "03/09/2026" instead and this trick evaporates.
5. Save on every write, not on exit
add_transaction calls self.save_data() before it prints its confirmation. There is no "save" command for the user to forget and no unsaved-changes state to reason about. The cost is a file write per transaction, which for a personal ledger of a few thousand entries is nothing. The benefit is that if the program is killed mid-session, the only thing you lose is the transaction you had not finished typing.
6. try/except as the normal path, not the panic button
load_data catches FileNotFoundError and returns a fresh empty structure. That is not error handling in the sense of something having gone wrong — it is the first run. Every user hits it exactly once. Writing it as an exception rather than an os.path.exists check is the Python habit: attempt the thing, handle the specific failure, and avoid the gap between checking and acting where the file could disappear anyway.
Starter Code
💡 This full interactive version uses input() which requires a local Python environment. Jump to the Browser Demo below to see a simplified version running in your browser.
This is the persistent version — the one that remembers. Read it in the order the program actually runs: __init__ stores the filename and immediately calls load_data, so by the time the object exists it already holds either your saved ledger or a fresh empty one. Then add_transaction does three things in a fixed order — build the entry dictionary, update the running balance, write the file — and only then prints. The two report methods never touch the file at all; they just read self.data, which is why they are so short. The four calls at the bottom are a stand-in for a menu, so you can run the script and see something happen.
import json
import datetime
from pathlib import Path
class FinanceTracker:
def __init__(self, filename="finance.json"):
self.filename = filename
self.data = self.load_data()
def load_data(self):
"""Load existing data or create new structure"""
try:
with open(self.filename) as f:
return json.load(f)
except FileNotFoundError:
return {"transactions": [], "balance": 0}
def save_data(self):
"""Save data to JSON file"""
with open(self.filename, "w") as f:
json.dump(self.data, f, indent=2)
def add_transaction(self, amount, description, category="General"):
"""Add income (positive) or expense (negative)"""
transaction = {
"amount": amount,
"description": description,
"category": category,
"date": datetime.date.today().isoformat()
}
self.data["transactions"].append(transaction)
self.data["balance"] += amount
self.save_data()
emoji = "💰" if amount > 0 else "💸"
print(f"{emoji} Added: {description} ({amount:,.2f})")
def show_balance(self):
"""Display current balance"""
balance = self.data["balance"]
color = "green" if balance >= 0 else "red"
print(f"💵 Current Balance: {balance:,.2f}")
def show_transactions(self, limit=10):
"""Show recent transactions"""
transactions = self.data["transactions"][-limit:]
print(f"📜 Recent Transactions (Last {limit}):")
for t in transactions:
amount = t["amount"]
sign = "+" if amount > 0 else ""
print(f" {t['date']} | {t['description']:20} | {sign}{amount:,.2f} | {t['category']}")
def monthly_report(self, month=None):
"""Generate report for specific month"""
if month is None:
month = datetime.date.today().strftime("%Y-%m")
monthly_txns = [t for t in self.data["transactions"]
if t["date"].startswith(month)]
income = sum(t["amount"] for t in monthly_txns if t["amount"] > 0)
expenses = sum(t["amount"] for t in monthly_txns if t["amount"] < 0)
print(f"📊 Report for {month}")
print(f" Income: {income:,.2f}")
print(f" Expenses: {abs(expenses):,.2f}")
print(f" Net: {income + expenses:,.2f}")
# Example usage
tracker = FinanceTracker()
tracker.add_transaction(1000, "Salary", "Income")
tracker.add_transaction(-50, "Groceries", "Food")
tracker.add_transaction(-30, "Netflix", "Entertainment")
tracker.add_transaction(200, "Freelance work", "Income")
tracker.show_balance()
tracker.show_transactions()
tracker.monthly_report()To run this locally: Save as finance_tracker.py and run python finance_tracker.py
What it prints on a first run
With no finance.json in the folder yet, this is the real output. The date column shows whichever day you run it.
💰 Added: Salary (1,000.00)
💸 Added: Groceries (-50.00)
💸 Added: Netflix (-30.00)
💰 Added: Freelance work (200.00)
💵 Current Balance: 1,120.00
📜 Recent Transactions (Last 10):
2026-09-03 | Salary | +1,000.00 | Income
2026-09-03 | Groceries | -50.00 | Food
2026-09-03 | Netflix | -30.00 | Entertainment
2026-09-03 | Freelance work | +200.00 | Income
📊 Report for 2026-09
Income: 1,200.00
Expenses: 80.00
Net: 1,120.00⚠️ The pitfall: run it twice and your balance doubles
Run the same file a second time and the balance is 2,240.00, the history shows eight rows, and the report claims 2,400.00 of income. Nothing is broken — this is persistence working correctly. The four example calls at the bottom are not a fixture, they are real transactions, and they run again on top of the ledger the first run saved. Almost everyone hits this and assumes the arithmetic is wrong.
The fix is to stop seeding data at import time. Move those calls behind if __name__ == "__main__":, and better still replace them with the menu loop this project is heading towards, so transactions only get added when a person asks for one. To reset while experimenting, delete finance.json, or pass a throwaway name: FinanceTracker("scratch.json").
Two smaller things to notice while you are in here. show_balance computes a color variable and never uses it — dead code left behind by an intention to colour the output, which the colorama enhancement below actually delivers. And from pathlib import Path at the top is imported but unused; the file work is all done with plain open(). Neither breaks anything, but spotting them is the habit that keeps a codebase honest.
Browser Demo (Simplified Version)
This version demonstrates the core functionality without requiring user input. Click "Run" to see it in action!
Compare it to the starter code and notice what was removed: there is no filename, no load_data, no save_data. The ledger is a plain list on the instance, so when the program ends the data is gone. That is the right trade for a demo — it strips out the file layer so you can see the ledger logic on its own — and it also isolates the one thing you should study here, which is monthly_summary. It computes income and expenses in two generator expressions, uses abs() so expenses read as positive money, and guards the savings-rate division with a conditional so a month with no income cannot crash it.
One difference will trip you up if you copy code between the two versions. The starter writes import datetime and then datetime.date.today() — that is the module, then the class inside it. This demo writes from datetime import datetime and then datetime.now() — that is the class itself, pulled out of the module and given the same name. Both are correct; mixing them in one file is how you end up staring at an attribute error.
from datetime import datetime
class FinanceTracker:
def __init__(self):
self.transactions = []
self.balance = 0
def add_transaction(self, amount, description, category="General"):
"""Add income (positive) or expense (negative)"""
transaction = {
"amount": amount,
"description": description,
"category": category,
"date": datetime.now().strftime("%Y-%m-%d")
}
self.transactions.append(transaction)
self.balance += amount
emoji = "💰" if amount > 0 else "💸"
print(f"{emoji} Added: {description} (£{amount:,.2f})")
def show_balance(self):
"""Display current balance"""
print(f"\n💵 Current Balance: £{self.balance:,.2f}")
def show_transactions(self):
"""Show all transactions"""
print(f"\n📜 All Transactions:")
for t in self.transactions:
amount = t["amount"]
sign = "+" if amount > 0 else ""
print(f" {t['date']} | {t['description']:20} | {sign}£{amount:,.2f} | {t['category']}")
def monthly_summary(self):
"""Generate monthly summary"""
income = sum(t["amount"] for t in self.transactions if t["amount"] > 0)
expenses = sum(abs(t["amount"]) for t in self.transactions if t["amount"] < 0)
print(f"\n📊 Monthly Summary:")
print(f" Income: £{income:,.2f}")
print(f" Expenses: £{expenses:,.2f}")
print(f" Net: £{income - expenses:,.2f}")
print(f" Savings Rate: {((income - expenses) / income * 100) if income > 0 else 0:.1f}%")
# Demo usage
print("=== PERSONAL FINANCE TRACKER DEMO ===\n")
tracker = FinanceTracker()
# Add sample transactions
tracker.add_transaction(2500, "Salary", "Income")
tracker.add_transaction(-800, "Rent", "Housing")
tracker.add_transaction(-150, "Groceries", "Food")
tracker.add_transaction(-45, "Netflix", "Entertainment")
tracker.add_transaction(-30, "Coffee", "Food")
tracker.add_transaction(500, "Freelance Project", "Income")
tracker.add_transaction(-65, "Internet Bill", "Utilities")
# Display reports
tracker.show_balance()
tracker.show_transactions()
tracker.monthly_summary()Real output from a run of exactly the code above. Only the date column will differ for you, because it comes from the clock.
=== PERSONAL FINANCE TRACKER DEMO ===
💰 Added: Salary (£2,500.00)
💸 Added: Rent (£-800.00)
💸 Added: Groceries (£-150.00)
💸 Added: Netflix (£-45.00)
💸 Added: Coffee (£-30.00)
💰 Added: Freelance Project (£500.00)
💸 Added: Internet Bill (£-65.00)
💵 Current Balance: £1,910.00
📜 All Transactions:
2026-09-03 | Salary | +£2,500.00 | Income
2026-09-03 | Rent | £-800.00 | Housing
2026-09-03 | Groceries | £-150.00 | Food
2026-09-03 | Netflix | £-45.00 | Entertainment
2026-09-03 | Coffee | £-30.00 | Food
2026-09-03 | Freelance Project | +£500.00 | Income
2026-09-03 | Internet Bill | £-65.00 | Utilities
📊 Monthly Summary:
Income: £3,000.00
Expenses: £1,090.00
Net: £1,910.00
Savings Rate: 63.7%Read the expense rows carefully, because there is a real formatting bug in that output: rent shows as £-800.00, with the minus sign stranded after the currency symbol. The f-string formats the number first and the literal pound sign is glued on in front of whatever comes out, so a negative number puts its sign in the wrong place. The fix is to format the magnitude and place the sign yourself — take abs(amount) for the number and build the prefix from the sign — which gives -£800.00. It is the kind of mistake that never crashes, never fails a test you wrote, and looks wrong to every single user.
The savings rate is worth checking by hand too: 3,000 in, 1,090 out, so (3000 − 1090) ÷ 3000 × 100 = 63.666…, printed as 63.7% by the .1f format spec. Knowing where a displayed number came from is how you catch the day it stops being right.
Common Errors
These are the tracebacks this specific project produces. The message is on the left of each explanation because that is what you will actually be searching for at eleven at night.
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
finance.json exists but is empty, or is truncated because a previous run was killed part-way through writing it. load_data only catches FileNotFoundError, so a file that exists but contains nothing readable sails straight past the handler and takes the program down before it starts. Catch both: except (FileNotFoundError, json.JSONDecodeError):. Column and character numbers in the message point at where the parser gave up, which tells you whether the file is empty or half-written.
TypeError: unsupported operand type(s) for +=: 'int' and 'str'
You passed the result of input() straight into add_transaction. input() always returns a string, even when the user typed digits, so self.data["balance"] += amount is trying to add text to a number. Convert at the edge, where the value enters the program: amount = float(input("Amount: ")), wrapped in a try/except for ValueError so a typo asks again instead of crashing.
ValueError: Unknown format code 'f' for object of type 'str'
The same string-where-a-number-belongs mistake, surfacing at a different line. This one comes from an f-string: {amount:,.2f} asks for fixed-point decimal formatting, and a string has no idea what that means. If you see this rather than the TypeError above, it means the value reached a print before it reached any arithmetic.
KeyError: 'balance'
The JSON file loaded fine but has no balance key — usually because you edited it by hand and deleted a line, or because it was written by an earlier version of the class that only stored transactions. The loader trusts the file's shape completely. Make it defensive by reading with a default, self.data.get("balance", 0), or by recomputing the balance from the transactions on load, which has the nice property of self-healing a file someone edited.
TypeError: Object of type date is not JSON serializable
You stored datetime.date.today() instead of datetime.date.today().isoformat(). The dictionary is built happily — the error only appears later, inside json.dump, when the encoder meets a type it has no rule for. Keep every value you put in self.data to plain JSON types and convert dates to strings at the point you create them.
FileNotFoundError: [Errno 2] No such file or directory: 'data/finance.json'
This one is nastier than it looks. Point the tracker at a folder that does not exist and the load succeeds — load_data catches the error and hands you an empty ledger, so the program starts normally. It is the first save that raises, after you have already typed a transaction in, and nothing catches it. Create the directory first, or store the file next to the script rather than in a subfolder.
ZeroDivisionError: division by zero
The savings rate divides by income, and a month where you recorded only expenses has an income of zero. The browser demo dodges this with its if income > 0 else 0 conditional; the moment you copy that line into a per-month version and simplify it, the bug is back. A savings rate is genuinely undefined with no income, so print a dash rather than a number.
No error at all: the balance that ends in 0.30000000000000004
Add 0.10 and 0.20 as floats and Python holds 0.30000000000000004, because binary floating point cannot represent those decimals exactly. Your display hides it — .2f rounds it to 0.30 — but a comparison does not, so a balance that looks like 0.30 can still fail an equality test against 0.3. For a toy tracker this is fine. For anything you would trust, store whole pence or cents as integers, or use decimal.Decimal built from strings, where 0.1 plus 0.2 really is 0.3.
Enhancement Ideas 🚀
Add these one at a time and re-run the tracker after each. Every one of them is a real feature with a real design decision inside it, not busywork.
1. Budget System
Set monthly budgets for categories and get warnings when approaching limits.
Store a second key alongside transactions — a dictionary mapping category name to a monthly limit — so it saves and loads with everything else. After each expense, sum that category for the current month and compare. The interesting decision is what counts as a warning: 80% of the limit is a useful nudge, 100% is a fact you already know. This also forces you to reuse the month filter from monthly_report, which is the moment you will want to pull it out into its own method rather than copying the list comprehension.
2. Data Visualization
Use matplotlib to create charts showing spending by category over time.
Before you install anything, build the totals: a dictionary of category to summed expenses, which collections.Counter or a plain loop will do. Getting that data structure right is most of the work, and you can print it as text bars made of hash characters to check it. A chart drawn from wrong numbers is just a prettier lie, so verify the totals against the report first.
3. Recurring Transactions
Add support for recurring monthly expenses (rent, subscriptions).
Keep a separate list of templates — amount, description, category, day of month — and on startup generate any that are due but not yet recorded. The hard part is not generating them, it is not generating them twice when you open the app three times in one day. Give each recurring entry a marker naming its template and the month it covers, then check for that marker before adding. This is the same idempotency problem every billing system has.
4. Multi-Currency Support
Track transactions in different currencies with automatic conversion.
Add a currency code to each transaction and store the rate that applied on the day it happened, not just the amount. Rates move, and a report you ran last month should not silently change its answer this month. Keep the balance in one base currency and treat everything else as converted on entry — mixing currencies in a single running total is how you produce a number that means nothing.
5. Export to CSV/Excel
Allow exporting transaction history for analysis in spreadsheet software.
Python's built-in csv module handles this with no extra install, and using it rather than joining strings with commas matters the first time a description contains a comma — "Coffee, milk and beans" would otherwise become two columns and shift every field after it. Write the header row from your transaction keys so the export never drifts out of sync when you add a field.
Next Steps
Work down this list in order. Each step leans on the one before it, and the early ones are deliberately small — the point is to keep a working tracker at every stage rather than a half-rewritten one.
- Test the tracker with sample transactions
- Add category management (add/edit/delete categories)
- Implement search and filter functionality
- Create yearly comparison reports
- Add data visualization with charts
- Build a budget tracking system
- Consider adding user authentication for privacy
A few notes on the harder ones. Search and filter is the natural place to introduce a single method that takes optional month, category and keyword arguments and returns a list, because every report you have written so far is really that method with different arguments. Yearly comparison reuses the ISO prefix trick with four characters instead of seven. And the last item deserves scepticism: a password prompt on a plain JSON file sitting on your own disk protects nothing, since anyone who can read the file can read it without your program. If privacy is the actual goal, encrypt the file contents; if the goal is learning authentication, build it knowing that is what you are learning.
When you are done, the tracker you have is a small but complete system: it takes input, validates it, stores it durably, survives being closed, and answers questions about the data it holds. That loop is the shape of most software you will ever write.