When you judge the best scanned bank statement converter, remember that OCR confidence scores are a measure of how clean a character looked, not whether the number is right, so a confidently misread digit sails straight past a confidence filter. The reliable check on a scan is arithmetic: the printed opening balance plus the extracted rows has to equal the printed closing balance, and when it doesn't, the gap is usually small enough to point at the exact digit. Below is runnable Python that finds single-digit repairs that make an account tie.
Disclosure: I work on ZeraBooks, a statement converter that reads scans and ranks itself first in our comparisons. The code here doesn't need it.
Why can't you trust a high confidence score?
Confidence is usually per character or per field, and it answers "how sure am I this blob is a 7?" It doesn't know about the statement. It can't know that the payroll amount should make the running balance land somewhere. So a few failure modes come out with high confidence:
- Transposed digits. Both digits are crisp. They're just in the wrong order, often because the line was slightly skewed and the engine read the characters' positions wrong.
- Look-alike digits on a faint scan. A 3 with a broken left side really does look like an 8. The engine is confident because the shape it saw was clear.
- Lost minus signs. The amount is read perfectly. The sign, or the column it was in, isn't.
- The right number in the wrong row. Two lines close together, amounts read correctly, attached to the wrong descriptions.
Meanwhile the low-confidence fields are often fine: a smudged but correct "385.00" next to a staple hole. Filtering on confidence sends the wrong rows to review.
A worked example
Six rows from a fictional scanned statement. The OCR misread the payroll amount, swapping the last two digits, and gave it 0.97 confidence. The only low-confidence field is a supplier payment that was read correctly.
The code does three things: computes the gap between what the rows add up to and the printed closing balance, shows what a 0.80 confidence filter would flag, and searches every row for a single plausible misread (a look-alike digit or two swapped neighbours) that exactly closes the gap.
from decimal import Decimal
# Digits OCR engines commonly confuse on low-resolution or faint scans.
LOOKALIKE = {"3": "8", "8": "3", "5": "6", "6": "5", "1": "7", "7": "1", "0": "8"}
def variants(amount):
"""Yield (new_amount, reason) for single plausible misreads of one amount."""
sign = "-" if amount < 0 else ""
digits = f"{abs(amount):.2f}"
for i, ch in enumerate(digits):
if ch in LOOKALIKE:
text = digits[:i] + LOOKALIKE[ch] + digits[i + 1:]
yield Decimal(sign + text), f"read {ch} as {LOOKALIKE[ch]}"
for i in range(len(digits) - 1):
a, b = digits[i], digits[i + 1]
if a.isdigit() and b.isdigit() and a != b:
text = digits[:i] + b + a + digits[i + 2:]
yield Decimal(sign + text), f"swapped {a}{b}"
def repair_candidates(opening, closing, rows):
"""rows: (description, amount, ocr_confidence). Find single-digit fixes that
make the account tie, and show how confident the OCR was about each one."""
gap = closing - (opening + sum(a for _, a, _ in rows))
found = []
for desc, amount, conf in rows:
for fixed, reason in variants(amount):
if fixed - amount == gap:
found.append((desc, amount, fixed, reason, conf))
return gap, found
opening, closing = Decimal("12480.00"), Decimal("10214.92")
rows = [ # amount as the OCR read it, with the engine's confidence for that field
("Deposit - client 1041", Decimal("3250.00"), 0.98),
("Rent - Oct", Decimal("-2900.00"), 0.99),
("Payroll run", Decimal("-2219.27"), 0.97), # the scan says -2219.72
("Supplier ACH", Decimal("-385.00"), 0.71),
("Card settlement", Decimal("1424.64"), 0.96),
("Loan payment", Decimal("-1435.00"), 0.94),
]
gap, found = repair_candidates(opening, closing, rows)
print(f"gap to close: {gap}")
low = [d for d, _, c in rows if c < 0.80]
print(f"rows a 0.80 confidence filter would send to review: {low}")
for desc, was, fixed, reason, conf in found:
print(f"fix {desc!r}: {was} -> {fixed} ({reason}, OCR confidence {conf})")
Output:
gap to close: -0.45
rows a 0.80 confidence filter would send to review: ['Supplier ACH']
fix 'Payroll run': -2219.27 -> -2219.72 (swapped 27, OCR confidence 0.97)
The confidence filter flags the one row that was right. The arithmetic finds the one that was wrong, names the misread, and tells you the corrected value. A human then checks that single row against the scan instead of re-reading the page.
How far can this repair search go?
Further than you'd think, with care:
- One error per account is the common case. The search above only tries single edits. That's deliberate. If two edits are needed, you get a combinatorial number of candidates and some will tie by coincidence.
- More than one candidate means review, not auto-fix. If two different rows each have a variant that closes the gap, don't pick one. Flag both.
- Use the running balance if the statement prints one. It localises the error to one row before you search, which shrinks the candidate set to that row alone.
- Sign flips are a special case. If the gap equals twice one row's amount, try flipping that row's sign before anything else.
- Never auto-fix without the document. The repair search tells you where to look and what to expect. A person, or a second read of that region of the image, confirms it.
That last point is how ZeraBooks handles it in the ledger: when a month doesn't tie, an AI re-read of the statement proposes only the fixes that would make it tie, each one quoting the line from the document. The proposal is the output, not a silent edit.
Scan quality rules that actually help
The best fix is a better scan. Publicly documented rules from tools in this space are a decent baseline: Hubdoc asks for 200 DPI minimum, scanned straight, English, one statement per file, and Dext asks for 200 dpi or more (both checked November 5, 2026). What I'd add:
- Scan at 200 to 300 dpi, greyscale or black and white.
- Use the flatbed glass for folded or crumpled pages.
- Include the summary page. Without printed balances there's nothing to check against, and the honest status is "unverified".
- One statement per PDF, pages in order.
- Nothing stapled or stuck over the amounts.
A phone photo works if it's flat, straight-on and in good light, but convert the photos into one PDF before upload.
Which tools accept image-only PDFs?
Worth knowing before you pick a pipeline. Excel's Power Query reads only the PDF's text layer, so an image-only scan has nothing for it to read. Acrobat Pro can recognize text in a scan but doesn't check totals. AutoEntry, Hubdoc and Dext accept scans with their own rules. DocuClipper and MoneyThumb say they read scans. ZeraBooks reads the page image directly, up to 500 pages and 50 MB a file, and marks an account Proven only when its rows reach the printed closing balance.
The full comparison, with each tool's published scan rules and whether it catches a misread, is in ZeraBooks' ranking of converters for scanned statements.
OCR is one layer of three
If you're designing a statement pipeline, it helps to name the layers:
- OCR turns pixels into text with positions. It doesn't know what a transaction is.
- Extraction turns that text into transactions: date, description, signed amount, balance, account. It drops headers and folds wrapped lines.
- Verification ties the extracted rows to the printed balances and flags what doesn't.
- Booking puts only verified rows into the ledger or the export, and holds the rest for a person.
Most of the confusion in this space comes from tools that do layer 1 or 2 and get described as if they do 3. "Has OCR" and "has a 99% accuracy claim" are both statements about layers 1 and 2. Layer 3 is the only one that tells you about the file in front of you.
The difference between converters that hand a person a finished file and platforms that hand your system JSON (Veryfi, Nanonets, Docsumo and others) is covered in ZeraBooks' extraction software ranking. Whichever side you're on, build layer 3 yourself if your tool doesn't do it. On scans especially, it's the only layer that doesn't lie.

Top comments (0)