Business Apps

Extract Invoice Data from PDFs with Python and AI

Pull invoice number, date, supplier, line items and totals out of any PDF layout using pypdf, a pydantic schema and JSON mode, then verify the maths in Python.

By the end of this guide you will have a Python script that takes any supplier invoice PDF and returns a checked record containing the invoice number, invoice date, supplier name, every line item, and the totals — with a list of anything that does not add up attached. Allow about an hour, including the time to test it on three or four of your own invoices.

The approach has one rule behind it: the model reads, Python verifies. A large language model (a system trained to predict text, which is what lets it find "Invoice No." on a page it has never seen) is superb at locating values in unfamiliar layouts and hopeless as a calculator. So you let it copy figures and you never let it do arithmetic. Every number it returns gets re-checked by code that cannot hallucinate.

This is one guide in AI Document Processing with Python, which covers the wider job of turning documents into data your systems can use.

Prerequisites

You need Python 3.10 or newer inside a virtual environment — an isolated folder of packages that keeps this project's dependencies away from the rest of your machine. If that phrase is new, work through Create a Python Virtual Environment for AI first, then come back.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "pypdf>=5.0" "openai>=1.40" "pydantic>=2.7" "python-dotenv>=1.0"
pip freeze > requirements.txt

Two of those are new if you have only made chat calls before. pypdf opens PDF files and hands you their text. pydantic is a library that turns a description of your data into a class Python enforces at runtime.

Put your API key in a .env file beside your script:

OPENAI_API_KEY=sk-your-key-here

Add .env to your .gitignore before you write another line, because invoice work usually ends up in a shared repository:

echo ".env" >> .gitignore

If this script will eventually run on a server rather than your laptop, read Manage API Keys Safely in Production before you deploy it.

Step 1 — Pull the page text out of the PDF

A PDF is not a picture of a document unless somebody scanned it. Files produced by accounting software, billing portals and word processors carry a hidden text layer: the actual characters, with coordinates. pypdf reads that layer directly, which costs nothing and takes milliseconds. Save this as read_pdf.py:

from pathlib import Path
from pypdf import PdfReader


def read_pdf_text(path: str | Path) -> str:
    """Return the text layer of every page, joined with page markers."""
    reader = PdfReader(str(path))
    chunks = []
    for number, page in enumerate(reader.pages, start=1):
        chunks.append(f"--- page {number} ---")
        chunks.append(page.extract_text() or "")
    return "\n".join(chunks)


if __name__ == "__main__":
    text = read_pdf_text("invoices/acme-4471.pdf")
    print(f"{len(text)} characters extracted")
    print(text[:800])

Run it and read the output. You will see the column alignment collapse — a table that looked tidy in a viewer arrives as a stream of values in reading order. That is fine. You are not going to parse those columns yourself; the model handles the layout, and that is the whole reason you are using one. The extraction step exists only to hand it clean characters.

Notice page.extract_text() or "". When a page has no text layer, pypdf returns None, and joining None into a string raises TypeError. That single or "" is the difference between a script that survives a mixed inbox and one that dies on the first scan.

Here is where each stage sits, and which of them is allowed to touch a number.

The five stages of a checked invoice extraction pipeline A flow running from the PDF file through pypdf text extraction and a model call in JSON mode, into pydantic validation and an arithmetic cross-check, which then splits into two outcomes: post the invoice, or send it for human review. Read the PDF pypdf page text Ask the model JSON mode, temp 0 Validate shape pydantic model Check the maths lines vs. total Post the invoice totals agree Human review mismatch found
Only the middle box involves the model; everything after it is ordinary Python, which is why a wrong figure gets caught rather than posted.

Step 2 — Describe the invoice as a pydantic model

Most tutorials describe the wanted fields in a paragraph of prompt text. That works until you add a field and forget to update the parser. Write the shape once, as a class, and everything downstream — the prompt, the validation, your editor's autocomplete — reads from that single definition.

