How to analyse bank statements automatically
If your team assesses credit, onboards a customer, or underwrites a loan, someone has probably spent an afternoon reading a bank statement line by line — transcribing figures into a spreadsheet, eyeballing the balance, hunting for gambling transactions or missed loan repayments. It works, but it does not scale, it is inconsistent between reviewers, and when a decision is later questioned there is rarely a clean record of what was actually checked.
Automating statement analysis does not mean handing the decision to a model. It means building a pipeline that does the mechanical work reliably — parsing, extraction, arithmetic, categorisation, rule-checking — and surfaces exactly the handful of things a person actually needs to judge. This article is a practical, end-to-end guide to building that pipeline, whether you buy it, build it, or do a bit of both.
The pipeline at a glance
Every automated statement-analysis system, regardless of vendor or stack, is some version of the same five stages. Getting the stages right — and knowing where each one can fail — matters more than the specific tools you pick.
Automated statement analysis · end to end
01 Ingest & parse
Accept the PDF (or scan/CSV), detect whether it is text-based or an image, and run layout-aware parsing or OCR to recover the raw text and table structure.
02 Extract transactions
Turn the parsed layout into structured rows — date, description, amount, running balance — each carrying a confidence score the reviewer can sort by.
03 Normalise & categorise
Standardise dates, signs, and currency; reconcile the arithmetic; and assign each transaction a category (income, rent, debt repayment, gambling, fees…).
04 Analyse & score
Run affordability and risk rules over the categorised data: net income, disposable income, NSF events, undisclosed credit, cash-flow volatility.
05 Review & decide
Present the result in risk order, let a human correct low-confidence fields with a logged reason, and emit an audit-ready decision record.
Stage 1 — Ingest and parse the PDF
The first fork in the road is the nature of the file. A bank statement can be a text-based PDF (exported directly from online banking — the text is selectable), an image-based PDF or scan (a photo or a fax, where every page is a picture of text), or a CSV/OFX export. They need different handling, and misclassifying them is the single most common cause of garbage extractions downstream.
- Text-based PDF — parse the embedded text layer with a layout-aware library so you keep table columns and rows intact. This is fast and near-lossless.
- Image-based PDF / scan — run OCR (optical character recognition). Expect lower per-character accuracy, so confidence scoring and human review matter much more here.
- CSV / OFX / MT940 — already structured; skip parsing and jump straight to normalisation. Still validate columns and encodings.
COMMON PITFALL
Detecting a text layer with plain text extraction can silently succeed on a scanned PDF that happens to have a thin, wrong OCR layer baked in by the sender. Always sanity-check the parse: if the reconstructed rows do not reconcile against the printed balances, treat the document as an image and re-run OCR.
A robust ingest step classifies the document, extracts a page-by-page layout, and hands the next stage a clean intermediate representation — usually a list of text blocks with bounding boxes, or a reconstructed table grid. In our own stack this is handled by a layout-aware extractor (Docling) before anything touches a language model.
Stage 2 — Extract transactions with confidence
This is the heart of the system. You are turning a messy grid of text into structured rows: date, description, amount, balance. Two approaches dominate, and modern systems combine them: deterministic table parsing (fast, exact when the layout is clean) and an LLM extractor (robust to weird layouts, multi-line descriptions, and inconsistent column ordering).
Whichever you use, the non-negotiable feature is a per-row confidence score. Without it, a reviewer has to read every row equally. With it, they read in risk order — lowest confidence first — and glance past the rest. The score is what turns a 30-minute review into a 5-minute one.
| Date | Description | Amount | Conf |
|---|---|---|---|
| Jan 12RISK · GAMBLING | Sports betting debit | -$120.00 | 62% |
| Jan 08 | Cash withdrawal | -$450.00 | 74% |
| Jan 19RISK · NSF | NSF fee — returned DD | -$35.00 | 81% |
| Jan 03 | Rent payment | -$1,780.00 | 91% |
| Jan 01 | Payroll deposit | +$4,250.00 | 96% |
A practical way to get structured output from an LLM is to constrain it to a schema. Define exactly the shape you expect, force the model to return valid JSON against it, and validate on the way out so malformed rows are caught rather than silently trusted. Here is the schema we hold extractions to:
Extraction JSON Schema
The output contract every extracted statement is validated against — account, period, and confidence-scored transactions. Draft 2020-12.
And here is the extraction call itself — a schema-constrained request that returns validated rows instead of free text:
import json
from openai import OpenAI
client = OpenAI()
def extract_transactions(parsed_pages: list[str], schema: dict) -> dict:
"""Turn parsed statement pages into confidence-scored rows."""
response = client.chat.completions.create(
model="gpt-4o",
temperature=0, # determinism matters for auditability
response_format={
"type": "json_schema",
"json_schema": {"name": "extraction", "schema": schema, "strict": True},
},
messages=[
{
"role": "system",
"content": (
"Extract every transaction. Never invent rows. "
"Set 'confidence' below 0.80 when a value is ambiguous, "
"smudged, or reconstructed rather than read directly."
),
},
{"role": "user", "content": "\n".join(parsed_pages)},
],
)
return json.loads(response.choices[0].message.content)WHY TEMPERATURE 0
For anything that feeds a lending decision you want the same input to produce the same output. temperature=0 makes the extractor as deterministic as the API allows, which is what lets you reproduce and defend a result months later.
Stage 3 — Normalise, reconcile, and categorise
Raw extracted rows are not yet analysable. Dates come in a dozen formats; some banks show debits as negative numbers, others in a separate column; currencies and thousands separators vary. Normalisation makes everything uniform, and — critically — reconciliation proves the extraction is internally consistent before you trust it.
The reconciliation check is simple and powerful: for each row, the previous balance plus the signed amount should equal the new balance; and opening balance plus all credits minus all debits should equal the closing balance. If it does not tie out, something was mis-read — flag it, do not analyse it.
from decimal import Decimal
def reconcile(opening: Decimal, closing: Decimal, txns: list[dict]) -> list[str]:
"""Return a list of integrity errors; empty list means the statement ties out."""
errors: list[str] = []
running = opening
for t in txns:
running += Decimal(str(t["amount"]))
if "balance" in t and abs(running - Decimal(str(t["balance"]))) > Decimal("0.01"):
errors.append(f"Row {t['date']} {t['description']!r}: running balance mismatch")
if abs(running - closing) > Decimal("0.01"):
errors.append(f"Closing balance mismatch: computed {running}, printed {closing}")
return errorsCategorisation is the other half of this stage. Every transaction gets a category so the analysis stage can reason about it: is this income, a fixed housing cost, a discretionary spend, a debt repayment, a gambling transaction, an adverse bank fee? A hybrid approach works best — a fast, transparent rules table for the obvious cases, with an LLM classifier as a fallback for the long tail of unusual merchant strings.
Start from a categorisation rules table. It is auditable (a human can read exactly why a row was tagged), fast, and free. Here is a starter set you can adapt to your market and merchant naming conventions:
Transaction categorisation rules
A starter regex-to-category mapping with risk weights. Import into your rules engine or use it to seed an LLM classifier's few-shot examples.
Stage 4 — Analyse: affordability and risk
With clean, categorised, reconciled data you can finally compute the numbers a credit decision actually depends on. Analysis splits into two questions: affordability (can they service the obligation?) and risk (is there evidence of financial stress or undisclosed commitments?).
| Signal | What it measures | How it is computed | Why it matters |
|---|---|---|---|
| Net monthly income | Sustainable regular income | Sum of income-category credits, excluding internal transfers, averaged over full months | The denominator for every affordability ratio |
| Disposable income | Cash left after fixed costs | Net income minus housing, utilities, and existing debt repayments | Headroom to service new credit |
| Debt-service ratio | Leverage | Existing debt repayments ÷ net income | A high ratio signals limited capacity for more credit |
| NSF / returned-item count | Cash-flow stress | Count of insufficient-funds, returned-DD, and overdraft-fee events | One of the strongest predictors of default |
| Gambling exposure | High-risk discretionary spend | Sum of gambling-category debits ÷ net income | Regulatory and affordability red flag |
| Balance volatility | Financial fragility | Std. deviation of daily closing balance; days below zero | Living paycheck-to-paycheck vs. genuine buffer |
These are deterministic calculations — no model required — which is exactly what you want for the part of the pipeline that touches a lending outcome. Encode them as pure functions over the transaction list so each one is independently testable:
from decimal import Decimal
def net_monthly_income(txns, months: int) -> Decimal:
income = sum(
Decimal(str(t["amount"])) for t in txns
if t["category"] == "income" and t["amount"] > 0
)
return (income / months).quantize(Decimal("0.01"))
def adverse_events(txns) -> int:
return sum(1 for t in txns if t["category"] == "bank_fee_adverse")
def gambling_ratio(txns, net_income: Decimal) -> Decimal:
spend = sum(
-Decimal(str(t["amount"])) for t in txns if t["category"] == "gambling"
)
return (spend / net_income).quantize(Decimal("0.001")) if net_income else Decimal(0)KEEP THE MODEL OUT OF THE ARITHMETIC
Use the LLM for reading and categorising, never for adding up. Language models are unreliable at arithmetic and impossible to audit when they are wrong. Every number that feeds a decision should come from deterministic code you can unit-test and reproduce.
Stage 5 — Human-in-the-loop review and the audit trail
The final stage is where automation and accountability meet. The system has done the mechanical work; now a person confirms the parts that matter. The design goal is to make that confirmation as fast as possible while producing a record that can defend the decision later.
Two features carry most of the weight. First, confidence-ordered review: the reviewer sees low-confidence extractions and flagged risks first, not the document top-to-bottom. Second, corrections as first-class events: when a reviewer changes a value, the change is stored with a before value, an after value, an actor, a timestamp, and a reason — so the audit trail is a side effect of the workflow, not a separate chore.
# Correct a mis-read amount; the reason is required, not optional.
curl -X PATCH https://api.statemaint.com/v1/extractions/7f3a/transactions \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "txn_0142",
"field": "amount",
"before": -12.00,
"after": -120.00,
"reason": "OCR dropped a trailing zero; verified against page 6 of source PDF"
}'The result is a decision you can hand to an auditor without reconstructing anything from memory: every touched field shows what it was, what it became, who changed it, and why. That is the difference between "we reviewed it" and "here is exactly what we checked."
Which approach should you use?
There are three broad ways to build statement analysis, and the honest answer is that mature systems blend them. But the trade-offs are worth seeing side by side.
| Dimension | Manual review | Pure rule-based | LLM-assisted + human-in-loop |
|---|---|---|---|
| Setup cost | None | High (write rules per bank) | Low to moderate |
| Speed per statement | 20–40 min | Seconds | Seconds + short review |
| Handles messy scans | Yes (slowly) | Poorly | Yes, with confidence flags |
| Consistency | Varies by reviewer | Perfectly consistent | Consistent, model-driven |
| Adapts to new layouts | Instantly | Needs new rules | Instantly |
| Auditability | Weak (ad hoc notes) | Strong | Strong (logged corrections) |
| Best for | One-off / low volume | Fixed, known formats | Varied formats at scale |
A worked example
To make it concrete, take a single applicant's month. The pipeline ingests a 14-page scanned PDF, extracts 142 transactions, reconciles them against the printed balances, and categorises each one. The analysis stage then produces this summary — which is what a reviewer actually looks at first:
| Metric | Value | Verdict |
|---|---|---|
| Net monthly income | $4,250 | Stable — single monthly payroll credit |
| Fixed obligations | $2,310 | Rent + utilities + one loan repayment |
| Disposable income | $1,940 | Adequate headroom |
| Debt-service ratio | 12% | Low |
| NSF / returned items | 2 events | Flag — cash-flow stress mid-month |
| Gambling exposure | $360 (8.5%) | Flag — review against policy |
| Undisclosed credit | 1 detected | Flag — BNPL repayment not declared |
The applicant is affordable on paper — but the three flags are exactly what a human should weigh, and exactly what the automation surfaced without the reviewer reading 142 rows. The two NSF events and the undisclosed buy-now-pay-later repayment would be easy to miss in a manual scan and are trivial for the pipeline to catch.
Downloadable templates
Everything you need to start is bundled into this article. The categorisation rules and extraction schema appear above; here is the reviewer checklist that closes the loop between the automated output and a defensible decision.
Bank statement review checklist
A print-ready checklist covering completeness, extraction integrity, income, risk signals, and the audit trail. Markdown — drop it into your workflow or wiki.
Frequently asked questions
Can I fully automate statement analysis and remove the human entirely?
You can, but for anything that drives a lending or onboarding decision you generally should not. The reliable win is automating the mechanical work — parsing, extraction, arithmetic, categorisation — while keeping a human to confirm low-confidence fields and weigh the flagged risks. That combination is faster than manual review and more defensible than full automation.
How accurate is automated extraction?
On clean, text-based PDFs, layout-aware extraction is near-lossless. On scans and photos, per-character OCR accuracy drops, which is precisely why per-row confidence scoring exists: it tells you which rows to trust and which to verify, so overall decision accuracy stays high even when raw OCR is imperfect.
What about data privacy and security?
Bank statements are highly sensitive. Mask account numbers on ingest, encrypt at rest and in transit, restrict access by role, and log every read. If you use a third-party model provider, confirm data-retention and training policies, and prefer providers that let you disable retention for submitted content.
Do I need a language model at all?
Not for fixed, known formats — a rules-based parser is faster, cheaper, and perfectly auditable there. LLMs earn their place when you face many different bank layouts, multi-line descriptions, and messy scans, where writing and maintaining per-bank rules becomes impractical.
How do I handle multiple currencies or non-standard formats?
Detect currency and locale during normalisation, store amounts as exact decimals (never floats), and keep the original string alongside the parsed value so a reviewer can always check the source. For consolidated analysis across accounts, convert to a base currency at a dated rate and record the rate used.
How long does it take to process one statement?
The automated stages run in seconds to a couple of minutes depending on page count and whether OCR is needed. The human review that follows is typically a few minutes because it is confidence-ordered — the reviewer confirms a handful of flagged rows rather than reading the whole document.
Putting it together
Automated bank statement analysis is not a single model or a magic API — it is a disciplined pipeline. Parse the document honestly, extract with confidence scores, normalise and reconcile so you never analyse a mis-read, compute affordability and risk with deterministic code, and put a human in the loop exactly where judgment is needed. Do that, and you get the speed of automation with the accountability of manual review, and none of the false trade-off between them.
SEE IT ON A REAL STATEMENT
StatemAInt runs this whole pipeline — layout-aware extraction, confidence scoring, reconciliation, risk analysis, and a logged correction trail — as a single review workspace. Book a demo to watch it process one of your own statements end to end.