Skip to content
Docs Security Sign in
PdfParse

OCR API Python guide, from scanned PDF to structured JSON

Published October 20, 2026. Scanned PDFs are images wrapped in a PDF container. To get data out of them in Python you need OCR, and plain OCR only gets you halfway: it returns characters and lines, while your application needs fields, tables and values it can store. This guide walks through the full path with an OCR API in Python, from detecting scanned pages to rotation, resolution, confidence scores and validated JSON.

First find out which pages need OCR

A born digital PDF has a text layer: the characters are stored as text with positions. A scanned PDF has one image per page and no text layer. Many files mix both, for example a typed contract with a scanned signature page, or an invoice batch where some suppliers send real PDFs and others send photos of paper.

Running OCR on a page that already has a text layer wastes time and can make results worse, because the original text is exact and OCR is an estimate. Running a text extractor on a scanned page returns an empty string and you lose the page without an error. Checking each page first avoids both mistakes.

Python
# pip install pypdf
from pypdf import PdfReader

def classify_pages(path, min_chars=25):
    reader = PdfReader(path)
    report = []
    for number, page in enumerate(reader.pages, start=1):
        text = (page.extract_text() or "").strip()
        has_images = len(page.images) > 0
        if len(text) >= min_chars:
            kind = "text"
        elif has_images:
            kind = "scanned"
        else:
            kind = "empty"
        report.append((number, kind, len(text)))
    return report

for number, kind, chars in classify_pages("supplier-batch.pdf"):
    print(number, kind, chars)

Watch for one trap. Some scanners add an invisible OCR text layer of poor quality. The page then looks like it has text, but the text is full of errors. If extracted text on a page contains many words that are not in your vocabulary, or many single characters separated by spaces, treat the page as scanned and OCR it again.

A local OCR baseline with open source tools

Before reaching for an API, it helps to see what a local pipeline looks like. The usual stack renders PDF pages to images, then runs an open source OCR engine on each image.

Python
# pip install pdf2image pytesseract pillow
# also needs the poppler and tesseract system packages
from pdf2image import convert_from_path
import pytesseract

pages = convert_from_path("scanned-form.pdf", dpi=300)
for number, image in enumerate(pages, start=1):
    text = pytesseract.image_to_string(image, lang="eng")
    print(f"--- page {number} ---")
    print(text[:500])

This works on clean, straight, high resolution scans of single column text. It returns a flat string, so you still have to find the invoice number, rebuild the line item table and decide which value is the total. You also have to install and maintain system packages on every machine that runs the job, tune page segmentation modes, and handle the cases in the next section yourself.

Rotated and skewed pages

Pages arrive rotated by 90 or 180 degrees when someone feeds paper sideways, and slightly skewed when a phone camera is not parallel to the page. OCR engines read line by line, so a page rotated by 90 degrees produces nonsense and a skew of a few degrees breaks lines apart.

The fix has two steps. Detect the coarse orientation (0, 90, 180, 270) and rotate the image. Then estimate the small skew angle from text lines and straighten the image.

Python
# pip install opencv-python numpy
import cv2
import numpy as np

def deskew(image_path):
    image = cv2.imread(image_path)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    coords = np.column_stack(np.where(binary > 0))
    angle = cv2.minAreaRect(coords)[-1]
    if angle > 45:
        angle = angle - 90
    h, w = gray.shape
    matrix = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
    return cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_CUBIC,
                          borderMode=cv2.BORDER_REPLICATE)

This estimate is sensitive to stamps, logos and table borders, which all add dark pixels that are not text. It is a reasonable starting point and a good illustration of why production OCR pipelines treat orientation as its own step.

Low DPI scans and phone photos

Resolution decides what OCR can see. At 300 DPI a 10 point character is around 40 pixels tall. At 100 DPI it is around 14 pixels, and characters such as "8" and "B", or "0" and "O", start to look alike. Fax quality images and heavily compressed JPGs lose the thin strokes that separate "1" from "l".

  • Render PDF pages at 300 DPI when you control the rendering.
  • Do not upscale a low resolution image and expect new detail, upscaling only smooths existing pixels.
  • Convert to grayscale and apply adaptive thresholding for uneven lighting in phone photos.
  • Keep the original file, so a person can review the source when confidence is low.

Tables and forms on scanned pages

On a born digital page you at least know where each character sits. On a scan, you first have to find the table grid in the image, then assign each recognized word to a cell. Faint ruling lines, handwriting in boxes and checkboxes make this harder. A checkbox is not a character at all: you need to detect the box and decide whether it is filled. PdfParse returns checkboxes as their own block type with a boolean value and a confidence, and returns tables as rows and columns. The extract tables from pdf python guide explains the table side in depth.