Save this as invoice_model.py:

from datetime import date, datetime
from decimal import Decimal

from pydantic import BaseModel, Field, field_validator

DATE_FORMATS = ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d %B %Y", "%d %b %Y", "%B %d, %Y"]
SYMBOL_TO_CODE = {"£": "GBP", "€": "EUR", "₹": "INR", "CHF": "CHF"}


class LineItem(BaseModel):
    description: str
    quantity: Decimal = Decimal("1")
    unit_price: Decimal
    amount: Decimal


class Invoice(BaseModel):
    invoice_number: str = Field(description="Printed invoice or document number, exactly as shown")
    invoice_date: date | None = Field(default=None, description="Issue date, ISO 8601 if possible")
    supplier_name: str = Field(description="Company that issued the invoice, not the recipient")
    currency: str | None = Field(default=None, description="ISO 4217 code, or the symbol printed")
    line_items: list[LineItem] = Field(default_factory=list)
    subtotal: Decimal | None = None
    tax: Decimal | None = None
    total: Decimal | None = None

    @field_validator("invoice_date", mode="before")
    @classmethod
    def parse_any_date(cls, value):
        if value in (None, "", "null"):
            return None
        if isinstance(value, date):
            return value
        raw = str(value).strip()
        for fmt in DATE_FORMATS:
            try:
                return datetime.strptime(raw, fmt).date()
            except ValueError:
                continue
        raise ValueError(f"unrecognised date format: {raw!r}")

    @field_validator("currency", mode="before")
    @classmethod
    def to_iso_code(cls, value):
        if value is None:
            return None
        raw = str(value).strip()
        if len(raw) == 3 and raw.isalpha():
            return raw.upper()
        return SYMBOL_TO_CODE.get(raw)

Three decisions in that file are worth spelling out.

Decimal, never float. A float stores 0.1 as the nearest available binary fraction, so summing invoice lines with floats drifts by fractions of a penny and your comparison fails on a perfectly correct invoice. Decimal stores the digits you were given.

Dates get normalised, not trusted. 03/04/2026 is 3 April in London and 4 March in Chicago, and no model can resolve that from the digits alone. DATE_FORMATS is ordered so day-first wins; put the month-first pattern ahead of it if your suppliers are American. Whichever you pick, the output is always a real date object, so a downstream sort or filter behaves.

$ is deliberately missing from SYMBOL_TO_CODE. The dollar sign belongs to more than a dozen currencies. Mapping it to USD would be a guess dressed as data, so the validator returns None instead and the check in Step 4 flags it. Add "$": "USD" only if every supplier you process is American.

Step 3 — Ask the model for JSON at temperature 0

Now hand the page text and the schema to the model. Two settings do the heavy lifting. response_format={"type": "json_object"} is JSON mode, which constrains the reply to valid JSON so you never get a friendly sentence wrapped around your data. temperature=0 removes the randomness the model would otherwise use when choosing between similar words; see What Is Temperature in an LLM API? for what that dial actually does. Save this one as extract.py:

import json
import os

from dotenv import load_dotenv
from openai import OpenAI

from invoice_model import Invoice

load_dotenv()                       # .env is in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

SCHEMA = json.dumps(Invoice.model_json_schema(), indent=2)

SYSTEM_PROMPT = (
    "You extract data from supplier invoices and reply with JSON only. "
    "Copy every value exactly as printed, including trailing zeros. "
    "Never calculate, never round, never infer a missing value. "
    "If a field does not appear on the page, use null."
)


def extract_invoice_json(page_text: str, model: str = "gpt-4o-mini") -> str:
    response = client.chat.completions.create(
        model=model,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": (
                    "Return one JSON object matching this JSON Schema:\n"
                    f"{SCHEMA}\n\n"
                    "Invoice text:\n---\n"
                    f"{page_text}\n---"
                ),
            },
        ],
    )
    return response.choices[0].message.content

