AI-Powered Transaction Categorizer

Use prompt engineering to build a transaction categorizer with structured JSON output.

Introduction

30 Second Summary

Ever looked at your bank statement and wished someone would sort through all those cryptic transaction descriptions for you? Between "AMZ*AMZN.COM" and "SHELL OIL 57442", figuring out where your money actually goes takes real effort.

In this project, you will build a Python module that uses the Google Gemini API to automatically categorize financial transactions, analyze spending patterns, and generate savings recommendations.

What You'll Build

A backend module for a personal finance app that transforms raw bank transactions into categorized, structured spending data using prompt engineering techniques.

By the end of this project, you'll have:

  • A system prompt with few-shot examples that reliably categorizes transactions into spending categories.
  • Type-safe structured JSON output validated with Pydantic models that your app can consume programmatically.
  • A prompt chain that categorizes transactions, summarizes spending, and generates personalized savings recommendations.
  • Secret Mission: Build a prompt evaluation harness that scores your categorizer's accuracy against a labeled test set.

Are there any prerequisites?

You should be comfortable with Python basics (variables, functions, lists, dictionaries) and have a general familiarity with JSON structure. No prior AI or machine learning experience is needed.

Set Up Your Project

Your Gemini API connection is live and ready. Now it's time to define the data structures that will shape every AI response in your application.

When a language model returns JSON, there's no guarantee the response matches what your code expects. Pydantic models solve this by defining strict schemas that validate the AI's output before your application touches it.

In this step, get ready to:
  • Define Pydantic models for categorized transactions.
  • Define Pydantic models for spending analysis output.
Define your transaction models

Your categorizer will take raw bank transactions and return them with a spending category and confidence level attached. You need models that describe this exact structure so your app can trust the AI's responses.

  • Create a new file called models.py in the same folder as test_connection.py.
  • Add the following code to define your transaction data models:
from pydantic import BaseModel, Field
from typing import List, Literal


# Define the valid spending categories
CategoryType = Literal[
    "housing", "food_dining", "transportation", "utilities",
    "entertainment", "shopping", "health", "income", "transfer", "other"
]


# A single categorized transaction
class CategorizedTransaction(BaseModel):
    description: str = Field(description="The original transaction description")
    amount: float = Field(description="The transaction amount")
    category: CategoryType = Field(description="The spending category")
    confidence: Literal["high", "medium", "low"] = Field(
        description="How confident the categorization is"
    )


# A batch of categorized transactions
class CategorizedBatch(BaseModel):
    transactions: List[CategorizedTransaction]

What does this code do?

CategoryType uses Python's Literal type to restrict categories to exactly the 10 values your app supports. If the AI returns a category not in this list, Pydantic will reject it.

CategorizedTransaction defines what one categorized transaction looks like. Each field has a type and a Field description that documents its purpose.

CategorizedBatch wraps a list of transactions into a single response object. This matches the JSON structure you'll ask Gemini to return.

  • Save models.py.
  • Verify the models load by running this command in your terminal:
python -c "from models import CategorizedTransaction, CategorizedBatch; print('Transaction models loaded')"

You should see Transaction models loaded printed in the terminal.

Seeing a ModuleNotFoundError?

If the error says No module named 'pydantic', reinstall the google-genai SDK by running pip install -U google-genai. Pydantic is included as a dependency of the SDK, so reinstalling should resolve this.

If the error mentions a syntax issue, double-check that each Field(description="...") has matching quotes and parentheses.

Why can't Python find my models?

Add your analysis models

Your pipeline will chain a second prompt that takes the categorized data and produces a spending summary with insights. These models define the shape of that analysis output.

  • In models.py, add the following code below the CategorizedBatch class:
# How much was spent in a single category
class CategorySpend(BaseModel):
    category: str = Field(description="The spending category name")
    total: float = Field(description="Total amount spent in this category")


# The full spending analysis output
class SpendingSummary(BaseModel):
    total_spent: float = Field(description="Total amount spent in the period")
    top_categories: List[CategorySpend] = Field(
        description="Top spending categories ranked by total amount"
    )
    insights: List[str] = Field(description="Key insights about spending patterns")
    recommendations: List[str] = Field(
        description="Actionable savings recommendations"
    )

What do these models do?

CategorySpend pairs a category name with its total spend. This powers the "top categories" breakdown in your analysis.

SpendingSummary is the full output of your spending analyzer. It includes the total amount spent, a ranked list of categories, plain-language insights, and actionable savings recommendations.

  • Save models.py.
  • Verify all models load by running:
python -c "from models import CategorizedBatch, SpendingSummary; print('All models loaded')"

You should see All models loaded printed in the terminal.

Seeing a NameError or IndentationError?

A NameError for BaseModel or Field means the new classes were pasted above the import lines at the top of the file. Make sure the from pydantic import BaseModel, Field line stays at the very top.

An IndentationError usually means the new code was accidentally pasted inside the CategorizedBatch class. Each new class keyword should start at the leftmost column with no indent.

Help me fix my models.py errors.

✔️ Awesome, I've got everything!

Great. Double check that you've saved models.py and the import command prints All models loaded with no errors.

ⓧ I'd like to double check the full code

Here's the complete models.py file for comparison:

from pydantic import BaseModel, Field
from typing import List, Literal


CategoryType = Literal[
    "housing", "food_dining", "transportation", "utilities",
    "entertainment", "shopping", "health", "income", "transfer", "other"
]


class CategorizedTransaction(BaseModel):
    description: str = Field(description="The original transaction description")
    amount: float = Field(description="The transaction amount")
    category: CategoryType = Field(description="The spending category")
    confidence: Literal["high", "medium", "low"] = Field(
        description="How confident the categorization is"
    )


