Categorize Bank CSV Exports Into Budgets Automatically with Groq Function Calling
What We’re Building
You have a raw bank CSV export full of cryptic descriptions like “SQ *FROTHY MONKEY NASHVILLE TN” or “ACH CREDIT PAYROLL”. You need to know, instantly, how much went to dining, income, or software subscriptions. We’re building a zero-cost Python script that does exactly that.
Feature List:
- Ingests a typical bank CSV (date, description, amount columns)
- Uses Groq Cloud’s free tier and Mixtral 8x7B tool-use to categorize every transaction with a structured function call
- Outputs a clean, aggregated budget summary: total per category, transaction count, and average amount
- Runs entirely locally except for the single API call—your financial data never touches a persistent remote database
- Extensible to any CSV format with a simple column-mapping config
This is the kind of tool an FDE ships in an afternoon to unblock a finance team drowning in spreadsheets. Let’s build it.
Architecture Overview
The pipeline is linear, but the function-calling step is where the magic happens. Here’s the flow:
We’re not streaming transactions one-by-one. That would burn through rate limits. Instead, we batch 25–30 transactions per API call and use Groq’s tool-use mode to force the model to return a strict JSON array of categorized objects. No parsing free-text. No regex hell.
Prerequisites
Everything here is free-tier or open-source. No credit card required to start.
- Python 3.10+ – python.org/downloads
- Groq Cloud API Key – Sign up at console.groq.com. Free tier gives you generous requests per minute on Mixtral and Llama models.
- Pandas –
pip install pandas - Groq Python SDK –
pip install groq - A bank CSV export – Any bank’s export works. We’ll assume columns:
Date,Description,Amount. You’ll map yours in the config.
Step 1: Project Setup and Dependencies
Create a project directory and a virtual environment:
mkdir budget-categorizer && cd budget-categorizer
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install pandas groq
Set your API key as an environment variable (never hardcode it):
export GROQ_API_KEY="gsk_your_key_here"
Create a single file: categorize.py. All logic lives here.
Step 2: Parsing the Bank CSV
Bank CSVs are messy. Headers vary, date formats vary, and amounts might be in a single “Amount” column or split into “Debit”/“Credit”. We’ll write a flexible parser that normalizes everything into a standard schema.
import pandas as pd
from datetime import datetime
def parse_bank_csv(filepath: str, date_col: str, desc_col: str, amount_col: str, debit_col: str = None, credit_col: str = None, date_format: str = None) -> pd.DataFrame:
df = pd.read_csv(filepath)
# Normalize date
if date_format:
df['date'] = pd.to_datetime(df[date_col], format=date_format)
else:
df['date'] = pd.to_datetime(df[date_col], infer_datetime_format=True)
# Normalize description
df['description'] = df[desc_col].astype(str).str.strip()
# Normalize amount: single column or debit/credit split
if debit_col and credit_col:
df['amount'] = df[credit_col].fillna(0) - df[debit_col].fillna(0)
else:
df['amount'] = pd.to_numeric(df[amount_col], errors='coerce')
# Drop rows with missing critical fields
df = df.dropna(subset=['date', 'description', 'amount'])
return df[['date', 'description', 'amount']]
Real-world example: Chase exports use “Posting Date”, “Description”, and “Amount” (negative for debits). A single config mapping handles it.
Step 3: Defining the Groq Tool Schema
This is the core of the build. We’re not asking the model to chat. We’re giving it a function signature and forcing it to call that function with structured arguments. Groq’s tool-use mode guarantees the response matches our schema.
We define a single tool: categorize_transactions. It takes an array of transaction objects and returns an array of categorized transaction objects.
CATEGORIZE_TOOL = {
"type": "function",
"function": {
"name": "categorize_transactions",
"description": "Categorize a batch of bank transactions into predefined budget categories.",
"parameters": {
"type": "object",
"properties": {
"transactions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {"type": "integer", "description": "Original index of the transaction in the batch"},
"category": {
"type": "string",
"enum": [
"Income",
"Housing",
"Utilities",
"Groceries",
"Dining Out",
"Transportation",
"Healthcare",
"Entertainment",
"Shopping",
"Software & Subscriptions",
"Travel",
"Transfer",
"Uncategorized"
]
},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"note": {"type": "string", "description": "Brief reasoning for the category choice"}
},
"required": ["index", "category", "confidence"]
}
}
},
"required": ["transactions"]
}
}
}
Why an enum? It constrains the model to your budget categories. No “Food & Drink” vs “Restaurants” inconsistency. You define the taxonomy once.
Step 4: Calling Groq with Function Calling
We batch transactions into chunks of 25 to stay well within token limits and rate limits. Each chunk is sent as a tool-use request. The model must respond with a function call containing the categorized array.
from groq import Groq
import json
import os
def categorize_batch(descriptions: list[str], amounts: list[float], indices: list[int]) -> list[dict]:
client = Groq(api_key=os.environ["GROQ_API_KEY"])
# Build the user message: a compact JSON of the batch
batch_input = []
for i, desc, amt in zip(indices, descriptions, amounts):
batch_input.append({"index": i, "description": desc, "amount": amt})
messages = [
{
"role": "system",
"content": "You are a financial transaction categorizer. You MUST call the categorize_transactions function with every transaction in the batch. Use the description and amount to infer the correct category. Be precise: payroll ACH credits are Income, Uber rides are Transportation, Netflix is Software & Subscriptions. Do not skip any transaction."
},
{
"role": "user",
"content": json.dumps(batch_input)
}
]
response = client.chat.completions.create(
model="mixtral-8x7b-32768",
messages=messages,
tools=[CATEGORIZE_TOOL],
tool_choice={"type": "function", "function": {"name": "categorize_transactions"}},
temperature=0.1 # Low temp for consistency
)
# Extract the function call arguments
tool_call = response.choices[0].message.tool_calls[0]
categorized = json.loads(tool_call.function.arguments)["transactions"]
return categorized
Key details:
tool_choiceis forced to our function. The model cannot respond with plain text.temperature=0.1keeps categorizations deterministic. You want the same result every run.- We pass the original index so we can join results back to the DataFrame later.
Step 5: Aggregating the Budget Summary
Once all batches are categorized, we merge results back with the original data and compute the summary.
def aggregate_budget(df: pd.DataFrame, categorizations: list[dict]) -> pd.DataFrame:
# Create a lookup from index to category
cat_map = {c['index']: c for c in categorizations}
# Apply categories to the original dataframe
df['category'] = df.index.map(lambda i: cat_map.get(i, {}).get('category', 'Uncategorized'))
df['confidence'] = df.index.map(lambda i: cat_map.get(i, {}).get('confidence', 0.0))
# Aggregate
summary = df.groupby('category').agg(
total_amount=('amount', 'sum'),
transaction_count=('amount', 'count'),
avg_amount=('amount', 'mean'),
avg_confidence=('confidence', 'mean')
).round(2)
# Sort by absolute total (largest impact first)
summary['abs_total'] = summary['total_amount'].abs()
summary = summary.sort_values('abs_total', ascending=False).drop(columns=['abs_total'])
return summary
Step 6: Running the Full Pipeline
Now we wire everything together with a main function that handles batching and progress logging.
def main(filepath: str, date_col: str, desc_col: str, amount_col: str, debit_col: str = None, credit_col: str = None, batch_size: int = 25):
# Parse
df = parse_bank_csv(filepath, date_col, desc_col, amount_col, debit_col, credit_col)
print(f"Parsed {len(df)} transactions.")
# Batch and categorize
all_categorizations = []
for start in range(0, len(df), batch_size):
end = min(start + batch_size, len(df))
batch_df = df.iloc[start:end]
indices = batch_df.index.tolist()
descriptions = batch_df['description'].tolist()
amounts = batch_df['amount'].tolist()
print(f"Categorizing batch {start//batch_size + 1} ({start}-{end-1})...")
batch_results = categorize_batch(descriptions, amounts, indices)
all_categorizations.extend(batch_results)
# Aggregate
summary = aggregate_budget(df, all_categorizations)
print("\n=== BUDGET SUMMARY ===")
print(summary.to_string())
# Optionally save to CSV
summary.to_csv("budget_summary.csv")
df.to_csv("categorized_transactions.csv", index=False)
print("\nSaved budget_summary.csv and categorized_transactions.csv")
if __name__ == "__main__":
# Example: Chase CSV with columns "Posting Date", "Description", "Amount"
main(
filepath="chase_export.csv",
date_col="Posting Date",
desc_col="Description",
amount_col="Amount"
)
Run it:
python categorize.py
You’ll see progress per batch and then a clean terminal table:
=== BUDGET SUMMARY ===
total_amount transaction_count avg_amount avg_confidence
category
Income 8500.00 2 4250.00 0.98
Housing -2200.00 1 -2200.00 0.99
Dining Out -342.18 12 -28.52 0.91
Groceries -567.40 4 -141.85 0.94
Software & Subscriptions -45.99 3 -15.33 0.97
Extensions and Next Steps
This script is a foundation. Here’s where you take it next:
- Add a config file: A
config.yamlthat maps column names, batch size, and custom categories. No more hardcoding. - Slack/Email digest: Run it weekly via cron, pipe the summary DataFrame into a formatted Slack message or email.
- Confidence threshold flagging: Append a
needs_reviewcolumn for anything under 0.8 confidence. A human can spot-check those 5% of transactions. - Historical trend analysis: Store each run’s summary in a SQLite database. Query month-over-month changes in discretionary spending.
- Multi-account support: Point it at a directory of CSVs from different banks. Unify into one household budget.
If you’re thinking about building more AI-powered internal tools like this, our breakdown of what an FDE ships in a week shows the pattern: small, composable scripts that chain LLMs into existing workflows.
Common Pitfalls
- Token limit exceeded. Mixtral’s context is 32k tokens. A batch of 25 transactions with long descriptions is safe. If your bank has verbose memos, drop to 15 per batch.
- Rate limiting. Groq free tier is generous but not infinite. Add a
time.sleep(1)between batches if you hit 429 errors. - Inconsistent categories. The enum prevents most drift, but model updates can shift behavior. Pin your model version if this becomes a production tool.
- Negative amount confusion. Some banks use negative for debits, positive for credits. Our parser normalizes this, but verify your export’s convention. A quick
df.head()after parsing catches this. - CSV encoding. Banks love Latin-1 or Windows-1252 encodings. If
pd.read_csvchokes, addencoding='latin1'orencoding='cp1252'.
FAQ
Q: Does Groq store my transaction data? A: No. Groq’s API does not persist prompts or responses. Your data is in-flight only. For extra paranoia, you can strip account numbers from descriptions before sending.
Q: Can I use this with Llama 3 instead of Mixtral?
A: Yes. Switch the model string to llama3-70b-8192 or llama3-8b-8192. The 8B model is faster but slightly less accurate on edge-case descriptions. Test with your data.
Q: What if a transaction legitimately fits two categories?
A: The current schema picks one. For split transactions (e.g., a Costco run with groceries and electronics), you’d extend the schema to allow an array of {category, amount} per transaction. That’s a v2 feature.
Q: How do I handle transfers between my own accounts? A: The “Transfer” category in our enum catches those. The model is surprisingly good at identifying “Transfer to Savings” or “Online Banking Payment” patterns.
Q: What’s the total cost? A: Zero. Groq’s free tier covers thousands of transactions per day. Mixtral on Groq is also extremely fast—a 25-transaction batch categorizes in under 2 seconds.
If you’re looking to chain this into a larger automation—like a competitor monitor that alerts on meaningful changes or a resume tailoring agent—the pattern is identical: free LLM + structured output + a thin Python wrapper. Ship it.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program