Invoice.model_json_schema() generates the JSON Schema from the class you wrote in Step 2, including every description= you added. Add a field to the model and the prompt grows a matching instruction on the next run — no prompt editing, no drift between what you ask for and what you parse.

The system prompt is short on purpose, and every sentence in it forbids something. "Never calculate" is the important one: without it, a model that sees line items but no printed total will helpfully add them up for you, and that invented figure looks identical to a real one. For more on shaping replies this way, see Write System Prompts that Control Output Format.

One practical limit: a very long invoice plus the schema can exceed the model's context window, the maximum amount of text one request may contain. Most invoices are one or two pages and fit easily. If yours do not, Fix the Context-Length-Exceeded Error in Python explains the error, and Split Long Text into Chunks for AI APIs covers splitting input sensibly.

Step 4 — Verify the numbers in Python

This is the step that makes the pipeline trustworthy. Invoice.model_validate_json checks the shape: required fields present, numbers parseable, the date real. Then your own code checks the meaning: does quantity times unit price equal the line amount, do the lines sum to the subtotal, does subtotal plus tax equal the total? Save it as audit.py:

from decimal import Decimal

from invoice_model import Invoice

TOLERANCE = Decimal("0.02")         # absorbs legitimate per-line rounding


def audit_invoice(raw_json: str) -> tuple[Invoice, list[str]]:
    """Validate the model's JSON, then re-check every figure in Python."""
    invoice = Invoice.model_validate_json(raw_json)
    problems: list[str] = []

    for index, item in enumerate(invoice.line_items, start=1):
        expected = (item.quantity * item.unit_price).quantize(Decimal("0.01"))
        if abs(expected - item.amount) > TOLERANCE:
            problems.append(
                f"line {index}: {item.quantity} x {item.unit_price} = {expected}, "
                f"page shows {item.amount}"
            )

    lines_sum = sum((item.amount for item in invoice.line_items), Decimal("0"))
    if invoice.subtotal is not None and abs(lines_sum - invoice.subtotal) > TOLERANCE:
        problems.append(f"line items sum to {lines_sum}, subtotal shows {invoice.subtotal}")

    if invoice.total is not None:
        base = invoice.subtotal if invoice.subtotal is not None else lines_sum
        expected_total = base + (invoice.tax or Decimal("0"))
        if abs(expected_total - invoice.total) > TOLERANCE:
            problems.append(f"subtotal plus tax = {expected_total}, total shows {invoice.total}")
    else:
        problems.append("no total found on the page")

    if invoice.currency is None:
        problems.append("currency could not be resolved to an ISO code")
    if invoice.invoice_date is None:
        problems.append("no invoice date found")

    return invoice, problems

The tolerance of two cents is not laziness. Suppliers round each line to the nearest cent before adding them, so a strictly exact comparison rejects honest invoices. Two cents absorbs that; a transposed digit produces a difference far larger and still gets caught.

When problems comes back non-empty, do not repair the record and do not retry the model hoping for a better answer. Store the invoice with the problem list attached and route it to a person. The value of this pipeline is not that it is never wrong — it is that it always knows when it might be.

Each field earns its own kind of check, and each failure has its own sensible response.

Three invoice fields, the Python check applied to each, and the failure action A three-row matrix pairing the line items and total, the invoice date, and the currency with the verification Python performs on each and what the script does when that verification fails. Field Python's check If it fails Line items and total Sum lines, compare against the total Hold for review never auto-post Invoice date any layout Parse to ISO date reject if unclear Keep the raw text and flag it Currency symbol or code Match a 3-letter ISO code Leave it empty never guess
Every rule in the middle column is plain Python, so it produces the same verdict on the same invoice forever — which is exactly what an audit needs.

Step 5 — Run the whole thing on a folder

