πŸ“ Tutorials
Β· 5 min read

Build an AI Expense Tracker That Reads Your Bank CSV Files


Bank statements are deeply personal data. Sending them to a cloud API for categorization feels wrong β€” and depending on where you live, it might violate GDPR or local privacy regulations. In this tutorial, we’ll build a Python tool that reads your bank’s CSV export, uses a local LLM through Ollama to categorize every transaction, and generates a clean markdown spending report. Your financial data never leaves your machine.

What We’re Building

A command-line Python tool that:

  1. Parses a bank CSV export (date, description, amount)
  2. Sends each transaction description to Ollama for AI-powered categorization
  3. Groups spending by category with totals
  4. Outputs a formatted markdown report you can open anywhere

The whole thing runs in a single file β€” no frameworks, no databases, no cloud accounts.

Prerequisites

  • Python 3.10+
  • Ollama installed and running locally (full setup guide here)
  • A model pulled β€” llama3.1:8b or mistral both work well for this task

Pull a model if you haven’t already:

ollama pull llama3.1:8b

Install the one Python dependency we need:

pip install requests

Preparing a Sample CSV

Most banks export CSVs with at least a date, description, and amount. Create a file called transactions.csv to test with:

date,description,amount
2026-06-01,SPOTIFY PREMIUM,-9.99
2026-06-01,SHELL STATION 4412,-52.30
2026-06-02,WHOLE FOODS MARKET,-87.45
2026-06-03,UBER TRIP 8A3F,-14.20
2026-06-03,NETFLIX.COM,-15.99
2026-06-04,AMAZON PRIME MEMBERSHIP,-14.99
2026-06-05,TRADER JOES 0192,-63.10
2026-06-06,CITY METRO TRANSIT,-2.75
2026-06-07,ELECTRIC COMPANY BILL,-142.00
2026-06-08,STARBUCKS 9921,-6.40
2026-06-09,PLANET FITNESS MONTHLY,-24.99
2026-06-10,WALGREENS PHARMACY,-33.50

If your bank uses different column names, you’ll just tweak the field mapping in the code below.

The Complete Code

Create expense_tracker.py:

import csv
import json
import sys
from collections import defaultdict
from datetime import datetime

import requests

OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "llama3.1:8b"

CATEGORIES = [
    "Groceries", "Transport", "Subscriptions", "Utilities",
    "Dining", "Health", "Shopping", "Entertainment", "Other"
]

def categorize(description: str) -> str:
    prompt = (
        f"Categorize this bank transaction into exactly one category.\n"
        f"Categories: {', '.join(CATEGORIES)}\n"
        f"Transaction: {description}\n"
        f"Respond with ONLY the category name, nothing else."
    )
    try:
        resp = requests.post(OLLAMA_URL, json={
            "model": MODEL,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0}
        }, timeout=30)
        result = resp.json()["response"].strip()
        # Validate against known categories
        for cat in CATEGORIES:
            if cat.lower() in result.lower():
                return cat
        return "Other"
    except Exception as e:
        print(f"  Warning: LLM error for '{description}': {e}")
        return "Other"

def read_transactions(path: str) -> list[dict]:
    transactions = []
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            transactions.append({
                "date": row["date"],
                "description": row["description"],
                "amount": float(row["amount"])
            })
    return transactions

def generate_report(categorized: list[dict]) -> str:
    totals = defaultdict(float)
    grouped = defaultdict(list)
    for t in categorized:
        totals[t["category"]] += abs(t["amount"])
        grouped[t["category"]].append(t)

    grand_total = sum(totals.values())
    lines = [
        f"# Expense Report",
        f"",
        f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
        f"",
        f"## Summary",
        f"",
        f"| Category | Total | % of Spending |",
        f"|----------|------:|:-------------:|",
    ]
    for cat in sorted(totals, key=totals.get, reverse=True):
        pct = (totals[cat] / grand_total) * 100
        lines.append(f"| {cat} | ${totals[cat]:.2f} | {pct:.1f}% |")
    lines.append(f"| **Total** | **${grand_total:.2f}** | **100%** |")

    lines += ["", "## Transactions by Category", ""]
    for cat in sorted(grouped):
        lines.append(f"### {cat}")
        lines.append("")
        lines.append("| Date | Description | Amount |")
        lines.append("|------|-------------|-------:|")
        for t in sorted(grouped[cat], key=lambda x: x["date"]):
            lines.append(f"| {t['date']} | {t['description']} | ${abs(t['amount']):.2f} |")
        lines.append("")

    return "\n".join(lines)