class CategorizedBatch(BaseModel):
    transactions: List[CategorizedTransaction]


class CategorySpend(BaseModel):
    category: str = Field(description="The spending category name")
    total: float = Field(description="Total amount spent in this category")


class SpendingSummary(BaseModel):
    total_spent: float = Field(description="Total amount spent in the period")
    top_categories: List[CategorySpend] = Field(
        description="Top spending categories ranked by total amount"
    )
    insights: List[str] = Field(description="Key insights about spending patterns")
    recommendations: List[str] = Field(
        description="Actionable savings recommendations"
    )

Your data contracts are locked in. Next up, you'll write a system prompt that tells Gemini how to categorize transactions into these exact structures.

Design Your System Prompt

Your Gemini connection is working. Now you need to tell the AI exactly what job to do.

A system prompt is a set of instructions that defines an AI's role, rules, and output expectations. Think of it as a job description. The clearer your system prompt, the more consistent and reliable the AI's responses become.

In this step, get ready to:
  • Write a system prompt that defines the AI as a financial transaction categorizer.
  • Create sample transactions and send them to Gemini for categorization.
  • Examine your prompt's design choices and understand how to iterate.
Define the categorizer role

The system prompt is the foundation of your categorizer. It needs to establish the AI's role, list the valid categories, define rules for handling edge cases, and specify the exact output format.

  • In VS Code, create a new file called categorizer.py in the same folder as models.py.
  • Add the imports, client setup, and system prompt by pasting this code:
import json
from google import genai
from google.genai import types
from models import CategorizedBatch

client = genai.Client()

SYSTEM_PROMPT = """You are a financial transaction categorizer for a personal finance app.

Your role is to analyze bank transaction descriptions and categorize them accurately.

Rules:
- Always assign exactly one category per transaction
- Use context clues in merchant names to determine category
- If a transaction is ambiguous, choose the most likely category and set confidence to "medium" or "low"
- Never invent or modify the transaction description or amount

Categories:
- housing: Rent, mortgage, home insurance, property tax
- food_dining: Groceries, restaurants, cafes, food delivery
- transportation: Gas, public transit, ride-sharing, car payments
- utilities: Electric, water, internet, phone bills
- entertainment: Streaming services, concerts, movies, games
- shopping: Clothing, electronics, general retail
- health: Doctor visits, pharmacy, gym membership
- income: Salary, freelance payments, refunds
- transfer: Bank transfers, peer-to-peer payments
- other: Anything that doesn't fit above

Output format: Return a JSON object with a "transactions" array. Each item must have: description (string), amount (number), category (one of the listed categories), confidence (high/medium/low)."""

What does this system prompt do?

The prompt has four sections, each with a specific job:

  • Role establishes the AI as a financial transaction categorizer, not a general-purpose assistant.
  • Rules define constraints like "exactly one category" and "never invent data" so the model behaves predictably.
  • Categories list the 10 valid options with examples so the model knows what each one means.
  • Output format tells the model exactly what shape the JSON response should take.
  • Save categorizer.py.

You won't see any output yet. The next chunk adds test data so you can run the categorizer.

Test with sample transactions

To see how well your system prompt works, you need realistic test data. A mix of obvious transactions (like rent payments) and ambiguous ones (like a pharmacy purchase) reveals how the model handles edge cases.

  • Add this list of sample transactions below your SYSTEM_PROMPT in categorizer.py:
SAMPLE_TRANSACTIONS = [
    {"description": "SPOTIFY USA", "amount": -9.99},
    {"description": "TRADER JOE'S #123", "amount": -45.67},
    {"description": "SHELL OIL 57442", "amount": -52.30},
    {"description": "VERIZON WIRELESS", "amount": -85.00},
    {"description": "DELTA AIR LINES", "amount": -342.00},
    {"description": "CVS PHARMACY #8890", "amount": -12.45},
    {"description": "VENMO PAYMENT RECEIVED", "amount": 150.00},
    {"description": "ZILLOW RENT PAYMENT", "amount": -1800.00},
]

What does this test data cover?

These 8 transactions span multiple categories and include tricky cases. DELTA AIR LINES could be transportation or entertainment (vacation). CVS PHARMACY could be health or shopping. VENMO PAYMENT RECEIVED is a positive amount, testing whether the model handles income correctly.

Negative amounts represent money spent. Positive amounts represent money received.

  • Save categorizer.py.

You won't see a change yet. The next chunk adds the function that sends these transactions to Gemini.

Now you need a function that formats the transactions, sends them to Gemini with the system prompt, and validates the response against your Pydantic models.

  • Add the categorize_transactions() function and the main block below SAMPLE_TRANSACTIONS in categorizer.py:

def categorize_transactions(transactions: list[dict]) -> CategorizedBatch:
    """Categorize a batch of transactions using the Gemini API."""

    # Format each transaction as a labeled string for the prompt
    transactions_text = "\n".join(
        f'Transaction: "{t["description"]}" amount: {t["amount"]}'
        for t in transactions
    )

    # Build the user prompt with the transactions to categorize
    prompt = f"""Now categorize the following transactions:

{transactions_text}"""

    # Send to Gemini with system prompt and JSON mode enabled
    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )

    # Validate the response against our Pydantic model
    try:
        result = CategorizedBatch.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result


if __name__ == "__main__":
    print("Categorizing transactions...\n")
    result = categorize_transactions(SAMPLE_TRANSACTIONS)

    for tx in result.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )

What does this code do?

  • transactions_text formats each transaction into a labeled string the model can parse easily.
  • system_instruction=SYSTEM_PROMPT passes your system prompt separately from the user message, so Gemini treats it as persistent instructions.
  • response_mime_type="application/json" tells Gemini to only return valid JSON, reducing the chance of extra text or formatting.
  • temperature=0.1 keeps responses highly deterministic. Low temperature means less randomness, which is what you want for categorization.
  • CategorizedBatch.model_validate_json() parses the JSON response and checks it matches your Pydantic schema. If the model returns unexpected data, validation catches it immediately.
  • Save categorizer.py.
  • Run the categorizer by entering this command in your terminal:
python categorizer.py

You should see each transaction printed with its assigned category and confidence level, formatted in a neat table.

Seeing an error?

  • If you see a module not found error for models, make sure models.py is in the same folder as categorizer.py.
  • If you see a validation error, the model may have returned unexpected JSON. Check the raw response printed below the error to see what Gemini actually returned.
  • If you see an API key error, confirm your GEMINI_API_KEY environment variable is still set in your current terminal session.

Help me debug my categorizer.py error

Understand prompt iteration

Look at the output from your categorizer. Each transaction should have a category and a confidence level. The quality of these results comes directly from the design choices in your system prompt.

Why does prompt design matter?

Your system prompt already contains several iteration-driven improvements that make it reliable:

  • "Never invent or modify the transaction description or amount" prevents the model from hallucinating data. Without this, models sometimes "clean up" descriptions or round amounts.
  • "Return a JSON object with a transactions array" gives a concrete output shape. Vague instructions like "return the results" lead to inconsistent formatting.
  • Confidence levels let you handle uncertainty programmatically. Your app can flag low-confidence items for manual review instead of blindly trusting every categorization.
  • Category descriptions with examples reduce ambiguity. Without examples, the model guesses what "food_dining" includes.

Build your iteration habit

When iterating on prompts, change one thing at a time and re-run. If you change the categories AND the rules AND the format simultaneously, you cannot tell which change improved (or broke) the output.

✔️ Awesome, I've got everything!

Great. Double check you've saved categorizer.py and that running it produces categorized output.

ⓧ I'd like to double check the full code

Here is the complete categorizer.py file at this point:

import json
from google import genai
from google.genai import types
from models import CategorizedBatch

client = genai.Client()

SYSTEM_PROMPT = """You are a financial transaction categorizer for a personal finance app.

Your role is to analyze bank transaction descriptions and categorize them accurately.

Rules:
- Always assign exactly one category per transaction
- Use context clues in merchant names to determine category
- If a transaction is ambiguous, choose the most likely category and set confidence to "medium" or "low"
- Never invent or modify the transaction description or amount

Categories:
- housing: Rent, mortgage, home insurance, property tax
- food_dining: Groceries, restaurants, cafes, food delivery
- transportation: Gas, public transit, ride-sharing, car payments
- utilities: Electric, water, internet, phone bills
- entertainment: Streaming services, concerts, movies, games
- shopping: Clothing, electronics, general retail
- health: Doctor visits, pharmacy, gym membership
- income: Salary, freelance payments, refunds
- transfer: Bank transfers, peer-to-peer payments
- other: Anything that doesn't fit above

Output format: Return a JSON object with a "transactions" array. Each item must have: description (string), amount (number), category (one of the listed categories), confidence (high/medium/low)."""

SAMPLE_TRANSACTIONS = [
    {"description": "SPOTIFY USA", "amount": -9.99},
    {"description": "TRADER JOE'S #123", "amount": -45.67},
    {"description": "SHELL OIL 57442", "amount": -52.30},
    {"description": "VERIZON WIRELESS", "amount": -85.00},
    {"description": "DELTA AIR LINES", "amount": -342.00},
    {"description": "CVS PHARMACY #8890", "amount": -12.45},
    {"description": "VENMO PAYMENT RECEIVED", "amount": 150.00},
    {"description": "ZILLOW RENT PAYMENT", "amount": -1800.00},
]


def categorize_transactions(transactions: list[dict]) -> CategorizedBatch:
    """Categorize a batch of transactions using the Gemini API."""

    transactions_text = "\n".join(
        f'Transaction: "{t["description"]}" amount: {t["amount"]}'
        for t in transactions
    )

    prompt = f"""Now categorize the following transactions:

{transactions_text}"""

    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )

    try:
        result = CategorizedBatch.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result


if __name__ == "__main__":
    print("Categorizing transactions...\n")
    result = categorize_transactions(SAMPLE_TRANSACTIONS)

    for tx in result.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )

Your system prompt is turning raw transaction strings into structured, categorized data. Next up, you will add few-shot examples that teach the model by showing it correct categorizations.

Add Few-Shot Examples

Your system prompt gives the model clear rules and categories, but it relies on the model's own judgment for ambiguous transactions.

Few-shot prompting solves this by showing the model concrete examples of correct categorizations. Instead of hoping it guesses your intent, you demonstrate it.

In this step, get ready to:
  • Write six example transaction-to-category mappings covering different categories.
  • Integrate the examples into your categorizer prompt.
  • Run the categorizer and observe improved consistency on ambiguous transactions.
Create few-shot examples