With the three pieces written, the runner is short. It walks a directory, extracts, audits, and prints a one-line verdict per file so you can see at a glance which invoices need a human. It imports the three modules you have just written, so keep all four files in the same folder.

import json
from pathlib import Path

from audit import audit_invoice
from extract import extract_invoice_json
from read_pdf import read_pdf_text

INBOX = Path("invoices")
RESULTS = Path("results")
RESULTS.mkdir(exist_ok=True)

for pdf_path in sorted(INBOX.glob("*.pdf")):
    text = read_pdf_text(pdf_path)
    if len(text.strip()) < 50:
        print(f"{pdf_path.name}: SCAN — no text layer, needs image extraction")
        continue

    raw = extract_invoice_json(text)
    invoice, problems = audit_invoice(raw)

    record = invoice.model_dump(mode="json")
    record["needs_review"] = bool(problems)
    record["problems"] = problems
    (RESULTS / f"{pdf_path.stem}.json").write_text(json.dumps(record, indent=2))

    status = "REVIEW" if problems else "OK"
    print(f"{pdf_path.name}: {status} {invoice.invoice_number} "
          f"{invoice.currency or '?'} {invoice.total} ({len(invoice.line_items)} lines)")
    for problem in problems:
        print(f"    - {problem}")

model_dump(mode="json") converts the Decimal and date objects back into strings that json.dumps accepts, so the saved file is portable. Point INBOX at a folder of real invoices and read the failures carefully — they tell you far more about your suppliers' documents than any success does. When you want this running on a schedule rather than by hand, Turn a Python AI Script into an API with FastAPI is the next step.

When the PDF is a scan

Sooner or later a supplier emails a photographed or scanned invoice. There is no text layer, extract_text() returns an empty string, and the model receives nothing to read. The len(text.strip()) < 50 guard in the runner catches this, and the threshold matters: some scans carry a stray header or a footer watermark as real text, so testing for "empty" is not enough.

Deciding whether a PDF page needs image-based extraction A decision tree that measures how many characters pypdf returned for a page, then routes pages with a real text layer to a text prompt and pages with almost no text to a rendered image sent to a vision model. Open the page pypdf extract_text How much text? count characters 50+ chars under 50 chars Real text layer born digital Looks scanned a picture, not text Parse with a model text prompt, JSON Send a page image to a vision model
One character count decides the route, so a mixed inbox of digital and scanned invoices flows through the same script without manual sorting.

The image route needs two more packages, pypdfium2 to render a page and pillow to save it:

pip install "pypdfium2>=4.30" "pillow>=10.0"

Render the page to PNG, encode it as base64 (a way of writing binary data using ordinary text characters, which is how images travel inside a JSON request), and send it alongside the same schema:

import base64
import io
import json

import pypdfium2 as pdfium

from extract import SCHEMA, SYSTEM_PROMPT, client


def page_to_base64_png(pdf_path: str, page_index: int = 0, scale: float = 2.0) -> str:
    """Render one PDF page to a PNG and return it as a base64 string."""
    document = pdfium.PdfDocument(pdf_path)
    image = document[page_index].render(scale=scale).to_pil()
    buffer = io.BytesIO()
    image.save(buffer, format="PNG")
    return base64.b64encode(buffer.getvalue()).decode("ascii")


def extract_from_scan(pdf_path: str, model: str = "gpt-4o") -> str:
    encoded = page_to_base64_png(pdf_path)
    response = client.chat.completions.create(
        model=model,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": f"Return JSON matching this schema:\n{SCHEMA}"},
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/png;base64,{encoded}"},
                    },
                ],
            },
        ],
    )
    return response.choices[0].message.content

scale=2.0 renders at roughly twice the default resolution, which is usually the difference between a legible 8 and a 3. The audit from Step 4 runs on this output unchanged — the verification does not care how the JSON was produced, which is the payoff for keeping reading and checking in separate functions. Vision-capable models cost more per page than text ones, so route scans deliberately rather than sending everything down this path; Estimate OpenAI API Costs with Python shows how to price it before you run a backlog.

