Skip to main content
Updated Feb 10, 2026

Capstone - Budget Tracker Complete App

Throughout this chapter, you learned every piece of the database puzzle:

  • L0: Created your /database-deployment skill scaffold
  • L1: Understood why databases beat CSV files
  • L2: Defined models as Python classes
  • L3: Built CRUD operations (Create, Read, Update, Delete)
  • L4: Connected tables with relationships and joins
  • L5: Protected data integrity with transactions
  • L6: Deployed to Neon with connection pooling
  • L7: Learned hybrid SQL + bash verification patterns

Now you put it all together. You'll run a complete, production-ready Budget Tracker application that demonstrates everything you've learned. This isn't a toy example. This is code you can actually use, extend, and share.

Why This Architecture Works

You might wonder: Why not just use bash scripts and JSON files to manage expenses? Why SQLAlchemy and a database?

Research from Braintrust (an AI evaluation platform) tested this exact question. They compared three approaches to querying structured data:

ApproachAccuracyTokens UsedTimeCost
SQL Queries100%155K45s$0.51
Bash + grep/awk52.7%1.06M401s$3.34
Hybrid (SQL + Bash)100%310K~150s-

What this means for your Budget Tracker:

  • Direct SQL queries (which SQLAlchemy generates): Fast, accurate, efficient
  • File-based approaches (bash/grep): 7x more tokens, 9x slower, half the accuracy
  • Why it matters: Even if you never use AI agents, the same efficiency applies to your own code. Direct queries scale from 100 expenses to 1 million without slowing down. File parsing gets slower with each additional record.

The research showed another insight: schema clarity is critical. The bash agent failed partly because "it didn't know the structure of the JSON files." Your SQLAlchemy models DO define that structure explicitly, which is why queries work reliably.

This is why professional applications — from startups to enterprises — use databases for anything more than toy data. The architectural choice you're making in this lesson is the same one made in production systems worldwide.

The Tool Choice Story

Looking back across Part 2, you've assembled a toolkit where each tool excels at specific data tasks:

Data TaskBest ToolWhyLearned In
File manipulationBashNative, fast, universalFile Processing
ComputationPythonDeterministic, decimal-safeComputation & Data Extraction
Structured queriesSQL (SQLAlchemy)Schema-aware, 100% accuracyThis chapter
Exploration + verificationHybrid (SQL + bash)Self-checking, catches edge casesL7 (Hybrid Patterns)

What This Means for Your Work

When AI agents query YOUR database, schema clarity determines accuracy. Your Budget Tracker models give Claude structural awareness that grep never has. The Expense model with its user_id, category_id, amount, and date columns tells any query engine exactly what questions it can answer and how to answer them.

This is why production AI systems use databases, not file parsing. The same ORM patterns you've learned in this chapter — models, sessions, relationships, transactions — are what power every real application that needs to remember, relate, and reliably query data. Your Budget Tracker isn't a toy; it's the same architecture pattern used at every scale.

In L7, you learned how combining SQL with bash verification creates self-checking data pipelines. Now you'll see all these patterns working together in one application.

What You're Building

The complete Budget Tracker includes these features:

FeatureImplementationLesson Origin
User accountsUser model with email, nameL3 (Models)
Expense categoriesCategory model with colorsL3 (Models)
Individual expensesExpense model with foreign keysL3 (Models)
Create expensescreate_expense() with error handlingL4 (CRUD)
List expensesread_expenses() with filteringL4 (CRUD)
Update expensesupdate_expense() with validationL4 (CRUD)
Delete expensesdelete_expense() safelyL4 (CRUD)
Spending by categoryget_expenses_by_category() with joinsL5 (Relationships)
Monthly summariesget_monthly_summary() with aggregationL5 (Relationships)
Budget transferstransfer_budget() atomic transactionL6 (Transactions)
Cloud persistenceNeon with connection poolingL6 (Neon)

Tech stack: SQLAlchemy ORM + Neon PostgreSQL + Python. No web framework yet. That comes in later chapters.

The Complete Application

Here's the full budget-tracker-complete.py. Every section maps directly to a lesson you've completed.

Section 1: Imports and Setup (L6)

"""
Complete Budget Tracker Application using SQLAlchemy ORM and Neon PostgreSQL
"""

import os
from datetime import datetime, date, timezone
from dotenv import load_dotenv
from sqlalchemy import (
create_engine,
Column,
Integer,
String,
Float,
DateTime,
Date,
ForeignKey,
func,
text,
)
from sqlalchemy.orm import declarative_base, relationship, Session
from sqlalchemy.pool import QueuePool

# Load environment variables
load_dotenv()

# Database connection
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL not set in .env file")

