Tools,

Invoice OCR in Python, with a math check

7 of 7

misread rows marked CHECK on 24 test scans

For printed scans: $0.006 a page

24 scans with known numbers

A scanned invoice from Northfield Supply, Invoice 3624: a ruled table of 11 items with SKU, description, qty, unit price and amount, then subtotal, tax and total
One of the 24 test scans, at 150 dpi, cropped.

These invoices are made up, so every right number is known. A script drew them in 4 fonts, then added tilt, blur, noise and low dpi. Real invoices vary more, in layout most of all.

How I tested it
  • 48 made-up invoices from one generator. I picked the settings on 24 of them. The other 24 picked nothing, and every score on this page comes from those.
  • A number counts as right only if it matches exactly: each qty, unit price and amount, plus subtotal, tax and total. 810 numbers in all.
  • Tesseract 5.5.3, pytesseract 0.3.13 and Pillow 12.3.0, on one Mac.

The script: 65 lines, one math check

The core is three lines: read a row, then test qty times unit price against the amount.

if m := ROW.match(line):
    sku, desc, qty, unit, amount = m.groups()
    ok = int(qty) * cents(unit) == cents(amount)
pip install pytesseract pillow

pytesseract also needs Tesseract itself: brew install tesseract on a Mac, or sudo apt install tesseract-ocr on Ubuntu.

Show all 65 lines
"""OCR a scanned invoice, print its rows as CSV, and flag bad math.

Usage: python invoice_ocr.py invoice.jpg
"""

import csv
import re
import sys

import pytesseract
from PIL import Image

MONEY = r"\$?(\d[\d,]*\.\d\d)"  # 1234.56, 1,234.56 or $1,234.56
# One item row: SKU, description, qty, unit price, amount
ROW = re.compile(rf"^\W*(\w+)\W+(.+?)\W+(\d+)\W+{MONEY}\W+{MONEY}\W*$")
FOOTER = re.compile(rf"^\W*(Subtotal|Tax|Total)\b.*?{MONEY}\W*$", re.IGNORECASE)
MARK = {True: "ok", False: "CHECK"}


def ocr(path, psm=4, cut=160):
    image = Image.open(path)
    dpi = round(image.info.get("dpi", (300, 300))[0])  # without it, Tesseract guesses
    # Pure black and white drops the scan noise
    ink = image.convert("L").point(lambda v: 255 if v > cut else 0)
    return pytesseract.image_to_string(ink, config=f"--psm {psm} --dpi {dpi}")


def cents(money):  # whole cents, so the math has no float rounding
    return int(money.replace(",", "").replace(".", "") or 0)


def parse(text):
    number = re.search(r"Invoice\s*#?\s*(\w+)", text)
    date = re.search(r"Date:?\s*([A-Z][a-z]+ \d{1,2}, \d{4})", text)
    rows, foot, checks = [], {}, []
    for line in text.splitlines():
        if m := ROW.match(line):
            sku, desc, qty, unit, amount = m.groups()
            ok = int(qty) * cents(unit) == cents(amount)
            ok = ok and not re.search(rf"-{MONEY}", line)  # a credit loses its minus
            unit, amount = unit.replace(",", ""), amount.replace(",", "")
            rows.append([sku, desc, qty, unit, amount, MARK[ok]])
        elif m := FOOTER.match(line):
            foot[m[1].lower()] = m[2].replace(",", "")
        elif re.search(r"\d[.,]\d\d\b", line):  # a price, but not a row
            first = re.findall(r"\w+", line)[0]
            rows.append([first, line.strip(), "", "", "", "CHECK"])
    if "subtotal" in foot:  # the amounts must add up to the subtotal
        ok = sum(cents(r[4]) for r in rows) == cents(foot["subtotal"])
        checks.append(["subtotal", foot["subtotal"], MARK[ok]])
    if {"subtotal", "tax", "total"} <= foot.keys():  # subtotal + tax = total
        ok = cents(foot["subtotal"]) + cents(foot["tax"]) == cents(foot["total"])
        checks += [["tax", foot["tax"]], ["total", foot["total"], MARK[ok]]]
    return number and number[1], date and date[1], rows, checks


