🧭 How toDataIntermediate✨ AI-assisted

How to Extract Data From a PDF With Gemini Structured Output

WittyTech Daily··5 min read
#gemini#pdf-extraction#structured-output#pydantic

Your finance team has 4,000 invoice PDFs and a warehouse table that wants columns. By the end of this guide you will have a script that sends each PDF to Gemini and gets back JSON that matches a Pydantic schema, with nulls where the document is silent and a reject pile for anything your own checks refuse. That is Gemini structured output for PDF files, using the Python google-genai SDK and gemini-3.8-flash.

One thing changed recently. Google's docs now show the Interactions API, which is generally available, recommended for new projects and needs google-genai 2.3.0 or newer. generateContent is still fully supported. The code below follows the call shape in Google's PDF processing and structured output docs.

Install the SDK

pip install "google-genai>=2.3.0" pydantic
export GEMINI_API_KEY=<your-api-key>

genai.Client() reads the key from the environment. The schema below uses X | None types, so you need Python 3.10 or newer.

Define a Pydantic schema that allows nulls

from datetime import date

from pydantic import BaseModel, Field, model_validator


class LineItem(BaseModel):
    description: str
    quantity: float | None = Field(description="Units billed, null if not printed")
    amount: float = Field(description="Line total exactly as printed")


class Invoice(BaseModel):
    vendor_name: str = Field(min_length=1, description="Seller name as printed")
    invoice_number: str | None = Field(description="Invoice number, null if absent")
    invoice_date: date | None = Field(description="Issue date as YYYY-MM-DD, null if absent")
    currency: str | None = Field(min_length=3, max_length=3, description="ISO 4217 code, null if not stated")
    total: float | None = Field(description="Grand total including tax, null if absent")
    line_items: list[LineItem]

    @model_validator(mode="after")
    def line_items_fit_total(self):
        if self.total is not None and self.line_items:
            if sum(i.amount for i in self.line_items) > self.total + 0.01:
                raise ValueError("line items add up to more than the total")
        return self

The nullable fields have no default on purpose. That makes them required keys that may hold null, so the model has to decide on every field instead of quietly skipping one. Google's docs recommend the description field to steer the model, so each description says what to return when the page is silent.

Upload the PDF with the Files API

from google import genai

client = genai.Client()
pdf = client.files.upload(file="invoices/acme-2026-10.pdf")
print(pdf.uri, pdf.mime_type)

The Files API takes PDFs up to 50 MB or 1,000 pages, keeps them for 48 hours and is free. Each page counts as 258 tokens. Rotate sideways pages and avoid blurry scans, as the docs advise. For a one-off call you can send base64 data inline instead.

Run Gemini structured output on the PDF

PROMPT = (
    "Extract the invoice fields. Use null for any field that is not printed "
    "on the document. Never calculate or guess a value."
)


def extract_invoice(pdf) -> Invoice:
    interaction = client.interactions.create(
        model="gemini-3.8-flash",
        input=[
            {"type": "document", "uri": pdf.uri, "mime_type": pdf.mime_type},
            {"type": "text", "text": PROMPT},
        ],
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": Invoice.model_json_schema(),
        },
        store=False,
    )
    return Invoice.model_validate_json(interaction.output_text)

The prompt goes after the document, which the docs recommend. response_format carries the JSON schema from model_json_schema(), and store=False stops the interaction being saved on Google's side (see Gotchas). The last line is where Pydantic checks everything the API could not.

Validate in code and load the results

The API strips minLength, maxLength and a few other keywords from your schema instead of returning an error. It still enforces minItems, maxItems, minimum and maximum. So the model can hand back an empty vendor_name or a five-letter currency and the API will not mind. Pydantic will, and line_items_fit_total catches output that is valid JSON with wrong arithmetic.

import json
from pathlib import Path

from pydantic import ValidationError

rows, rejects = [], []

for path in sorted(Path("invoices").glob("*.pdf")):
    try:
        invoice = extract_invoice(client.files.upload(file=str(path)))
        rows.append({"source_file": path.name, **invoice.model_dump(mode="json")})
    except ValidationError as err:
        rejects.append({"source_file": path.name, "error": str(err)})

with open("invoices.jsonl", "w") as f:
    for row in rows:
        f.write(json.dumps(row) + "\n")

print(f"{len(rows)} loaded, {len(rejects)} sent to review")

The JSON Lines file loads straight into BigQuery, Snowflake or DuckDB. Rejects go to a human, not the warehouse.

What it costs per page

At the published rate of $0.75 per million input tokens for gemini-3.8-flash, 1,000 pages (258,000 tokens) cost about $0.19 in input. Output is $3.75 per million, thinking tokens included, so output dominates on short documents.

INPUT_USD_PER_M = 0.75   # gemini-3.8-flash until 2026-12-31, then 1.50
OUTPUT_USD_PER_M = 3.75  # includes thinking tokens, then 7.50


def estimate_usd(pages: int, output_tokens: int = 600, prompt_tokens: int = 200) -> float:
    input_tokens = pages * 258 + prompt_tokens
    return (input_tokens * INPUT_USD_PER_M + output_tokens * OUTPUT_USD_PER_M) / 1_000_000

estimate_usd(10) returns about $0.0043 for a 10-page invoice with a 600-token JSON answer, roughly $43 per 10,000 invoices. That is arithmetic on published rates, not a measurement. The response carries a usage object (total_input_tokens, total_output_tokens), so log it on a sample of your own documents before you quote a budget. Both rates double on 1 January 2027, so put that date in your cost model.

Gotchas

  • Valid is not correct. Schema-valid JSON can still hold a wrong total. Compare a sample against hand-keyed values.
  • Big schemas get rejected. Deep nesting, large maxItems, long enums and long runs of optional properties can return 400 INVALID_ARGUMENT. Keep one small schema per document type.
  • Retention. Interactions are stored by default (55 days on the paid tier, 1 day on the free tier) unless you pass store=False. Uploaded files disappear after 48 hours, so re-upload for reruns.
  • Batch discount. Batch rates are half price, but the Interactions guide lists the Batch API as not available yet. Check before you plan a bulk run around it.
  • Size limits. 50 MB or 1,000 pages per request. Split anything bigger.

One schema, one validator and a reject pile turn a folder of PDFs into warehouse rows, and the reject pile is what stops a confident model from writing wrong numbers into a production table.

← More in Data