# Engine with connection pooling
engine = create_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
pool_recycle=3600,
pool_pre_ping=True,
echo=False # Set to True for SQL debugging
)

Base = declarative_base()

What you recognize: Environment variables from L6, connection pooling from L6, declarative_base() from L2.

Section 2: Models (L3)

class User(Base):
"""User account for budget tracking."""
__tablename__ = 'users'

id = Column(Integer, primary_key=True)
email = Column(String(100), unique=True, nullable=False)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

# Relationship: User has many expenses
expenses = relationship("Expense", back_populates="user", cascade="all, delete-orphan")

def __repr__(self):
return f"<User(id={self.id}, email='{self.email}')>"


class Category(Base):
"""Budget categories (Food, Transportation, Entertainment, etc.)."""
__tablename__ = 'categories'

id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
color = Column(String(7), default="#FF6B6B")

# Relationship: Category has many expenses
expenses = relationship("Expense", back_populates="category", cascade="all, delete-orphan")

def __repr__(self):
return f"<Category(name='{self.name}')>"


class Expense(Base):
"""Individual expense entry."""
__tablename__ = 'expenses'

id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
category_id = Column(Integer, ForeignKey('categories.id'), nullable=False)
description = Column(String(200), nullable=False)
amount = Column(Float, nullable=False)
date = Column(Date, default=date.today)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

# Relationships: Expense belongs to User and Category
user = relationship("User", back_populates="expenses")
category = relationship("Category", back_populates="expenses")

def __repr__(self):
return f"<Expense(${self.amount:.2f}, '{self.description}')>"

What you recognize: Column types from L2, ForeignKey from L4, relationship() with back_populates from L4, cascade="all, delete-orphan" from L4.

Section 3: CRUD Operations (L4)

def create_expense(user_id, description, amount, category_id, expense_date=None):
"""Create a new expense."""
try:
with Session(engine) as session:
expense = Expense(
user_id=user_id,
description=description,
amount=amount,
category_id=category_id,
date=expense_date or date.today()
)
session.add(expense)
session.commit()
return {"success": True, "id": expense.id}
except Exception as e:
return {"success": False, "error": str(e)}


def read_expenses(user_id, category_id=None):
"""Get expenses for a user, optionally filtered by category."""
with Session(engine) as session:
query = session.query(Expense).filter(Expense.user_id == user_id)
if category_id:
query = query.filter(Expense.category_id == category_id)
return query.order_by(Expense.date.desc()).all()


def update_expense(expense_id, **kwargs):
"""Update an expense. Allowed: description, amount, category_id, date."""
allowed_fields = {'description', 'amount', 'category_id', 'date'}
updates = {k: v for k, v in kwargs.items() if k in allowed_fields}

try:
with Session(engine) as session:
expense = session.query(Expense).filter(Expense.id == expense_id).first()
if not expense:
return {"success": False, "error": "Expense not found"}

for field, value in updates.items():
setattr(expense, field, value)

session.commit()
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}


def delete_expense(expense_id):
"""Delete an expense."""
try:
with Session(engine) as session:
expense = session.query(Expense).filter(Expense.id == expense_id).first()
if not expense:
return {"success": False, "error": "Expense not found"}

session.delete(expense)
session.commit()
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}

What you recognize: session.add(), session.commit() from L3; session.query().filter() from L3; error handling with try/except from L5.

Section 4: Relationship Queries (L5)

def get_monthly_summary(user_id, year, month):
"""Get spending summary grouped by category for a specific month."""
with Session(engine) as session:
# Calculate date range
if month == 12:
next_month = date(year + 1, 1, 1)
else:
next_month = date(year, month + 1, 1)
current_month = date(year, month, 1)

# Query: sum amount by category with join
results = session.query(
Category.name,
func.sum(Expense.amount).label('total'),
func.count(Expense.id).label('count')
).join(Expense).filter(
(Expense.user_id == user_id) &
(Expense.date >= current_month) &
(Expense.date < next_month)
).group_by(Category.name).all()

return [
{"category": name, "total": float(total or 0), "count": count}
for name, total, count in results
]


def get_expenses_by_category(user_id):
"""Get all expenses grouped by category."""
with Session(engine) as session:
categories = session.query(Category).all()

result = {}
for category in categories:
expenses = session.query(Expense).filter(
(Expense.user_id == user_id) &
(Expense.category_id == category.id)
).all()

result[category.name] = {
"count": len(expenses),
"total": sum(e.amount for e in expenses),
"expenses": [
{"id": e.id, "description": e.description,
"amount": e.amount, "date": e.date.isoformat()}
for e in expenses
]
}

return result


