Why a 98%-accurate extractor is more dangerous than one that fails loudly — and the arithmetic that catches the difference.
Every developer who has been handed a PDF bank statement and asked to "just get the transactions out" starts in the same place: a PDF text extractor, a regex or two, and a quiet confidence that this is a weekend job.
It isn't. But not for the reason you expect.
The parsing is genuinely hard, and there is plenty written about that already — statements are laid out for printing, columns are visual alignment rather than structure, and a naive text dump turns a tidy table into one smeared column. That part you find out immediately, which is exactly what makes it the easy part. It fails loudly.
The hard part is the failure that doesn't announce itself.
The 98% problem
Suppose your extractor is 98% accurate on a 300-row statement. That sounds good. In practice it means roughly six rows are wrong, and nothing about the output looks wrong. The dates parse. The amounts are plausible numbers. The descriptions read like real merchants. You hand over a spreadsheet that is indistinguishable from a correct one, and the error surfaces weeks later during reconciliation — or never, which is worse.
Compare that to an extractor that throws on page 3. That one is annoying and honest. You fix it and move on.
So the interesting engineering question is not "how do I get the numbers out" but "how do I know whether the numbers I got are right, without having the right answer to compare against?"
The statement checks itself
Here is what makes bank statements unusually tractable compared to, say, invoices: a bank statement contains its own checksum.
Two of them, actually.
Document level. The statement prints an opening balance and a closing balance. Whatever transactions you extracted must reconcile between them:
opening + sum(credits) − sum(debits) == closing
If your extracted rows don't satisfy that, you know you're wrong before anyone looks at the output. You don't know which row is wrong yet, but you know the set is bad. That single check turns a silent failure into a loud one.
Row level. Most — not all, more on that below — statements print a running balance per line. That gives you a second, much sharper constraint: each row's balance must follow from the row above it.
balance[n] == balance[n−1] + signed_amount[n]
Now you can localise the error. The first row where the running balance stops following is where the extraction went wrong: a missed line, a duplicated one, or a misread digit.
Do this in integers, or don't bother
A detail that looks pedantic and isn't: do all of this in integer cents.
The moment you represent money as a float, your checksum starts producing false alarms. 0.1 + 0.2 != 0.3 is not a curiosity here, it's a bug generator: accumulate a few hundred additions and your reconciliation drifts by fractions of a cent. Now you either fail correct statements or widen your tolerance until it stops catching real errors.
Integers make the tolerance meaningful. Ours is one cent, at both levels — not because floating point needs slack, but because banks genuinely round running balances differently from one another. One cent is wide enough for legitimate rounding and narrow enough that a transposed digit can't hide in it.
DOC_LEVEL_TOLERANCE_CENTS = 1
ROW_LEVEL_TOLERANCE_CENTS = 1
def doc_reconciles(opening, credits, debits, closing):
delta = opening + credits - debits - closing
return abs(delta) <= DOC_LEVEL_TOLERANCE_CENTS
If you find yourself reaching for a tolerance of "about a dollar", stop: you've just disabled the only mechanism that was going to catch a real mistake.
Where the checksum doesn't exist
Credit card statements break the row-level check, and this is where a lot of extractors quietly invent data.
Card issuers don't print a running balance per transaction. They print a previous balance, a new balance, purchases, payments, and a payment due amount — a cycle, not a ledger. There is no per-row balance to verify against, because the concept doesn't exist on that document.
The tempting move is to compute one: start from the previous balance and accumulate. Now your output has a balance column on every row, it looks complete, and it is fiction — plausible-looking numbers that were never on the source document. Anyone reconciling against the real statement will find columns that agree with nothing.
The correct handling is to know what kind of document you're reading before you decide which invariants apply, and to leave the column out entirely when the document doesn't support it. An empty column is information. A fabricated one is a liability.
This generalises: your validation rules have to be a function of the document type, not a fixed pipeline. A bank account and a credit card statement are different objects that happen to look similar.
The other silent killer: 03/04/2026
Dates deserve their own section because they fail the same way — silently, plausibly, and expensively.
03/04/2026 is March 4th or April 3rd depending on which country printed it. Both readings produce a valid date. Neither throws. If you guess wrong on a UK statement, every transaction in your output lands in the wrong month, all your monthly totals are wrong, and nothing in the file looks broken.
You cannot resolve this per-date. You have to resolve it per-document: work out the statement's locale first — from the bank, the address block, the currency, the number formatting (1,234.56 vs 1.234,56), the language — and only then interpret every date under that single decision.
Then write dates out in ISO YYYY-MM-DD. Not as a style preference: an ISO date is the only format that can't be re-misread by the next system down the line, and it sorts correctly as a string, which matters the moment your output lands in a spreadsheet.
The same document-level reasoning applies to numbers. Decide once whether 1.234 is one thousand two hundred thirty-four or one point two three four, based on what the document is — not on what each individual token looks like.
What this buys you
None of the above makes extraction more accurate. That's the point that took us longest to internalise: verification is a separate concern from extraction, and it's the one that determines whether the output is usable.
An extractor with a checksum can be wrong and say so. An extractor without one is asking a human to redo the work by hand to find out — which is the work they were trying to avoid.
So the shape that ends up mattering is:
- Identify what the document is (type, locale, conventions) before extracting anything.
- Extract.
- Verify against constraints the document itself provides.
- When verification fails, surface the specific rows rather than lowering the bar until it passes.
- Never generate a field the source document doesn't contain.
Step 4 is the one people skip, and it's the one users actually feel. A flagged row is a thirty-second review. An unflagged wrong row is a reconciliation session.
The examples here come from building MainBook, a bank statement converter. The reconciliation and locale rules described above are what it runs on production documents.
Top comments (1)
The row-balance invariant is the right center of gravity here. It turns extraction quality from a fuzzy OCR question into a local accounting check. I would still keep a separate exception path for fee reversals and pending lines, since that is where a clean invariant can become too confident.