Images, phone photos and multi page TIFF files

Scans do not always arrive as PDFs. Mobile apps upload JPG and PNG photos, document management systems export multi page TIFF files, and email inboxes collect all three. A good pipeline accepts each format as it is, instead of converting everything to PDF first and losing resolution on the way.

A multi page TIFF stores several frames in one file. Each frame is a page, and frames inside the same file can have different resolutions and orientations. If you handle TIFF files locally, iterate over the frames explicitly.

Python
# pip install pillow
from PIL import Image, ImageSequence

with Image.open("archive-scan.tif") as tif:
    for number, frame in enumerate(ImageSequence.Iterator(tif), start=1):
        dpi = frame.info.get("dpi", (0, 0))
        print(number, frame.size, frame.mode, "dpi", dpi)

Phone photos bring their own problems: perspective distortion when the camera is not above the page, shadows from a hand or a lamp, and an orientation flag stored in EXIF metadata rather than in the pixels. Apply the EXIF orientation before any other step, or a photo that looks upright on your phone reaches the OCR engine on its side.

Python
from PIL import Image, ImageOps

with Image.open("receipt-photo.jpg") as photo:
    upright = ImageOps.exif_transpose(photo)
    upright.save("receipt-upright.png")

PdfParse accepts PDF, scanned PDF, PNG, JPG and TIFF, including multi page files, and counts one page per PDF page or per image file. You send the file as it arrived.

Common OCR errors and how to catch them

Most OCR mistakes fall into a few patterns. Knowing them lets you write cheap checks that target the fields where they hurt most.

Error pattern Where it shows up Cheap check
Similar characters swapped, such as 0 and O or 1 and l IDs, invoice numbers, IBANs Validate format with a pattern or a check digit
Missing or extra decimal separator Amounts Compare with totals and line sums
Split or merged words Names, addresses Compare with known records
Lost minus sign or parentheses Credits, refunds Check sign against document type
Characters from a stamp or signature Totals near a stamp Low confidence on the block

Identifier fields benefit most from a format check. If invoice numbers from a supplier always start with letters and end with digits, you can correct the classic swaps in the digit part and reject anything that still does not match.

Python
import re

DIGIT_FIXES = str.maketrans({"O": "0", "o": "0", "I": "1", "l": "1", "S": "5", "B": "8"})

def normalize_invoice_number(raw, pattern=r"^[A-Z]{2,4}-\d{4,8}$"):
    value = raw.strip().upper().replace(" ", "")
    prefix, sep, digits = value.partition("-")
    candidate = prefix + sep + digits.translate(DIGIT_FIXES)
    return candidate if re.match(pattern, candidate) else None

print(normalize_invoice_number("INV-20O7"))  # INV-2007

Only apply corrections like this where the format is known and strict. On open text fields the same substitutions create errors instead of fixing them.

Confidence scores and what to do with them

Every OCR result is an estimate, so every useful OCR API returns a confidence next to the text. Confidence is a ranking signal, not a probability you can trust in absolute terms. A value of 0.95 on one document type does not mean the same as 0.95 on another. What it reliably does is order results: low confidence blocks are where errors cluster.

Use confidence to route work, not to hide it.

Confidence band Typical action
High Accept and store automatically
Middle Accept, then validate with business rules
Low Send the page and the value to a review queue
Missing value Treat as low confidence and review

Calibrate the bands on your own documents. Take a sample of pages, compare extracted values with the truth, and set the thresholds where the error rate becomes unacceptable for that field. A total amount on an invoice deserves a stricter threshold than an open text note.

Run the OCR API from Python

PdfParse decides per page whether OCR is needed when you pass ocr="auto", corrects orientation and skew, detects tables, form fields and checkboxes, and returns structured JSON with a confidence on every block. Install the SDK and keep the key in an environment variable.

curl
pip install pdfparse-ai
export PDFPARSE_API_KEY="pp_live_your_key"
Python
import os
from pdfparse_ai import PdfParse

client = PdfParse(api_key=os.environ["PDFPARSE_API_KEY"])
result = client.parse("scanned-form.pdf", outputs=["json"], ocr="auto")

for page in result["pages"]:
    print("page", page["number"], page["width"], "x", page["height"])
    for block in page["blocks"]:
        print(f'  {block["type"]:<11} {block["confidence"]:.2f} {block["text"][:60]}')

