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.
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.
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.
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
| Field | Validation rule |
|---|---|
invoice_number | Non-empty string, copied verbatim; never reformatted or padded. |
invoice_date | Parsed against DATE_FORMATS into a real date; unparseable means flag. |
currency | Three alphabetic characters, or an unambiguous symbol; $ stays None. |
line_items | quantity * unit_price matches amount within a two-cent tolerance. |
total | Equals 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 andextract_text()returnedNone. Cause: a scanned page in a mixed file. Fix: keep theor ""fallback inread_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 oftentotalas"1,204.50"with a thousands separator. Cause: your prompt did not forbid formatting. Fix: add "write numbers without thousands separators or currency symbols" toSYSTEM_PROMPTand 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 missingresponse_formatargument. Fix: setresponse_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
pypdfcoordinates 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.
Related guides
- AI Document Processing with Python — the main guide for this section, from intake to structured output.
- Classify Incoming Documents with Python and AI — sort invoices from contracts before you extract anything.
- Summarize Long PDFs with Python and Chunking — for documents that will not fit in a single request.
- Build a Document Question-Answering Tool — query the archive once the data is in.
- What Is Temperature in an LLM API? — why extraction work always runs at zero.