Parse Indian Bank Statements with PydanticAI and Lekha
Build type-safe financial document agents in Python using PydanticAI and Lekha's extraction API for Indian bank statements, ITR, and salary slips.
This guide walks through building a financial document analysis agent using PydanticAI + Lekha, from a single document extraction to a multi-step agent that answers questions about a user's finances.
Why PydanticAI for Financial Agents?
Financial data demands precision. A salary slip that returns "₹45,000" instead of 45000 breaks downstream logic. PydanticAI's core strength — enforcing structured outputs via Pydantic models — makes it ideal for fintech agents where type safety isn't optional.
Combined with Lekha's extraction API (which already returns clean, structured JSON from Indian financial documents), you get a two-layer guarantee: Lekha normalises the document format, PydanticAI normalises your agent's reasoning output.
Prerequisites
Install the dependencies:
pip install pydanticai httpx python-dotenv
Set your API key:
export LEKHA_API_KEY="lk_live_your_key_here"
Get a free key at lekhadev.com.
Step 1: Extract a Bank Statement with Lekha
Define a Pydantic model that mirrors Lekha's bank statement response, then wrap the API call as a PydanticAI tool.
from __future__ import annotations
import os
import httpx
from pydantic import BaseModel, Field
from pydanticai import Agent
from pydanticai.tools import Tool
LEKHA_API_KEY = os.environ["LEKHA_API_KEY"]
LEKHA_BASE_URL = "https://api.lekhadev.com/v1"
Mirror the shape of Lekha's response
class Transaction(BaseModel):
date: str # ISO 8601 — YYYY-MM-DD
description: str
amount: float # Always a number, never a string
type: str # "credit" | "debit"
balance: float
class BankStatementData(BaseModel):
bank_name: str
account_number: str
account_holder: str
period_from: str
period_to: str
opening_balance: float
closing_balance: float
transactions: list[Transaction]
class LekhaResponse(BaseModel):
success: bool
data: BankStatementData | None = None
error: dict | None = None
async def extract_bank_statement(pdf_path: str) -> LekhaResponse:
"""Call Lekha to extract structured data from an Indian bank statement PDF."""
async with httpx.AsyncClient() as client:
with open(pdf_path, "rb") as f:
response = await client.post(
f"{LEKHA_BASE_URL}/extract",
headers={"x-api-key": LEKHA_API_KEY},
files={"file": ("statement.pdf", f, "application/pdf")},
data={"document_type": "bank_statement"},
timeout=60.0,
)
return LekhaResponse.model_validate(response.json())
Step 2: Define Agent Output Models
Define what you want the agent to produce — a financial summary with structured insights.
class SpendingCategory(BaseModel):
category: str
total_amount: float
transaction_count: int
percentage_of_spend: float
class FinancialSummary(BaseModel):
account_holder: str
analysis_period: str
total_income: float
total_expenses: float
net_savings: float
savings_rate: float = Field(description="Savings as a percentage of income")
top_spending_categories: list[SpendingCategory]
average_monthly_balance: float
unusual_transactions: list[str] = Field(
description="Transactions that stand out — large debits, late-night transfers, etc."
)
risk_flags: list[str] = Field(
description="Patterns that may indicate financial stress or fraud risk"
)
Step 3: Build the PydanticAI Agent
Create an agent that takes extracted bank data and reasons over it to produce a FinancialSummary.
from pydanticai import Agent, RunContext
from pydanticai.models.anthropic import AnthropicModel
Use Claude for best accuracy on Indian financial contexts
model = AnthropicModel("claude-opus-5-5")
financial_analyst = Agent(
model=model,
result_type=FinancialSummary,
system_prompt="""You are an expert Indian financial analyst. Analyse bank statement
data and produce structured financial summaries.
Key rules:
- Salary credits, freelance income, rental income = income
- UPI, NEFT, IMPS debits; card payments; EMIs = expenses
- Identify UPI apps (PhonePe, GPay, Paytm) from transaction descriptions
- Flag transactions > 3x the account's average debit as unusual
- Risk flags: multiple small withdrawals (structuring), gambling sites,
frequent overdrafts, EMI bounce patterns
""",
)
@financial_analyst.tool
async def get_category_keywords(ctx: RunContext[None], category: str) -> list[str]:
"""Return typical keywords found in Indian bank statement descriptions for a spend category."""
categories = {
"food": ["SWIGGY", "ZOMATO", "BLINKIT", "BIGBASKET", "RESTAURANT"],
"fuel": ["INDIAN OIL", "BHARAT PETROLEUM", "HPCL", "PETROL"],
"utilities": ["BESCOM", "TATA POWER", "MAHANAGAR GAS", "BSNL", "AIRTEL"],
"emi": ["EMI", "LOAN", "HDFC BANK", "BAJAJ FINANCE", "MUTHOOT"],
"investment": ["ZERODHA", "GROWW", "MF", "SIP", "MUTUAL FUND", "NSDL"],
}
return categories.get(category.lower(), [])
Step 4: Run the Full Pipeline
Combine extraction and analysis into a single async workflow.
import asyncio
async def analyse_bank_statement(pdf_path: str) -> FinancialSummary:
# Step 1: Extract raw data via Lekha
extraction = await extract_bank_statement(pdf_path)
if not extraction.success or extraction.data is None:
raise ValueError(f"Extraction failed: {extraction.error}")
data = extraction.data
# Step 2: Format transactions for the agent
transaction_text = "\n".join([
f"{t.date} | {t.type.upper()} | ₹{t.amount:,.2f} | {t.description} | Balance: ₹{t.balance:,.2f}"
for t in data.transactions
])
prompt = f"""
Analyse this bank statement:
Account Holder: {data.account_holder}
Bank: {data.bank_name}
Account: {data.account_number}
Period: {data.period_from} to {data.period_to}
Opening Balance: ₹{data.opening_balance:,.2f}
Closing Balance: ₹{data.closing_balance:,.2f}
Transactions ({len(data.transactions)} total):
{transaction_text}
Produce a complete financial summary with spending categories, savings rate,
and any risk flags you observe.
"""
# Step 3: Run the PydanticAI agent
result = await financial_analyst.run(prompt)
return result.data
Run it
async def main():
summary = await analyse_bank_statement("hdfc_statement_aug2026.pdf")
print(f"Account: {summary.account_holder}")
print(f"Period: {summary.analysis_period}")
print(f"Income: ₹{summary.total_income:,.0f}")
print(f"Expenses: ₹{summary.total_expenses:,.0f}")
print(f"Savings Rate: {summary.savings_rate:.1f}%")
print(f"\nTop Spending:")
for cat in summary.top_spending_categories[:3]:
print(f" {cat.category}: ₹{cat.total_amount:,.0f} ({cat.percentage_of_spend:.1f}%)")
if summary.risk_flags:
print(f"\nRisk Flags:")
for flag in summary.risk_flags:
print(f" ⚠ {flag}")
asyncio.run(main())
Step 5: Multi-Document Agent with Memory
For loan underwriting or wealth management use cases, you often need to analyse multiple document types together. PydanticAI's dependency injection makes this clean.
from dataclasses import dataclass
from pydanticai import Agent, RunContext
@dataclass
class ApplicantDocs:
bank_statement: BankStatementData
salary_slip: dict # From Lekha's salary slip extraction
itr_data: dict | None = None # Optional ITR data
class LoanEligibility(BaseModel):
eligible: bool
recommended_loan_amount: float
max_emi: float = Field(description="Maximum EMI the applicant can service (40% of net income)")
debt_to_income_ratio: float
credit_risk: str # "low" | "medium" | "high"
reasons: list[str]
conditions: list[str] = Field(description="Conditions for loan approval")
loan_underwriter = Agent(
model=AnthropicModel("claude-opus-5-5"),
result_type=LoanEligibility,
deps_type=ApplicantDocs,
system_prompt="""You are an Indian bank loan underwriter. Evaluate loan eligibility
using RBI guidelines:
- EMI should not exceed 40-50% of net monthly income
- Minimum 6 months employment continuity
- No cheque bounces in the last 3 months
- Stable or increasing income trend
""",
)
@loan_underwriter.tool
async def get_average_monthly_credits(ctx: RunContext[ApplicantDocs]) -> float:
"""Calculate average monthly credits from the bank statement."""
credits = [t.amount for t in ctx.deps.bank_statement.transactions if t.type == "credit"]
return sum(credits) / max(1, 3) # 3-month average
@loan_underwriter.tool
async def count_emi_bounces(ctx: RunContext[ApplicantDocs]) -> int:
"""Count EMI bounce / return transactions in the statement."""
bounce_keywords = ["BOUNCE", "RETURN", "INSUFFICIENT", "DISHONOUR", "ECS RETURN"]
return sum(
1 for t in ctx.deps.bank_statement.transactions
if any(kw in t.description.upper() for kw in bounce_keywords)
)
Handling Indian-Specific Edge Cases
Working with Indian bank statements requires a few extra considerations that PydanticAI handles gracefully:
1. UPI transaction descriptions are terse UPI credits show asUPI/940238102938/JOHN DOE/PAYTM. Tell the agent to extract merchant names from these patterns.
2. Salary comes in parts
Some employers split salary across multiple credits on the same day. Use a Pydantic validator to detect and merge them:
from pydantic import model_validator
class MergedTransaction(Transaction):
is_salary_component: bool = False
@model_validator(mode="after")
def tag_salary_components(self) -> "MergedTransaction":
salary_keywords = ["SALARY", "SAL", "STIPEND", "WAGES", "PAYROLL"]
if any(kw in self.description.upper() for kw in salary_keywords):
self.is_salary_component = True
return self
3. Amounts in lakhs
Lekha always returns amounts as plain floats (e.g., 150000.0 not "1.5L"), so this is already handled — but remind your agent in the system prompt that "1 lakh = 100000" for its reasoning.
Testing Your Agent
PydanticAI has a built-in TestModel for deterministic unit tests:
from pydanticai import capture_run_messages
from pydanticai.models.test import TestModel
def test_agent_produces_valid_summary():
with financial_analyst.override(model=TestModel()):
result = financial_analyst.run_sync("Analyse this: ...")
# PydanticAI validates the output against FinancialSummary automatically
assert isinstance(result.data, FinancialSummary)
assert 0 <= result.data.savings_rate <= 100
assert result.data.total_income >= 0
Try it yourself in the Lekha Playground — upload any Indian bank statement and see the structured JSON output that your PydanticAI agent will work with.
FAQ
What Indian banks does Lekha support?Lekha supports 50+ Indian banks including SBI, HDFC, ICICI, Axis, Kotak, Yes Bank, IndusInd, PNB, Canara, Union Bank, Bank of Baroda, Bank of India, Federal Bank, RBL, IDFC First, and more. See the full list in the docs.
Can I use PydanticAI with free-tier LLM providers?Yes. Lekha's free plan routes to Ollama/OpenRouter; PydanticAI supports OpenRouter models via OpenAIModel with a custom base URL. The extraction quality may vary on smaller models — Claude and GPT-4o-class models produce the best results for financial reasoning.
Lekha processes multi-page PDFs natively. Pass the full PDF; Lekha returns all transactions regardless of page count. See the multi-page PDF guide for chunking strategies on very large files.
Is PydanticAI production-ready for financial applications?PydanticAI v1.0 reached production stability in early 2025. For regulated financial applications, ensure you log all agent runs (PydanticAI supports logfire natively) and implement a human review step for any automated decisions that affect lending, credit, or KYC outcomes.
Lekha + PydanticAI is one of the cleanest ways to build financially-aware AI agents in Python. Lekha handles the messy document parsing; PydanticAI enforces type safety at the agent layer; your application logic stays clean and testable.
Ready to start? Sign up for a free Lekha API key at lekhadev.com and explore the interactive API playground to see what your bank statements look like as structured JSON.