def report(text, out=sys.stdout):
    number, date, rows, checks = parse(text)
    head = [["invoice", number], ["date", date]]
    head.append(["sku", "description", "qty", "unit_price", "amount", "check"])
    csv.writer(out, lineterminator="\n").writerows(head + rows + checks)


if __name__ == "__main__":
    report(ocr(sys.argv[1]))

Download invoice_ocr.py

It prints CSV, with a check column

Run python invoice_ocr.py invoice.jpg. On the scan above, it prints:

invoice,3624
date,"October 12, 2026"
sku,description,qty,unit_price,amount,check
1001,Paint gallon,6,34.69,208.14,ok
1002,Copper elbow 1/2in,10,6.37,63.70,ok
1003,Drywall sheet,4,57.62,230.48,ok
1004,Door closer,6,176.51,1059.06,ok
1005,Thermostat,1,78.27,78.27,ok
1006,Gasket set,11,430.68,4737.48,ok
1007,Pipe strap,6,1.76,10.56,ok
1008,Breaker 20A,3,110.68,332.04,ok
1009,Tile spacers,1,6.67,6.67,ok
1010,Hex bolts M8,12,9.79,117.48,ok
1011,Permit fee,7,6.83,47.81,ok
subtotal,6891.69,ok
tax,482.42
total,7374.11,ok

CHECK means a row needs a look. Either qty times unit price is not the amount, or a line has a price but does not read as a row. Then the script prints the whole line, and never drops it.

The last lines check the subtotal and the total the same way.

Every misread row was flagged

On the 24 test scans Result
Numbers right 791 of 810
Item rows read exactly 257 of 264
Misread rows marked CHECK 7 of 7
Of those, failed the math 1
Of those, did not read as a row 6
Misread rows marked ok 0
Invoice number and date right 24 of 24

The check cannot fix a number. It tells you which rows to read again, so you check 7 rows, not 264.

On 3 of the 6 invoices with a subtotal, the subtotal said CHECK too. A row above it did not read, so its amount counted as zero.

Without --dpi, 213 of 810 right

Numbers right of 810, with settings taken out
The script
791
--psm 3, the default
779
No black and white step
682
No --dpi
213
Plain image_to_string, all 3 off
182
--psm 6
9

Give pytesseract an image, and it saves a temp copy with no dpi, so Tesseract guesses. The script reads the dpi from the file and passes it.

--psm 6 read the ruled table as junk, such as foot [Rinenon |e Toe [ote, so only 3 of 264 rows matched.

Our Actor on the same 24 scans

Reader Numbers right Wrong, not flagged
The script 791 of 810 0
Our Actor 809 of 810 0

Its one miss, a qty of 1 read as "i", got a row warning too. It is weak at handwriting and phone photos. On receipt photos, the RapidOCR script on our receipt page beats it.

Which build of the Actor?

Build 0.1.44, the build on the Apify Store on Nov 7, 2026. I ran its code on the Mac, on the same 24 scans, not on Apify.

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

My invoice has other columns. Will it work?

Not as it is. ROW expects SKU, description, qty, unit price and amount on one line. Change ROW to match your columns, and keep the math check.

Can it read the invoice number and the total?

On these scans, yes: the number and date on all 24, and the subtotal, tax and total on all 6 that had them. Real labels vary, such as "Invoice #:", so test the regex on yours.

What will trip it up on real invoices?

Other labels and lines. A SKU with a dash splits in two, and lines like Shipping print as CHECK rows. A phone photo can claim 72 dpi, so pass the real dpi.

What if my invoice is a PDF?

If you can select its text, skip OCR and read the table with pdfplumber. If it is a scan, save each page as an image first, then run the script.