Few-shot examples teach the model by demonstration. Each example shows a transaction description, an amount, and the correct categorization result.

  • In categorizer.py, find the line that says SAMPLE_TRANSACTIONS = [.
  • Add the following FEW_SHOT_EXAMPLES string directly above it:
FEW_SHOT_EXAMPLES = """Here are examples of correct categorizations:

Transaction: "NETFLIX.COM" amount: -15.99
Result: {"description": "NETFLIX.COM", "amount": -15.99, "category": "entertainment", "confidence": "high"}

Transaction: "WHOLE FOODS MKT #10234" amount: -89.42
Result: {"description": "WHOLE FOODS MKT #10234", "amount": -89.42, "category": "food_dining", "confidence": "high"}

Transaction: "UBER TRIP" amount: -24.50
Result: {"description": "UBER TRIP", "amount": -24.50, "category": "transportation", "confidence": "high"}

Transaction: "TRANSFER FROM SAVINGS" amount: 500.00
Result: {"description": "TRANSFER FROM SAVINGS", "amount": 500.00, "category": "transfer", "confidence": "high"}

Transaction: "PAYROLL DEPOSIT - ACME INC" amount: 3500.00
Result: {"description": "PAYROLL DEPOSIT - ACME INC", "amount": 3500.00, "category": "income", "confidence": "high"}

Transaction: "AMZ*AMZN.COM" amount: -67.23
Result: {"description": "AMZ*AMZN.COM", "amount": -67.23, "category": "shopping", "confidence": "medium"}
"""

What do these examples teach the model?

Each example pairs a transaction with its correct JSON result. Five examples have "confidence": "high" because they're unambiguous merchants.

The last example (AMZ*AMZN.COM) uses "confidence": "medium" because Amazon could be shopping or household supplies. This teaches the model how to handle ambiguity and when to lower its confidence.

You won't see a change yet. The next substep wires these examples into the actual prompt.

Integrate examples into the prompt

The few-shot examples need to appear in the prompt between the system instruction and the actual transactions. This gives the model a reference point before it sees new data.

  • In categorizer.py, scroll down to the categorize_transactions function.
  • Find the prompt variable that currently looks like this:
    prompt = f"""Now categorize the following transactions:

{transactions_text}"""
  • Replace it with this version that prepends FEW_SHOT_EXAMPLES before the transactions:
    prompt = f"""{FEW_SHOT_EXAMPLES}

Now categorize the following transactions:

{transactions_text}"""

Why put examples before the new transactions?

The model reads the prompt top to bottom. By placing examples first, it establishes the expected format and reasoning before encountering the new transactions it needs to categorize. The system instruction (sent separately via system_instruction) sets the role. The examples set the pattern. The new transactions are what it acts on.

  • Save categorizer.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

import json
from google import genai
from google.genai import types
from models import CategorizedBatch

client = genai.Client()

SYSTEM_PROMPT = """You are a financial transaction categorizer for a personal finance app.

Your role is to analyze bank transaction descriptions and categorize them accurately.

Rules:
- Always assign exactly one category per transaction
- Use context clues in merchant names to determine category
- If a transaction is ambiguous, choose the most likely category and set confidence to "medium" or "low"
- Never invent or modify the transaction description or amount

Categories:
- housing: Rent, mortgage, home insurance, property tax
- food_dining: Groceries, restaurants, cafes, food delivery
- transportation: Gas, public transit, ride-sharing, car payments
- utilities: Electric, water, internet, phone bills
- entertainment: Streaming services, concerts, movies, games
- shopping: Clothing, electronics, general retail
- health: Doctor visits, pharmacy, gym membership
- income: Salary, freelance payments, refunds
- transfer: Bank transfers, peer-to-peer payments
- other: Anything that doesn't fit above

Output format: Return a JSON object with a "transactions" array. Each item must have: description (string), amount (number), category (one of the listed categories), confidence (high/medium/low)."""

FEW_SHOT_EXAMPLES = """Here are examples of correct categorizations:

Transaction: "NETFLIX.COM" amount: -15.99
Result: {"description": "NETFLIX.COM", "amount": -15.99, "category": "entertainment", "confidence": "high"}

Transaction: "WHOLE FOODS MKT #10234" amount: -89.42
Result: {"description": "WHOLE FOODS MKT #10234", "amount": -89.42, "category": "food_dining", "confidence": "high"}

Transaction: "UBER TRIP" amount: -24.50
Result: {"description": "UBER TRIP", "amount": -24.50, "category": "transportation", "confidence": "high"}

Transaction: "TRANSFER FROM SAVINGS" amount: 500.00
Result: {"description": "TRANSFER FROM SAVINGS", "amount": 500.00, "category": "transfer", "confidence": "high"}

Transaction: "PAYROLL DEPOSIT - ACME INC" amount: 3500.00
Result: {"description": "PAYROLL DEPOSIT - ACME INC", "amount": 3500.00, "category": "income", "confidence": "high"}

Transaction: "AMZ*AMZN.COM" amount: -67.23
Result: {"description": "AMZ*AMZN.COM", "amount": -67.23, "category": "shopping", "confidence": "medium"}
"""

SAMPLE_TRANSACTIONS = [
    {"description": "SPOTIFY USA", "amount": -9.99},
    {"description": "TRADER JOE'S #123", "amount": -45.67},
    {"description": "SHELL OIL 57442", "amount": -52.30},
    {"description": "VERIZON WIRELESS", "amount": -85.00},
    {"description": "DELTA AIR LINES", "amount": -342.00},
    {"description": "CVS PHARMACY #8890", "amount": -12.45},
    {"description": "VENMO PAYMENT RECEIVED", "amount": 150.00},
    {"description": "ZILLOW RENT PAYMENT", "amount": -1800.00},
]


def categorize_transactions(transactions: list[dict]) -> CategorizedBatch:
    """Categorize a batch of transactions using the Gemini API."""

    transactions_text = "\n".join(
        f'Transaction: "{t["description"]}" amount: {t["amount"]}'
        for t in transactions
    )

    prompt = f"""{FEW_SHOT_EXAMPLES}

Now categorize the following transactions:

{transactions_text}"""

    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )

    try:
        result = CategorizedBatch.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result