Use ocr="always" when you know every page is a scan or the embedded text layer is unreliable, and ocr="off" for files that are born digital and must not be touched. The pages option limits the call to a range, which is useful when only the first page of each file matters.

Python
first_page = client.parse("scanned-form.pdf", outputs=["json"], ocr="always", pages="1-1")

The equivalent request with curl:

curl
curl https://api.pdfparse.ai/v1/parse \
  -H "Authorization: Bearer $PDFPARSE_API_KEY" \
  -F "[email protected]" \
  -F 'outputs=["json"]' \
  -F "ocr=auto"

More on engine behaviour, supported formats (PDF, scanned PDF, PNG, JPG, TIFF, multi page) and limits is on the ocr api page.

From OCR text to structured fields

Blocks with text and positions are useful for search and display. Most applications want named values instead: invoice number, invoice date, supplier, total. Define a schema once with field names, types and descriptions, and every parse returns those fields with a confidence and the source page.

Python
schema = {
    "name": "supplier_invoice",
    "fields": [
        {"name": "invoice_number", "type": "string", "description": "Invoice number printed near the top"},
        {"name": "invoice_date", "type": "date", "description": "Date the invoice was issued"},
        {"name": "supplier_name", "type": "string", "description": "Company that issued the invoice"},
        {"name": "total_amount", "type": "money", "description": "Amount due including tax"},
        {"name": "paid_stamp", "type": "boolean", "description": "True when a PAID stamp is visible"}
    ]
}

result = client.parse("scanned-invoice.jpg", outputs=["json"], ocr="auto", schema=schema)

for name, field in result["fields"].items():
    print(name, field["value"], field["confidence"], "page", field["page"])

Custom extraction schemas, inline or saved with POST /v1/schemas and reused by schema_id, are part of the Growth plan (up to 10 schemas) and unlimited on Scale and above. See pdf extraction api pricing for the limits per plan.

Validate the JSON before you store it

OCR errors often produce values that look right and are wrong. Validation rules turn many of them into visible errors. Check types, ranges and relations between fields, and combine the result with confidence.

Python
from datetime import date
from decimal import Decimal, InvalidOperation

def validate_invoice(fields, min_confidence=0.85):
    issues = []
    for name, field in fields.items():
        if field.get("value") is None:
            issues.append((name, "missing"))
        elif field.get("confidence", 0) < min_confidence:
            issues.append((name, "low confidence"))

    try:
        total = Decimal(str(fields["total_amount"]["value"]["amount"]))
        if total <= 0:
            issues.append(("total_amount", "not positive"))
    except (KeyError, TypeError, InvalidOperation):
        issues.append(("total_amount", "not a number"))

    issued = fields.get("invoice_date", {}).get("value")
    if issued and date.fromisoformat(issued) > date.today():
        issues.append(("invoice_date", "in the future"))
    return issues

problems = validate_invoice(result["fields"])
if problems:
    print("send to review", problems)

When an invoice prints line items, compare the sum of line totals with the printed subtotal. A mismatch almost always means a misread digit or a missed row, and it tells you exactly which document to review.

Scaling up with batches and webhooks

For a folder of scans, do not loop over files and wait on each call. Submit a batch of up to 500 files per job (Growth and up) with an Idempotency-Key header, so a retry never creates a second job, and receive a parse.completed or parse.failed webhook for each document. Webhooks are signed with the PdfParse-Signature header, an HMAC SHA256 of the request body.

Python
import hashlib
import hmac
import os

def verify_signature(raw_body: bytes, header_value: str) -> bool:
    secret = os.environ["PDFPARSE_WEBHOOK_SECRET"].encode()
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_value)

Large single files return a job id instead of a result, and you read the status with GET /v1/jobs/{id}. The document parsing api reference lists every endpoint, and the python pdf parser page collects the SDK options in one place.

OCR in Python, a working checklist

  • Classify pages first and run OCR only where there is no usable text layer.
  • Correct orientation and skew before recognition.
  • Render at 300 DPI and keep the original file for review.
  • Read tables as rows and columns and checkboxes as booleans, not as loose characters.
  • Extract named fields with a schema, then validate types, ranges and totals.
  • Route low confidence values to people and keep the source page with every value.

If the output goes into a language model rather than a database, read convert pdf to markdown next. If you need spreadsheets, the pdf to csv page shows the CSV export.

Parse your first PDF

Pick the scanned form in the demo or drop your own scan, switch on "Run OCR on scanned pages" and compare the JSON with the image. Page one is parsed without an account and no card is needed.