def main():
    csv_path = sys.argv[1] if len(sys.argv) > 1 else "transactions.csv"
    print(f"Reading {csv_path}...")
    transactions = read_transactions(csv_path)
    print(f"Found {len(transactions)} transactions. Categorizing with {MODEL}...\n")

    categorized = []
    for i, t in enumerate(transactions, 1):
        cat = categorize(t["description"])
        t["category"] = cat
        categorized.append(t)
        print(f"  [{i}/{len(transactions)}] {t['description']} β†’ {cat}")

    report = generate_report(categorized)
    output_path = "expense_report.md"
    with open(output_path, "w") as f:
        f.write(report)

    print(f"\nReport saved to {output_path}")

if __name__ == "__main__":
    main()

Run it:

python expense_tracker.py transactions.csv

You’ll see each transaction get categorized in real time:

Reading transactions.csv...
Found 12 transactions. Categorizing with llama3.1:8b...

  [1/12] SPOTIFY PREMIUM β†’ Subscriptions
  [2/12] SHELL STATION 4412 β†’ Transport
  [3/12] WHOLE FOODS MARKET β†’ Groceries
  ...

Report saved to expense_report.md

How the Categorization Works

The key is a tightly constrained prompt. We give the LLM a fixed list of categories and ask it to return only a category name β€” no explanation, no formatting. Setting temperature: 0 makes responses deterministic, so the same transaction always maps to the same category.

The validation loop after the API call is important. LLMs sometimes return extra text like β€œThe category is: Groceries” instead of just β€œGroceries”. By checking if any known category appears in the response, we handle these edge cases gracefully. Anything truly unrecognizable falls back to β€œOther”.

Choosing the Right Model

For a classification task this simple, you don’t need a massive model. Here’s what works well:

  • llama3.1:8b β€” Fast, accurate, good default choice
  • mistral:7b β€” Slightly faster, equally reliable for categorization
  • gemma2:9b β€” Another solid option with good instruction following

Larger models like llama3.1:70b won’t meaningfully improve accuracy here β€” the task is straightforward enough that 7-8B parameter models handle it perfectly. Check our guide to the best local models for more on picking the right model for the job.

Speeding Things Up with Batching

Processing transactions one at a time works but is slow for large exports. You can batch multiple transactions in a single prompt:

def categorize_batch(descriptions: list[str]) -> list[str]:
    numbered = "\n".join(f"{i+1}. {d}" for i, d in enumerate(descriptions))
    prompt = (
        f"Categorize each transaction into exactly one category.\n"
        f"Categories: {', '.join(CATEGORIES)}\n"
        f"Transactions:\n{numbered}\n"
        f"Respond with ONLY a numbered list of category names."
    )
    resp = requests.post(OLLAMA_URL, json={
        "model": MODEL, "prompt": prompt,
        "stream": False, "options": {"temperature": 0}
    }, timeout=60)
    lines = resp.json()["response"].strip().split("\n")
    results = []
    for line in lines:
        matched = "Other"
        for cat in CATEGORIES:
            if cat.lower() in line.lower():
                matched = cat
                break
        results.append(matched)
    return results

This cuts API calls dramatically β€” batches of 10-15 transactions work reliably with 8B models.

Customizing Categories

The CATEGORIES list is yours to edit. Tailor it to your spending patterns:

CATEGORIES = [
    "Groceries", "Transport", "Subscriptions", "Utilities",
    "Dining", "Health", "Shopping", "Entertainment",
    "Rent", "Insurance", "Childcare", "Pets", "Other"
]

The LLM adapts automatically β€” no retraining needed. Just update the list and re-run.

Handling Different CSV Formats

Banks love using different column names. If your export uses Date, Details, and Debit instead, update the read_transactions function:

transactions.append({
    "date": row["Date"],
    "description": row["Details"],
    "amount": -float(row["Debit"]) if row["Debit"] else float(row["Credit"])
})

For more complex data transformations β€” say, pulling from a database instead of CSV β€” our Text-to-SQL tutorial shows how to query structured data with natural language.

Privacy: Why This Matters

Every transaction in your bank statement is a data point about your life β€” where you shop, what you subscribe to, where you eat. Cloud-based expense trackers send all of this to third-party servers. With Ollama running locally, the data stays on your hardware. Period. No API keys, no terms of service, no data retention policies to worry about. Read more about why self-hosted AI matters for privacy.

What to Build Next

This tracker is a starting point. Some ideas to extend it:

  • Trend analysis β€” compare spending across months
  • Budget alerts β€” flag categories that exceed a threshold
  • Receipt matching β€” pair transactions with scanned receipts using a vision model
  • Interactive dashboard β€” pipe the JSON data into a simple web UI

The core pattern β€” CSV in, LLM categorization, structured output β€” applies to far more than expenses. Categorizing support tickets, sorting email, tagging inventory β€” same approach, different prompt.

That’s it. A private, local expense tracker in under 100 lines of Python. Your bank data stays yours.

πŸ“˜