if __name__ == "__main__":
    print("Categorizing transactions...\n")
    result = categorize_transactions(SAMPLE_TRANSACTIONS)

    for tx in result.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )
Measure the improvement

Time to run the updated categorizer and see how few-shot examples affect the output.

  • Run the categorizer by executing this command in your terminal:
python categorizer.py

You should see all eight sample transactions printed with their assigned category and confidence level.

What should I look for?

Pay attention to how the model handles ambiguous merchants. Transactions like DELTA AIR LINES (could be transportation or entertainment/travel) and VENMO PAYMENT RECEIVED (could be income or transfer) should now categorize more consistently across multiple runs.

The few-shot examples anchor the model's decisions. Without them, the model might flip between categories on ambiguous transactions. With them, it follows the patterns you demonstrated.

Seeing a validation error?

If the script throws a validation error, check that your FEW_SHOT_EXAMPLES string uses triple quotes and the JSON inside it has proper curly braces.

Also confirm the prompt f-string includes {FEW_SHOT_EXAMPLES} inside the triple-quoted string, not outside it.

Help me debug my categorizer validation error.

Your categorizer now uses two prompt engineering techniques working together: a system prompt for rules and few-shot examples for pattern demonstration. Next up, you'll enforce structured output with Pydantic validation so your app can trust every response it receives.

Enforce Structured Output with Pydantic

Your categorizer now produces accurate results thanks to system prompts and few-shot examples. But there's a problem for production apps: raw text responses from an AI model are unpredictable.

A finance SaaS needs guaranteed JSON structure it can parse programmatically. In this step, you'll explore how your Pydantic models and the response_mime_type config work together to enforce type-safe output from Gemini.

In this step, get ready to:
  • Understand how Pydantic models define the shape of your AI's output.
  • See how response_mime_type forces valid JSON from the model.
  • Run the categorizer and confirm it returns typed, iterable results.
Review your Pydantic models

Your models.py file defines the exact shape that every AI response must conform to. Without these models, you'd be parsing raw text and hoping the format is consistent.

  • Switch back to VS Code and open models.py.
  • Look at the CategorizedTransaction and CategorizedBatch classes. These are the two models that validate your categorizer's output:
CategoryType = Literal[
    "housing", "food_dining", "transportation", "utilities",
    "entertainment", "shopping", "health", "income", "transfer", "other"
]


class CategorizedTransaction(BaseModel):
    description: str = Field(description="The original transaction description")
    amount: float = Field(description="The transaction amount")
    category: CategoryType = Field(description="The spending category")
    confidence: Literal["high", "medium", "low"] = Field(
        description="How confident the categorization is"
    )


class CategorizedBatch(BaseModel):
    transactions: List[CategorizedTransaction]

What do these models do?

Each field has a strict type. category uses a Literal type that only accepts your ten valid category strings. If the model returns "groceries" instead of "food_dining", Pydantic rejects it immediately.

CategorizedBatch wraps a list of transactions. This gives you a single object you can iterate over, pass to other functions, or serialize back to JSON.

Why client-side validation instead of schema enforcement?

Some LLM providers let you pass a JSON schema directly to the API so the model is forced to match it. That works, but validating on your side with Pydantic is a transferable pattern. It works with any LLM provider and catches issues that schema enforcement alone can miss, like a negative confidence score or a missing field.

See how response_mime_type enforces JSON output

Pydantic validates the structure after you receive a response. But you also need the model to actually return JSON in the first place, not a paragraph of prose with JSON embedded somewhere inside it.

  • Open categorizer.py and find the generate_content call inside categorize_transactions(). Look at the config parameter:
    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )

What does response_mime_type do?

Setting response_mime_type="application/json" tells the Gemini API to constrain its output to valid JSON. Without it, the model might wrap its answer in markdown code fences or add explanatory text around the JSON.

The temperature=0.1 keeps output deterministic. For categorization tasks, you want consistency over creativity.

  • Now look at the validation line just below the API call:
    try:
        result = CategorizedBatch.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

How does validation work here?

CategorizedBatch.model_validate_json(response.text) takes the raw JSON string from Gemini and parses it into a typed Python object. If any field is missing, has the wrong type, or contains an invalid category, Pydantic raises a ValidationError with a clear description of what went wrong.

The try/except block catches validation failures and prints the raw response. This is essential for debugging. Without it, you'd see a traceback but have no idea what the model actually returned.

Run the categorizer and verify structured output

Time to see the full pipeline in action. The __main__ block at the bottom of categorizer.py calls categorize_transactions() with the sample transactions and prints each result in a formatted table.

  • Run the categorizer by executing this command in your terminal:
python categorizer.py
  • You should see output showing each transaction with its category and confidence level, formatted like a table. For example, SPOTIFY USA should map to entertainment with high confidence.

What should I see?

You'll see "Categorizing transactions..." followed by 8 rows. Each row shows the merchant name, dollar amount, assigned category, and confidence level. The output is coming from a typed CategorizedBatch object, not raw text. Every value has been validated against your Pydantic schema before printing.

Seeing a validation error?

If the script prints "Validation error" followed by the raw response, check the raw JSON output it logs. Common causes:

  • The model returned a category not in your CategoryType Literal. Check that your system prompt categories match exactly.
  • A field name is slightly different (e.g. "conf" instead of "confidence"). Your system prompt's output format instructions should match your model field names.
  • Rate limit hit. Wait a moment and try again. Free tier limits reset at midnight Pacific time.