def get_top_expenses(user_id, limit=10):
"""Get the highest-value expenses."""
with Session(engine) as session:
return session.query(Expense).filter(
Expense.user_id == user_id
).order_by(Expense.amount.desc()).limit(limit).all()

What you recognize: .join() from L4, func.sum() and func.count() from L4, .group_by() from L4, navigation through relationships from L4.

Section 5: Transactions (L6)

def transfer_budget(user_id, from_category_id, to_category_id, amount):
"""
Atomic operation: Move budget from one category to another.
Creates two expense entries: negative in source, positive in destination.
Both succeed or both fail.
"""
try:
with Session(engine) as session:
from_cat = session.query(Category).filter(
Category.id == from_category_id
).first()
to_cat = session.query(Category).filter(
Category.id == to_category_id
).first()

if not from_cat or not to_cat:
raise ValueError("Category not found")

# Create BOTH transactions atomically
from_expense = Expense(
user_id=user_id,
category_id=from_category_id,
description=f"Transfer to {to_cat.name}",
amount=-amount
)
to_expense = Expense(
user_id=user_id,
category_id=to_category_id,
description=f"Transfer from {from_cat.name}",
amount=amount
)

session.add(from_expense)
session.add(to_expense)
session.commit() # Both succeed or both fail

return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}

What you recognize: Atomic transaction from L5 (all-or-nothing), session.rollback() implicit on exception from L5, paired operations from L5.

Section 6: Utilities and Main

def init_database():
"""Create all tables in database."""
Base.metadata.create_all(engine)
print("Database initialized")


def seed_data():
"""Add sample data for testing."""
with Session(engine) as session:
if session.query(User).count() > 0:
print("Database already has data, skipping seed")
return

user = User(email="alice@example.com", name="Alice Smith")
session.add(user)

categories = [
Category(name="Food", color="#FF6B6B"),
Category(name="Transportation", color="#4ECDC4"),
Category(name="Entertainment", color="#95E1D3"),
Category(name="Utilities", color="#F38181"),
]
session.add_all(categories)
session.flush()

expenses = [
Expense(user_id=user.id, category_id=categories[0].id,
description="Groceries", amount=52.50, date=date(2024, 1, 15)),
Expense(user_id=user.id, category_id=categories[0].id,
description="Lunch", amount=18.75, date=date(2024, 1, 16)),
Expense(user_id=user.id, category_id=categories[1].id,
description="Gas", amount=45.00, date=date(2024, 1, 17)),
Expense(user_id=user.id, category_id=categories[2].id,
description="Movie tickets", amount=30.00, date=date(2024, 1, 18)),
]
session.add_all(expenses)
session.commit()
print("Sample data added")


def test_connection():
"""Test database connection."""
try:
with Session(engine) as session:
session.execute(text("SELECT 1"))
print("Database connection successful")
return True
except Exception as e:
print(f"Connection failed: {e}")
return False


if __name__ == "__main__":
init_database()
if not test_connection():
exit(1)
seed_data()

user_id = 1

# Create new expense
print("\nCreating new expense...")
result = create_expense(user_id, "Dinner", 45.75, 1)
print(f"Result: {result}")

# List expenses
print("\nAll expenses:")
expenses = read_expenses(user_id)
for e in expenses:
print(f" ${e.amount:.2f} | {e.category.name} | {e.description}")

# Monthly summary
print("\nMonthly Summary (January 2024):")
summary = get_monthly_summary(user_id, 2024, 1)
for item in summary:
print(f" {item['category']:15} | Count: {item['count']:2} | ${item['total']:.2f}")

print("\nAll operations completed successfully!")

Running the Application

Prerequisites (from L6):

  1. Neon account with project created
  2. .env file with DATABASE_URL
  3. Dependencies installed

Install dependencies:

pip install sqlalchemy psycopg2-binary python-dotenv

Or with uv:

uv add sqlalchemy psycopg2-binary python-dotenv

Run the application:

python budget-tracker-complete.py

Output:

Database initialized
Database connection successful
Sample data added

Creating new expense...
Result: {'success': True, 'id': 5}

All expenses:
$45.75 | Food | Dinner
$30.00 | Entertainment | Movie tickets
$45.00 | Transportation | Gas
$18.75 | Food | Lunch
$52.50 | Food | Groceries

Monthly Summary (January 2024):
Food | Count: 3 | $117.00
Transportation | Count: 1 | $45.00
Entertainment | Count: 1 | $30.00

All operations completed successfully!

If you see this output, your complete Budget Tracker is working.

Function Reference (Every Lesson Mapped)

