All articles
Build Guides

Build a PDF Invoice Extractor That Outputs Structured JSON with Gemini Flash Free Tier

FDE Coach EditorialJuly 15, 202612 min read

What We're Building

We're building a command-line pipeline that accepts a PDF invoice or receipt and outputs a clean, structured JSON object containing the vendor name, invoice date, line items with quantities and prices, subtotal, tax, and total. No OCR services. No paid APIs. Just raw text extraction and a single call to Google's Gemini Flash API, which offers a generous free tier.

Feature list:

  • Accepts any text-based PDF (scanned/image PDFs need a small extension we'll cover)
  • Extracts raw text using PyPDF2 (or pdfplumber for better layout parsing)
  • Uses Gemini Flash to parse unstructured text into a strict JSON schema
  • Validates output with Pydantic models
  • Handles multi-page invoices and common date formats
  • Runs entirely within free-tier limits

Architecture Overview

The pipeline is a three-stage linear flow: extract raw text from the PDF, send that text to Gemini Flash with a structured prompt, and parse the LLM response into a Pydantic model. The Pydantic model acts as both the schema contract and the validation layer.

The key design decision is separation of concerns: text extraction knows nothing about invoices, and the LLM knows nothing about PDF parsing. This makes each stage independently testable and swappable. If you later need OCR for scanned PDFs, you replace only the extraction stage. If you want to use a different LLM, you replace only the API call.

Prerequisites

Everything here is free-tier or open-source. No credit card required to start.

Tools and libraries:

  • Python 3.10+python.org/downloads
  • Google Gemini Flash API — free tier gives 15 requests per minute, 1,500 requests per day. Get your API key at aistudio.google.com/apikey
  • PyPDF2pip install PyPDF2 (pure Python, no system dependencies)
  • pdfplumber (optional but recommended) — pip install pdfplumber (better table extraction, handles malformed PDFs)
  • Pydanticpip install pydantic (schema definition and validation)
  • google-generativeaipip install google-generativeai (official Google SDK)

Why Gemini Flash specifically: The free tier is generous enough for batch processing dozens of invoices daily. Flash is optimized for low-latency structured extraction tasks. For comparison, GPT-3.5 Turbo's free tier is rate-limited to the point of unusability for batch work, and local models require GPU resources. Flash hits the sweet spot.

Step 1: Project Setup and Dependencies

Create a new directory and set up a virtual environment:

mkdir invoice-extractor
cd invoice-extractor
python -m venv venv
source venv/bin/activate  # or venv\Scripts\activate on Windows

Install dependencies:

pip install PyPDF2 pdfplumber pydantic google-generativeai python-dotenv

Create a .env file in the project root:

GEMINI_API_KEY=your_key_here

Create the main script file:

touch extractor.py

Step 2: Extracting Raw Text from PDFs

We'll implement two extraction strategies: a basic PyPDF2 reader for simple PDFs, and a pdfplumber reader for complex layouts with tables. The function returns a single string regardless of page count.

import PyPDF2
import pdfplumber
from pathlib import Path


def extract_text_pypdf2(pdf_path: str) -> str:
    """Basic extraction using PyPDF2. Works well for simple, text-based PDFs."""
    text_parts = []
    with open(pdf_path, "rb") as f:
        reader = PyPDF2.PdfReader(f)
        for page in reader.pages:
            page_text = page.extract_text()
            if page_text:
                text_parts.append(page_text)
    return "\n---PAGE BREAK---\n".join(text_parts)


def extract_text_pdfplumber(pdf_path: str) -> str:
    """Better extraction using pdfplumber. Handles tables and irregular layouts."""
    text_parts = []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            page_text = page.extract_text()
            if page_text:
                text_parts.append(page_text)
    return "\n---PAGE BREAK---\n".join(text_parts)


def extract_text(pdf_path: str, method: str = "pdfplumber") -> str:
    """Unified interface for text extraction."""
    path = Path(pdf_path)
    if not path.exists():
        raise FileNotFoundError(f"PDF not found: {pdf_path}")
    
    if method == "pdfplumber":
        return extract_text_pdfplumber(pdf_path)
    else:
        return extract_text_pypdf2(pdf_path)

The ---PAGE BREAK--- delimiter is intentional. It gives Gemini a structural hint that content spans multiple pages, which helps it correctly associate line items that might wrap across page boundaries.

Step 3: Defining the Structured Output Schema

Pydantic models serve double duty: they define the JSON schema we want Gemini to output, and they validate the response at runtime. If Gemini hallucinates a field or returns a string where we expect a float, Pydantic catches it immediately.

from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date


class LineItem(BaseModel):
    description: str = Field(description="Product or service description")
    quantity: float = Field(default=1.0, description="Quantity")
    unit_price: float = Field(description="Price per unit")
    total_price: float = Field(description="Line total (quantity * unit_price)")


class Invoice(BaseModel):
    vendor_name: str = Field(description="Company or individual issuing the invoice")
    invoice_number: Optional[str] = Field(default=None, description="Invoice identifier if present")
    invoice_date: Optional[str] = Field(default=None, description="Date in YYYY-MM-DD format if present")
    due_date: Optional[str] = Field(default=None, description="Payment due date in YYYY-MM-DD format")
    line_items: List[LineItem] = Field(default_factory=list, description="All line items")
    subtotal: Optional[float] = Field(default=None, description="Subtotal before tax")
    tax: Optional[float] = Field(default=None, description="Tax amount")
    total: Optional[float] = Field(default=None, description="Total amount including tax")
    currency: Optional[str] = Field(default="USD", description="Currency code")

Why optional fields? Real-world invoices are messy. Some omit invoice numbers. Some have tax baked into line items rather than shown separately. Making fields optional with sensible defaults prevents the pipeline from failing on valid-but-incomplete invoices. The Field(description=...) metadata is critical — it gets passed to Gemini as part of the prompt, guiding the model toward correct extraction.

Step 4: Crafting the Gemini Flash Prompt

The prompt is the engine of this pipeline. We need to be explicit about the output format, edge cases, and fallback behavior. The key technique: we embed the Pydantic schema directly into the prompt by serializing the model's JSON schema.

import json
import google.generativeai as genai


def build_prompt(raw_text: str) -> str:
    """Construct the extraction prompt with schema embedded."""
    schema = Invoice.model_json_schema()
    schema_json = json.dumps(schema, indent=2)
    
    prompt = f"""You are an invoice data extraction system. Given the raw text extracted from a PDF invoice or receipt, extract the structured data according to the JSON schema below.

Rules:
1. Output ONLY valid JSON. No markdown fences, no explanatory text.
2. Convert all dates to YYYY-MM-DD format. If you see "Jan 15, 2024" output "2024-01-15".
3. For line items, extract the description, quantity, unit price, and total price. If quantity is not explicitly stated, assume 1.
4. If a field is not present in the text, omit it or use null — do not fabricate values.
5. Currency defaults to "USD" unless another currency symbol or code is clearly present.
6. If the text contains no recognizable invoice data, return an empty line_items array and leave other fields null.

JSON Schema:
{schema_json}

Raw PDF Text:
{raw_text}
"""
    return prompt

The schema embedding trick eliminates prompt drift. If you add a field to the Pydantic model, the prompt automatically updates. No manual synchronization required.

Step 5: Wiring the Extraction Pipeline

Now we connect text extraction, Gemini API call, and Pydantic validation into a single function. We'll configure Gemini for deterministic output (temperature=0) since extraction is not a creative task.

import os
from dotenv import load_dotenv

load_dotenv()


def configure_gemini():
    """Configure Gemini with API key from environment."""
    api_key = os.getenv("GEMINI_API_KEY")
    if not api_key:
        raise ValueError("GEMINI_API_KEY not set in .env file")
    genai.configure(api_key=api_key)


def extract_invoice(pdf_path: str, method: str = "pdfplumber") -> Invoice:
    """Full pipeline: PDF -> text -> Gemini -> Pydantic Invoice."""
    # Stage 1: Extract raw text
    raw_text = extract_text(pdf_path, method=method)
    
    if not raw_text.strip():
        raise ValueError(f"No text extracted from {pdf_path}. The PDF may be image-based.")
    
    # Stage 2: Call Gemini Flash
    configure_gemini()
    model = genai.GenerativeModel(
        model_name="gemini-1.5-flash",
        generation_config={
            "temperature": 0,
            "top_p": 1,
            "max_output_tokens": 2048,
        }
    )
    
    prompt = build_prompt(raw_text)
    response = model.generate_content(prompt)
    response_text = response.text.strip()
    
    # Strip markdown fences if Gemini ignores instructions
    if response_text.startswith("```json"):
        response_text = response_text[7:]
    if response_text.startswith("```"):
        response_text = response_text[3:]
    if response_text.endswith("```"):
        response_text = response_text[:-3]
    response_text = response_text.strip()
    
    # Stage 3: Parse and validate
    try:
        data = json.loads(response_text)
        invoice = Invoice(**data)
        return invoice
    except json.JSONDecodeError as e:
        raise ValueError(f"Gemini returned invalid JSON: {e}\nResponse: {response_text}")
    except Exception as e:
        raise ValueError(f"Pydantic validation failed: {e}\nData: {data}")

Why temperature=0: Invoice extraction is a deterministic parsing problem. We want reproducibility, not creativity. Setting temperature to zero minimizes hallucinations and ensures consistent output for the same input.

Markdown fence stripping: Despite explicit instructions, Gemini sometimes wraps JSON in ``` fences. The defensive stripping handles this without a retry.

Step 6: Running the Extractor

Add a CLI entry point that accepts a PDF path and prints the JSON output:

import argparse


def main():
    parser = argparse.ArgumentParser(description="Extract structured invoice data from PDF")
    parser.add_argument("pdf_path", help="Path to the PDF invoice or receipt")
    parser.add_argument("--method", default="pdfplumber", choices=["pdfplumber", "pypdf2"],
                        help="Text extraction method")
    parser.add_argument("--output", "-o", help="Output JSON file path (default: stdout)")
    args = parser.parse_args()
    
    try:
        invoice = extract_invoice(args.pdf_path, method=args.method)
        output_json = invoice.model_dump_json(indent=2)
        
        if args.output:
            with open(args.output, "w") as f:
                f.write(output_json)
            print(f"Output written to {args.output}")
        else:
            print(output_json)
    except Exception as e:
        print(f"Error: {e}")
        exit(1)


if __name__ == "__main__":
    main()

Run it against a sample invoice:

python extractor.py sample_invoice.pdf

Expected output:

{
  "vendor_name": "Acme Corp",
  "invoice_number": "INV-2024-0042",
  "invoice_date": "2024-03-15",
  "due_date": "2024-04-14",
  "line_items": [
    {
      "description": "Widget A",
      "quantity": 10.0,
      "unit_price": 25.00,
      "total_price": 250.00
    },
    {
      "description": "Widget B",
      "quantity": 5.0,
      "unit_price": 40.00,
      "total_price": 200.00
    }
  ],
  "subtotal": 450.00,
  "tax": 36.00,
  "total": 486.00,
  "currency": "USD"
}

Extensions and Improvements

Once the core pipeline works, several high-value extensions are straightforward:

Batch processing: Wrap extract_invoice in a loop over a directory of PDFs. Add a progress bar with tqdm. Rate-limit to stay within Gemini's free tier (15 RPM).

import time
from pathlib import Path

def batch_extract(pdf_dir: str, output_dir: str):
    pdf_files = list(Path(pdf_dir).glob("*.pdf"))
    for i, pdf_path in enumerate(pdf_files):
        if i > 0 and i % 14 == 0:  # Stay under 15 RPM
            time.sleep(60)
        invoice = extract_invoice(str(pdf_path))
        output_path = Path(output_dir) / f"{pdf_path.stem}.json"
        output_path.write_text(invoice.model_dump_json(indent=2))

OCR fallback for scanned PDFs: When extract_text returns an empty string, use pytesseract (free, open-source) to OCR the page images. Install with pip install pytesseract pdf2image and add an OCR extraction method. This is the bridge from text-based to image-based invoices.

Confidence scoring: Gemini doesn't natively return confidence scores, but you can prompt it to include a confidence field (0-1) per extracted field. Add this to the Pydantic model and ask Gemini to self-assess.

Database integration: Pipe the validated Invoice objects directly into SQLite or Postgres. The Pydantic model can map to an ORM model with minimal glue code. This is where the pipeline graduates from a script to a system—and where Forward Deployed Engineers spend most of their time. Understanding how to productionize extraction pipelines is a core skill covered in depth in our breakdown of what an FDE actually does in a week at an AI startup.

Common Pitfalls

1. Empty text extraction on image-based PDFs: PyPDF2 and pdfplumber only extract embedded text. If the PDF is scanned, you'll get an empty string. Solution: add the OCR fallback mentioned above.

2. Gemini rate limiting: The free tier allows 15 RPM. If you exceed this, you'll get a 429 error. Solution: implement exponential backoff or batch with delays.

3. Hallucinated values: Even with temperature=0, Gemini may invent invoice numbers or dates for ambiguous text. Solution: add a confidence field and flag low-confidence extractions for human review.

4. Multi-currency confusion: If an invoice uses "$" but the text mentions "CAD" elsewhere, Gemini may default to USD. Solution: add explicit currency detection logic in preprocessing.

5. Large PDFs exceeding token limits: Gemini Flash has a 1M token context window, which is enormous, but extremely dense PDFs with tiny text could theoretically approach this. If you hit the limit, chunk the text by page and aggregate results.

FAQ

Q: Why not use a dedicated invoice parsing API like Mindee or Veryfi? A: Those are excellent products, but they're paid beyond small trial tiers. This pipeline costs nothing and gives you full control over the schema. When you need to extract custom fields that off-the-shelf parsers don't support, you're not blocked.

Q: How accurate is Gemini Flash for this task? A: On clean, text-based invoices, accuracy is above 95% for core fields (vendor, total, date). Line-item extraction accuracy depends on PDF layout quality. For production use, always add validation and a human-review step for low-confidence extractions.

Q: Can I use this for handwritten receipts? A: Not directly. You'd need to add an OCR stage (pytesseract or a cloud OCR service) before the Gemini call. The LLM can handle messy OCR output reasonably well, but handwriting recognition remains the weak link.

Q: How does this compare to using LangChain or LlamaIndex? A: Those frameworks add abstraction layers that are useful for complex RAG pipelines but are overkill here. Direct API calls are simpler, faster, and easier to debug. If you're building a larger document intelligence system, frameworks make sense — and our SQL analyst agent guide using LlamaIndex shows how to structure that kind of system.

Q: What if Gemini returns JSON that doesn't match my schema? A: Pydantic will raise a ValidationError. Wrap the Invoice(**data) call in a try/except and either retry with a more explicit prompt or log the failure for manual review. In production systems, this is where you'd implement a self-correction loop — ask Gemini what went wrong and have it retry.

Q: Will this work for non-English invoices? A: Yes. Gemini Flash handles multiple languages. The Pydantic field names remain in English, but the extracted values will be in the invoice's language. You may want to add a language field to the schema for downstream processing.


Next steps: If you're building extraction pipelines that need to feed into larger agentic workflows — say, an invoice extractor that triggers a multi-agent approval process — the architecture patterns overlap significantly with multi-agent research systems. The same principles of separation of concerns, structured output contracts, and defensive parsing apply across both domains.

#document-extraction#ocr#gemini#pdf#structured-data

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now