Help me debug my Pydantic validation error with the Gemini categorizer

✔️ Awesome, I've got everything!

Great. Double check you've saved both models.py and categorizer.py.

ⓧ I'd like to double check the full code

Here's the complete models.py:

from pydantic import BaseModel, Field
from typing import List, Literal


CategoryType = Literal[
    "housing", "food_dining", "transportation", "utilities",
    "entertainment", "shopping", "health", "income", "transfer", "other"
]


class CategorizedTransaction(BaseModel):
    description: str = Field(description="The original transaction description")
    amount: float = Field(description="The transaction amount")
    category: CategoryType = Field(description="The spending category")
    confidence: Literal["high", "medium", "low"] = Field(
        description="How confident the categorization is"
    )


class CategorizedBatch(BaseModel):
    transactions: List[CategorizedTransaction]


class CategorySpend(BaseModel):
    category: str = Field(description="The spending category name")
    total: float = Field(description="Total amount spent in this category")


class SpendingSummary(BaseModel):
    total_spent: float = Field(description="Total amount spent in the period")
    top_categories: List[CategorySpend] = Field(
        description="Top spending categories ranked by total amount"
    )
    insights: List[str] = Field(description="Key insights about spending patterns")
    recommendations: List[str] = Field(
        description="Actionable savings recommendations"
    )

And here's the complete categorizer.py:

import json
from google import genai
from google.genai import types
from models import CategorizedBatch

client = genai.Client()

SYSTEM_PROMPT = """You are a financial transaction categorizer for a personal finance app.

Your role is to analyze bank transaction descriptions and categorize them accurately.

Rules:
- Always assign exactly one category per transaction
- Use context clues in merchant names to determine category
- If a transaction is ambiguous, choose the most likely category and set confidence to "medium" or "low"
- Never invent or modify the transaction description or amount

Categories:
- housing: Rent, mortgage, home insurance, property tax
- food_dining: Groceries, restaurants, cafes, food delivery
- transportation: Gas, public transit, ride-sharing, car payments
- utilities: Electric, water, internet, phone bills
- entertainment: Streaming services, concerts, movies, games
- shopping: Clothing, electronics, general retail
- health: Doctor visits, pharmacy, gym membership
- income: Salary, freelance payments, refunds
- transfer: Bank transfers, peer-to-peer payments
- other: Anything that doesn't fit above

Output format: Return a JSON object with a "transactions" array. Each item must have: description (string), amount (number), category (one of the listed categories), confidence (high/medium/low)."""

FEW_SHOT_EXAMPLES = """Here are examples of correct categorizations:

Transaction: "NETFLIX.COM" amount: -15.99
Result: {"description": "NETFLIX.COM", "amount": -15.99, "category": "entertainment", "confidence": "high"}

Transaction: "WHOLE FOODS MKT #10234" amount: -89.42
Result: {"description": "WHOLE FOODS MKT #10234", "amount": -89.42, "category": "food_dining", "confidence": "high"}

Transaction: "UBER TRIP" amount: -24.50
Result: {"description": "UBER TRIP", "amount": -24.50, "category": "transportation", "confidence": "high"}

Transaction: "TRANSFER FROM SAVINGS" amount: 500.00
Result: {"description": "TRANSFER FROM SAVINGS", "amount": 500.00, "category": "transfer", "confidence": "high"}

Transaction: "PAYROLL DEPOSIT - ACME INC" amount: 3500.00
Result: {"description": "PAYROLL DEPOSIT - ACME INC", "amount": 3500.00, "category": "income", "confidence": "high"}

Transaction: "AMZ*AMZN.COM" amount: -67.23
Result: {"description": "AMZ*AMZN.COM", "amount": -67.23, "category": "shopping", "confidence": "medium"}
"""

SAMPLE_TRANSACTIONS = [
    {"description": "SPOTIFY USA", "amount": -9.99},
    {"description": "TRADER JOE'S #123", "amount": -45.67},
    {"description": "SHELL OIL 57442", "amount": -52.30},
    {"description": "VERIZON WIRELESS", "amount": -85.00},
    {"description": "DELTA AIR LINES", "amount": -342.00},
    {"description": "CVS PHARMACY #8890", "amount": -12.45},
    {"description": "VENMO PAYMENT RECEIVED", "amount": 150.00},
    {"description": "ZILLOW RENT PAYMENT", "amount": -1800.00},
]


def categorize_transactions(transactions: list[dict]) -> CategorizedBatch:
    """Categorize a batch of transactions using the Gemini API."""

    transactions_text = "\n".join(
        f'Transaction: "{t["description"]}" amount: {t["amount"]}'
        for t in transactions
    )

    prompt = f"""{FEW_SHOT_EXAMPLES}

Now categorize the following transactions:

{transactions_text}"""

    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )

    try:
        result = CategorizedBatch.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result


if __name__ == "__main__":
    print("Categorizing transactions...\n")
    result = categorize_transactions(SAMPLE_TRANSACTIONS)

    for tx in result.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )

You now have a categorizer that guarantees typed, validated output every time it runs. Next up, you'll chain this categorizer into a multi-step pipeline that analyzes spending patterns and generates savings advice.

Chain Prompts for Spending Analysis

Your categorizer now transforms raw transaction text into structured, validated data.

Categorized transactions are useful on their own, but the real power comes from chaining multiple prompts together. In this step, you will build a second AI call that takes the categorizer's output as input and produces a spending summary with insights and savings recommendations.

What is prompt chaining?

Prompt chaining is a pattern where the output of one AI call becomes the input for the next. Each step in the chain has a different role and system instruction, keeping prompts focused and reliable.