Field validation reference

FieldValidation rule
invoice_numberNon-empty string, copied verbatim; never reformatted or padded.
invoice_dateParsed against DATE_FORMATS into a real date; unparseable means flag.
currencyThree alphabetic characters, or an unambiguous symbol; $ stays None.
line_itemsquantity * unit_price matches amount within a two-cent tolerance.
totalEquals subtotal + tax, and subtotal equals the summed line amounts.

Troubleshooting

  • TypeError: sequence item 1: expected str instance, NoneType found — a page had no text layer and extract_text() returned None. Cause: a scanned page in a mixed file. Fix: keep the or "" fallback in read_pdf_text, then route that file through the image path.
  • pydantic_core._pydantic_core.ValidationError: 1 validation error for Invoice — the model returned a field in the wrong type, most often total as "1,204.50" with a thousands separator. Cause: your prompt did not forbid formatting. Fix: add "write numbers without thousands separators or currency symbols" to SYSTEM_PROMPT and re-run.
  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 — you parsed a reply that was not JSON, which happens when JSON mode is omitted. Cause: a missing response_format argument. Fix: set response_format={"type": "json_object"}; Fix JSONDecodeError with AI API Responses in Python covers the other causes.
  • openai.RateLimitError: Error code: 429 — you looped over a large folder faster than your account allows. Cause: no pacing between calls. Fix: add a short sleep or a retry with backoff, as described in Fix the 429 Rate-Limit Error in Python.

When to use this vs. alternatives

  • Use this pipeline when invoices arrive in many layouts from many suppliers and you cannot write a template per sender. Handling layout variation is precisely what a model buys you, and the arithmetic check is what makes the result safe to store.
  • Use a rules-based parser instead when a single supplier sends thousands of identical invoices. A fixed template with pypdf coordinates is faster, free to run, and fully deterministic — a model adds cost and uncertainty for no benefit.
  • Use a dedicated invoice-capture service instead when you need audit trails, approval workflows and accounting-system connectors out of the box. Building those yourself is a much larger project than extraction; this script is for when you want the data in your own database, on your own terms.

Once invoices are flowing, the same reading-and-checking pattern extends across your whole document pile: Classify Incoming Documents with Python and AI decides what each arriving file is before extraction begins, Summarize Long PDFs with Python and Chunking handles contracts too long for one request, and Build a Document Question-Answering Tool lets you ask questions across the archive you have accumulated. Back to AI Document Processing with Python.

Frequently asked questions

Can AI read invoices from PDFs accurately?

It reads them well and copies them badly if you let it. A modern model finds the invoice number, date, supplier and line items across almost any layout, but it can drop a digit or quietly round a figure. The fix is to have Python re-add the line items and compare them to the printed total before you trust the result.

Do I need OCR to extract invoice data from a PDF?

Only if the file is a scan. A PDF exported from accounting software carries a real text layer that pypdf reads instantly, with no OCR involved. If pypdf returns almost nothing for a page, that page is a picture, and you either run OCR on it or send the rendered image to a vision-capable model.

Why should I use a pydantic model instead of just parsing the JSON?

Because a pydantic model turns your expectations into code that fails loudly. It checks that the total really is a number, that the date really is a date, and that no field you rely on is missing. Plain json.loads accepts any shape the model happens to return, so a bad answer reaches your database untouched.

What temperature should I use for invoice extraction?

Set temperature to 0. Temperature controls how much randomness the model adds when picking its next word. For creative writing that variety helps, but for copying a number off a page you want the same input to give the same output every time, so turn the randomness off entirely.

How do I handle invoices where the totals do not add up?

Send them to a person. A mismatch between the summed line items and the stated total means either the model misread a figure or the supplier made an error, and you cannot tell which from code alone. Store the record with a review flag and the exact discrepancy, and never post it automatically.