← Back to blog
·8 min read

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.

pydanticaibank statement parsingai agentpythonfintech indiadocument extractionstructured output
PydanticAI is the official agent framework from the Pydantic team — the same library that powers data validation in FastAPI, LangChain, and nearly every serious Python project. When you combine PydanticAI's structured, type-safe agent design with Lekha's financial document extraction API, you get a robust pipeline for processing Indian bank statements, salary slips, ITR, and more — with zero parsing guesswork and full IDE support.

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.

Key advantages for document AI:
  • Strongly typed tool inputs and outputs with Pydantic v2 models
  • Built-in retry logic when the LLM returns malformed data
  • First-class support for streaming and async workflows
  • Works with Claude, OpenAI, Gemini, and Groq — no vendor lock-in
  • 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 as UPI/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.

    How do I handle multi-month bank statement PDFs?

    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.