To catch OCR errors on a scanned bank statement, treat the printed balances as a checksum: gap = opening + sum(rows) - closing. Then search single-character misreads (3 and 8, 1 and 7, a lost minus sign or decimal point) for the one change that makes the gap exactly zero. Usually there is a single candidate.
OCR on a scanned statement will eventually give you a confident, wrong digit. The statement itself carries the fix. This post builds the search in about forty lines.
How do you use a bank statement's balances as a checksum?
from decimal import Decimal
def gap(opening: Decimal, rows: list[Decimal], closing: Decimal) -> Decimal:
"""Positive when the rows overshoot the printed closing balance."""
return opening + sum(rows, Decimal("0")) - closing
Use Decimal. You are comparing to the cent, and a float sum of a few hundred amounts will drift. If gap is zero, the statement ties. If not, the size of the gap tells you where to look.
Which OCR misreads should you model?
On faint or tilted scans, a short list of confusions covers most errors:
| Misread | Example | Gap it leaves |
|---|---|---|
| 8 for 3, or 3 for 8 | 1,335.00 read as 1,385.00 | 5, 50 or 500 |
| 7 for 1, or 1 for 7 | 417.20 read as 477.20 | 6, 60 or 600 |
| 6 and 5 | 2,506.00 read as 2,505.00 | 1, 10 or 100 |
| 0 and 8 | 8, 80 or 800 | |
| Decimal point lost | 45.00 read as 4500 | 99 times the amount |
| Minus sign lost | (250.00) read as 250.00 | 2 times the amount |
CONFUSABLE = {"3": "8", "8": "30", "1": "7", "7": "1",
"5": "6", "6": "5", "0": "8"}
def digit_variants(amount: Decimal):
"""Yield (label, corrected) for every single-digit confusion."""
text = f"{abs(amount):.2f}"
sign = -1 if amount < 0 else 1
for i, ch in enumerate(text):
for alt in CONFUSABLE.get(ch, ""):
swapped = text[:i] + alt + text[i + 1:]
yield f"digit {ch}->{alt} at {i}", sign * Decimal(swapped)
def all_variants(amount: Decimal):
yield from digit_variants(amount)
yield "sign flipped", -amount
if amount == amount.to_integral_value() and abs(amount) >= 100:
yield "decimal lost", amount / 100
"8": "30" tries both a 3 and a 0. Tune the table to your own failures: log every correction you make by hand and the table writes itself.
How do you search for the fix that closes the gap?
def candidates(opening, rows, closing):
g = gap(opening, rows, closing)
if g == 0:
return []
found = []
for idx, amount in enumerate(rows):
for label, fixed in all_variants(amount):
if amount - fixed == g: # this change removes the gap exactly
found.append((idx, amount, fixed, label))
return found
What does it find on a real-looking statement?
A fictional statement, as OCR read it:
opening = Decimal("2000.00")
rows = [Decimal("-318.40"), Decimal("1250.00"),
Decimal("-36.75"), Decimal("-450.00")]
closing = Decimal("2394.85")
print(gap(opening, rows, closing)) # 50.00
print(candidates(opening, rows, closing))
# [(2, Decimal('-36.75'), Decimal('-86.75'), 'digit 3->8 at 0')]
The rows overshoot by 50.00. Exactly one single-character change closes it: the third row was -86.75, and the scan dropped the bottom loop of the 8. Apply it and the gap is zero.

A converted statement in ZeraBooks Convert. Every account is checked against its printed balances before download.
Run the gap check before you tune your OCR. It tells you where to tune.
What if there is more than one candidate, or none?
A gap of 500.00 can be a 3 read as an 8 in the hundreds place, or a 250.00 debit that lost its sign. Don't auto-apply in that case. Return both and ask a person to look at two specific cells, which is still far better than rechecking a page.
When there are no candidates, the usual cause is a skipped line. Check whether the gap equals an amount you can see on the image but didn't extract, typically a small fee at the foot of a page.
Which edge cases will bite you?
- Running balances. Many statements print a balance after each line. Compare row by row and you get the exact line, not just the statement.
- Two errors that cancel. Rare, but the closing balance can't see them. A running balance can.
- No balances printed. There is no checksum. Mark the statement unverified rather than pretending.
- Multi-account statements. Run the check per account, never on the combined rows.
The user-facing version of this, with a test for telling a scan from a digital PDF and a worked two-page proof, is the guide to scanned bank statement to Excel.
Key takeaways
- The statement's opening and closing balances are a free checksum.
- A one-digit misread leaves the digit difference times its place value.
- Search single-character variants for one that makes the gap exactly zero.
- Two candidates go to a human; zero candidates usually means a skipped line.
FAQ
Why does Excel's Get Data from PDF return nothing on a scan?
It reads the text a PDF carries, and a scanned page carries only an image. Nothing to read means no tables found.
Does running a second OCR engine verify the first?
No. Two readers can make the same mistake on the same smudge. Only the printed balances check every row.
What if the statement has no printed balances?
There is no checksum, so the statement can't be proven. Mark it unverified and compare rows against the image.
Disclosure: I'm the founder of ZeraBooks. The ZeraBooks bank statement converter reads scanned PDFs with no text layer and won't mark a statement Proven until every account ties to its printed opening and closing balances. When one doesn't, it shows the account and the amount it is off by, and the user corrects the row. PDFs only, up to 500 pages and 50 MB each.
Top comments (0)