FunctionPurposeLesson
init_database()Creates tables from modelsL2
seed_data()Adds sample data for testingL3
create_expense()CRUD Create with error handlingL3, L5
read_expenses()CRUD Read with filteringL3
update_expense()CRUD Update with validationL3
delete_expense()CRUD Delete safelyL3
get_monthly_summary()Complex join + aggregationL4
get_expenses_by_category()Grouping with relationshipsL4
get_top_expenses()Sorting + limitingL3
transfer_budget()Multi-step atomic transactionL5
test_connection()Verify Neon worksL6

Testing Your Understanding

Add your own expense:

with Session(engine) as session:
# Create a new user
new_user = User(email='me@example.com', name='My Name')
session.add(new_user)
session.commit()
print(f"Created user id: {new_user.id}")

Output:

Created user id: 2

Add an expense for your user:

result = create_expense(
user_id=2,
description='Coffee',
amount=4.50,
category_id=1 # Food
)
print(result)

Output:

{'success': True, 'id': 6}

Query your expenses:

my_expenses = read_expenses(user_id=2)
for e in my_expenses:
print(f"${e.amount:.2f}: {e.description}")

Output:

$4.50: Coffee

Working With AI on Your Budget Tracker

At this point, you've moved from "learning" to "building." When you encounter issues, you and AI work as collaborators.

Your question: "My transfer_budget() function returns {'success': True} but the expenses don't appear. What's wrong?"

AI suggests: "Check if the session is committing correctly. Add some debug output:

print(f'From expense id: {from_expense.id}')
print(f'To expense id: {to_expense.id}')

Also verify the category IDs exist in your database."

Your refinement: "The IDs are None before commit. But after commit they should have values, right?"

AI adapts: "Correct. If they're still None after session.commit(), the transaction may have rolled back silently. Check if there's an exception being swallowed. Try wrapping in explicit try/except and print any exception."

What emerged: A debugging approach that traced the transaction lifecycle, identifying that the issue was likely in error handling, not the transaction logic itself.

Try With AI

Prompt 1: Understand the Code

What you're learning: Reading production code and explaining what each part does.

Read the complete budget-tracker-complete.py code.

Answer these questions:
1. What does transfer_budget() do? Why does it create TWO expenses?
2. Why does it use try/except? What happens if an error occurs?
3. What happens if you try to transfer from a category that doesn't exist?
4. How many expenses can a single user have? (Hint: look at the relationship)
5. How would you add a "notes" field to expenses?

For each answer, point to the specific line of code that proves your answer.

After AI explains, verify: Can you trace through transfer_budget() line by line and explain what each line does?

Prompt 2: Run and Verify

What you're learning: Executing real applications and verifying they work.

Help me run the Budget Tracker application:

1. I have my .env file with DATABASE_URL from L6
2. Walk me through:
- Installing dependencies (pip or uv)
- Running python budget-tracker-complete.py
- Verifying the output matches expected
- Checking Neon dashboard for the data

If I get errors:
- Connection failed? → Check DATABASE_URL format
- No module 'psycopg2'? → Install psycopg2-binary
- Import error? → Install sqlalchemy

Report back with the exact output you see.

After completing, verify: Do you see "All operations completed successfully!" in your terminal?

Prompt 3: Finalize Your Skill

What you're learning: Documenting mastery as a reusable skill.

My /database-deployment skill has grown throughout this chapter. Help me finalize it.

Add these sections:
1. **Complete Example**: budget-tracker-complete.py as reference
2. **Decision Guide**:
- When to use models (defining structure)
- When to use relationships (connected data)
- When transactions are critical (data integrity)
- When to use pooling (production deployments)
3. **Troubleshooting**: Common errors I learned to fix
4. **Chapter Checklist**: All the skills I mastered

End with: "This skill is production-ready for any SQLAlchemy + Neon project."

After AI responds, verify: Could you use this skill to build a completely different database application (not Budget Tracker)?

Checkpoint: Chapter Complete

Before finishing this chapter, verify your mastery:

  • I understand every function in budget-tracker-complete.py
  • I can run the app on Neon (or explain exactly where I got stuck)
  • I can add a new function (e.g., get_spending_by_month_chart())
  • I can modify a model (e.g., add notes field to Expense)
  • My /database-deployment skill is finalized with complete example
  • I can explain why transactions matter (atomic = all or nothing)
  • I can explain why pooling matters (connection reuse, cloud limits)

You've built something real. This Budget Tracker works. You can use it to track your own spending. You can extend it with a web interface. You can share it with friends.

More importantly, you've built a reusable skill. Every database application you build from now on follows this same pattern: models, sessions, CRUD, relationships, transactions, cloud deployment. This skill is now part of your permanent toolkit.

Next up: The Chapter Quiz to test your mastery of everything you've learned — from models and CRUD to hybrid verification patterns.