Your chain will have two links: a categorizer (Step 1) and a spending advisor (Step 2). Splitting responsibilities this way means each prompt does one job well instead of trying to do everything at once.

In this step, get ready to:
  • Review the analysis models already defined in your schema.
  • Build a spending analysis function with a personal finance advisor system prompt.
  • Wire the full two-step pipeline and run it end to end.
Review the analysis models

Your models.py file already defines the Pydantic models that the spending analyzer will return.

  • Switch back to models.py in VS Code and find the CategorySpend and SpendingSummary classes at the bottom of the file.
class CategorySpend(BaseModel):
    category: str = Field(description="The spending category name")
    total: float = Field(description="Total amount spent in this category")


class SpendingSummary(BaseModel):
    total_spent: float = Field(description="Total amount spent in the period")
    top_categories: List[CategorySpend] = Field(
        description="Top spending categories ranked by total amount"
    )
    insights: List[str] = Field(description="Key insights about spending patterns")
    recommendations: List[str] = Field(
        description="Actionable savings recommendations"
    )

What do these models define?

  • CategorySpend pairs a category name with a dollar total, so the analyzer can rank spending by category.
  • SpendingSummary is the overall response shape. It holds the total spent, the top categories list, plain-English insights, and actionable recommendations.
  • Because these are Pydantic models, any response that doesn't match the schema will raise a validation error instead of silently passing bad data into your app.
Build the analysis prompt

The second link in your prompt chain needs a completely different persona. Instead of a transaction categorizer, this prompt plays the role of a personal finance advisor who reviews categorized data and generates insights.

  • Create a new file called analyzer.py in the same project folder as categorizer.py.
  • Add the following imports, client setup, and analyze_spending function:
from google import genai
from google.genai import types
from models import CategorizedBatch, SpendingSummary
from categorizer import categorize_transactions, SAMPLE_TRANSACTIONS

client = genai.Client()


def analyze_spending(categorized: CategorizedBatch) -> SpendingSummary:
    """Chain prompts: take categorized transactions and produce a spending summary."""

    # Serialize the categorized data to JSON so the model can read it
    categorized_json = categorized.model_dump_json(indent=2)

    # Build the analysis prompt with the categorized data as context
    analysis_prompt = f"""Analyze the following categorized transactions and provide a spending summary.

Categorized transactions:
{categorized_json}

Provide:
1. The total amount spent (exclude income and transfers, use absolute values)
2. Top spending categories ranked by total amount
3. 2-3 key insights about spending patterns
4. 2-3 actionable recommendations to save money

Be specific and reference actual numbers from the data."""

    # Call Gemini with a different system instruction (finance advisor role)
    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=analysis_prompt,
        config=types.GenerateContentConfig(
            system_instruction=(
                "You are a personal finance advisor. Analyze spending data and "
                "provide actionable, specific advice. Be encouraging but honest "
                "about areas for improvement. Always reference actual dollar "
                "amounts from the data."
            ),
            response_mime_type="application/json",
            temperature=0.3,
        ),
    )

    # Validate the response against our SpendingSummary schema
    try:
        result = SpendingSummary.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result

What does this code do?

  • model_dump_json(indent=2) converts the typed CategorizedBatch object back into a JSON string the model can read. This is how Step 1's output becomes Step 2's input.
  • The system_instruction gives this call a different role than the categorizer. It acts as a finance advisor who references actual dollar amounts.
  • temperature=0.3 is slightly higher than the categorizer's 0.1. This allows a bit more creativity in recommendations while keeping the response grounded in the data.
  • response_mime_type="application/json" plus Pydantic validation ensures the spending summary is always type-safe.
  • Save analyzer.py.

Seeing an import error?

Make sure analyzer.py is in the same folder as categorizer.py and models.py. Python resolves imports relative to the current directory.

help me fix an import error in analyzer.py

Connect the pipeline and run it

The analysis function is ready, but it needs a driver to call both steps in sequence. The __main__ block will run the categorizer first, then feed its output directly into the analyzer.

  • Add the following __main__ block at the bottom of analyzer.py, below the analyze_spending function:
if __name__ == "__main__":
    # Step 1: Categorize raw transactions
    print("Step 1: Categorizing transactions...")
    categorized = categorize_transactions(SAMPLE_TRANSACTIONS)

    print("\nCategorized Results:")
    for tx in categorized.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )

    # Step 2: Analyze the categorized spending
    print("\nStep 2: Analyzing spending patterns...\n")
    summary = analyze_spending(categorized)

    # Display the full spending summary
    print(f"Total Spent: ${summary.total_spent:.2f}")
    print("\nTop Categories:")
    for cat in summary.top_categories:
        print(f"  - {cat.category}: ${cat.total:.2f}")
    print("\nInsights:")
    for insight in summary.insights:
        print(f"  - {insight}")
    print("\nRecommendations:")
    for rec in summary.recommendations:
        print(f"  - {rec}")

What does this pipeline do?

  • categorize_transactions(SAMPLE_TRANSACTIONS) runs the first Gemini call using your system prompt and few-shot examples. It returns a typed CategorizedBatch.
  • analyze_spending(categorized) takes that typed output, serializes it to JSON, and passes it into a second Gemini call with the finance advisor persona.
  • The final print statements display the full journey: raw transactions turned into categorized data, then into actionable spending insights.
  • Save analyzer.py.
  • Run the full pipeline by executing this command in your terminal:
python analyzer.py
  • You should see two sections of output: the categorized transactions table from Step 1, followed by a spending summary with total spent, top categories, insights, and recommendations from Step 2.

Seeing a validation error on the summary?

The model occasionally returns a field name that doesn't match your schema (like total_amount instead of total_spent). Run the script again. The response_mime_type="application/json" setting makes the model follow your prompt format closely, but occasional mismatches can happen.

If the error persists, check that your analysis prompt explicitly mentions the field names that match your SpendingSummary model.

help me debug my spending analysis validation error

✔️ Awesome, I've got everything!

Great. Double check you have saved analyzer.py and move on.

ⓧ I'd like to double check the full code

Here is the complete analyzer.py file:

from google import genai
from google.genai import types
from models import CategorizedBatch, SpendingSummary
from categorizer import categorize_transactions, SAMPLE_TRANSACTIONS

client = genai.Client()


def analyze_spending(categorized: CategorizedBatch) -> SpendingSummary:
    """Chain prompts: take categorized transactions and produce a spending summary."""

    categorized_json = categorized.model_dump_json(indent=2)

    analysis_prompt = f"""Analyze the following categorized transactions and provide a spending summary.

Categorized transactions:
{categorized_json}

Provide:
1. The total amount spent (exclude income and transfers, use absolute values)
2. Top spending categories ranked by total amount
3. 2-3 key insights about spending patterns
4. 2-3 actionable recommendations to save money

Be specific and reference actual numbers from the data."""

    response = client.models.generate_content(
        model="gemini-3.5-flash",
        contents=analysis_prompt,
        config=types.GenerateContentConfig(
            system_instruction=(
                "You are a personal finance advisor. Analyze spending data and "
                "provide actionable, specific advice. Be encouraging but honest "
                "about areas for improvement. Always reference actual dollar "
                "amounts from the data."
            ),
            response_mime_type="application/json",
            temperature=0.3,
        ),
    )

    try:
        result = SpendingSummary.model_validate_json(response.text)
    except Exception as e:
        print(f"Validation error: {e}")
        print(f"Raw response: {response.text}")
        raise

    return result


if __name__ == "__main__":
    print("Step 1: Categorizing transactions...")
    categorized = categorize_transactions(SAMPLE_TRANSACTIONS)

    print("\nCategorized Results:")
    for tx in categorized.transactions:
        print(
            f"  {tx.description:<30} ${tx.amount:>8.2f}  "
            f"-> {tx.category:<16} ({tx.confidence})"
        )

    print("\nStep 2: Analyzing spending patterns...\n")
    summary = analyze_spending(categorized)

    print(f"Total Spent: ${summary.total_spent:.2f}")
    print("\nTop Categories:")
    for cat in summary.top_categories:
        print(f"  - {cat.category}: ${cat.total:.2f}")
    print("\nInsights:")
    for insight in summary.insights:
        print(f"  - {insight}")
    print("\nRecommendations:")
    for rec in summary.recommendations:
        print(f"  - {rec}")

You now have a complete prompt engineering pipeline. Raw bank transactions go in, and structured spending insights come out. Next up, the Secret Mission challenges you to measure how accurate your categorizer really is.

Secret mission

Build a Prompt Evaluation Harness

Your categorizer works, but how accurate is it really? In this secret mission, you'll build an evaluation harness that scores your prompts against labeled test data. Instead of guessing whether a prompt change helped, you'll have hard numbers to prove it.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses the Gemini API free tier. There are no ongoing costs or paid resources to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Gemini API key (created in Google AI Studio).
  • Local project files (test_connection.py, models.py, categorizer.py, analyzer.py, evaluate.py).
  • google-genai Python package.
  • GEMINI_API_KEY environment variable.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with prompt engineering or building on this project.

  • Your Gemini API key stays active at no cost on the free tier.
  • Your project files remain on your machine ready for further development.
  • The GEMINI_API_KEY environment variable persists in your shell profile for future use.

Pause - I'll come back to this later

Remove the environment variable from your current session while keeping everything else intact. Your files and API key stay ready for when you return.

  • Unset the environment variable in your current terminal session by running:
unset GEMINI_API_KEY
  • Your project files and API key remain. When you return, re-export the variable or open a new terminal that reads your shell profile.

Delete - I don't want to use this again

Remove all project resources and revoke your API key. Follow each step below to clean up completely.

  • Delete your Gemini API key. Go to Google AI Studio (aistudio.google.com), click Get API key to view your keys, then delete the key you created for this project.
  • Remove the GEMINI_API_KEY line from your shell profile (~/.zshrc on macOS or ~/.bashrc on Linux). Open the file in a text editor, find the export GEMINI_API_KEY= line, and delete it.
  • Unset the variable in your current session by running:
unset GEMINI_API_KEY
  • Uninstall the google-genai package by running:
pip uninstall google-genai
  • Delete your project files by running:
rm test_connection.py models.py categorizer.py analyzer.py evaluate.py
  • Confirm the files are gone by running ls. You should no longer see any of the project .py files listed.

Nice Work!

Nice Work!

You just built a production-ready prompt engineering pipeline that transforms raw bank transactions into categorized, analyzed spending data. This is exactly the kind of AI feature that powers real fintech products.

Here's what you learned:

  • Designed a system prompt with roles, rules, and constraints, then improved accuracy with few-shot examples that teach the model by demonstration.
  • Enforced type-safe structured output using Pydantic models to validate AI responses your application can consume programmatically.
  • Chained multiple prompts into a spending analysis pipeline that categorizes transactions, summarizes patterns, and generates savings recommendations.
  • Secret Mission: Built a prompt evaluation harness to measure categorizer accuracy against labeled test data and iterate on prompts with evidence.

Ready to quiz yourself?