Build a Receipt-Parsing Pipeline: Claude Vision + Pydantic + SQLite in Python
End-to-end Python project: a receipt photo, Claude vision extracts items, prices, tax, totals; Pydantic validates; SQLite stores; CSV exports.
Image: Anthropic vision guide, used for editorial coverage of the API surface the tutorial builds against.
What you will build
A small Python project that turns a receipt photograph into a row of structured data. The reader uploads a JPEG or PNG, the script base64-encodes it and sends it to Claude with a structured-output prompt, a Pydantic schema validates the response, and the validated record lands in a local SQLite database. A second script exports every receipt to a CSV ready for an accounting tool or a spreadsheet. Per the Anthropic vision guide, every current Claude model accepts image input, so the same code path works against claude-opus-4-7, claude-sonnet-4-6, and claude-haiku-4-5. 1 The tutorial defaults to Claude Sonnet 4.6 because the source pricing page lists it at $3 per million input tokens and $15 per million output tokens, a middle band between the Opus and Haiku tiers. 2
The pipeline has four moving parts:
- Image loader. A helper reads a local file, infers the media type from the extension, and returns a base64 string. The Anthropic vision guide documents three input methods (base64 in
imagecontent blocks, public URL reference, or Files API); the tutorial uses base64 because it works on every endpoint, including Bedrock and Vertex AI which are base64-only. 3 - Extractor. The script calls
client.messages.createwith the image and a prompt asking for merchant, date, line items, tax, and total in JSON. The system prompt anchors the schema; the user message carries the image. - Validator. A Pydantic
BaseModelparses the response.Decimalfields preserve cent-accurate prices, amodel_validatorcross-checks that line-item subtotals plus tax match the printed total within a small tolerance, and afield_validatorrejects negative quantities. - Store and export. Validated receipts go into a SQLite database via the stdlib
sqlite3module, and a one-shot exporter writes the joined items-and-totals view to a CSV using the stdlibcsvmodule.
Image: Anthropic models overview, used for editorial coverage of the model IDs the tutorial passes to the SDK.
What you will need
- Python 3.11 or later.
- An Anthropic API key, set as
ANTHROPIC_API_KEYin the shell environment. - Two Python packages:
anthropicandpydantic. SQLite, CSV, and Decimal ship with the standard library. - A sample receipt image (JPEG or PNG). Per the Anthropic vision guide, the long edge should be at most 1,568 pixels for Sonnet and Haiku, or 2,576 pixels for Opus 4.7; larger images are resized server-side before inference, which can hurt small-text legibility. 4
python -m venv .venv
source .venv/bin/activate
pip install "anthropic>=0.40" "pydantic>=2.8"
The Anthropic Python SDK page on PyPI lists the current release; the >=0.40 floor pins a version that supports the claude-sonnet-4-6 model identifier the tutorial uses. 5 Pydantic v2 is the current major line; the model_validate and model_dump methods used in the schema below are v2 surface area documented under “Models” in the Pydantic docs. 6
Step 1: define the Pydantic schema
The schema is the contract between the model output and the database. The validator runs before any row hits SQLite, so a half-parsed receipt fails fast instead of corrupting the table. Save this as schema.py:
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class LineItem(BaseModel):
description: str = Field(min_length=1, max_length=200)
quantity: Decimal = Field(gt=0, max_digits=8, decimal_places=3)
unit_price: Decimal = Field(ge=0, max_digits=10, decimal_places=2)
line_total: Decimal = Field(ge=0, max_digits=10, decimal_places=2)
@field_validator("description", mode="before")
@classmethod
def strip_description(cls, v: str) -> str:
return v.strip()
class Receipt(BaseModel):
merchant: str = Field(min_length=1, max_length=200)
purchase_date: Optional[date] = None
currency: str = Field(min_length=3, max_length=3)
items: List[LineItem]
subtotal: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
tax: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
total: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
@model_validator(mode="after")
def totals_consistent(self) -> "Receipt":
items_sum = sum((i.line_total for i in self.items), Decimal("0"))
if abs(items_sum - self.subtotal) > Decimal("0.05"):
raise ValueError(
f"line-item sum {items_sum} does not match subtotal {self.subtotal}"
)
if abs(self.subtotal + self.tax - self.total) > Decimal("0.05"):
raise ValueError(
f"subtotal+tax {self.subtotal + self.tax} does not match total {self.total}"
)
return self
Three design choices follow the Pydantic concept docs. Decimal with max_digits and decimal_places is the documented pattern for monetary values; floats accumulate rounding error fast on receipts with twenty line items. 7 field_validator runs in before mode to strip whitespace before string-length checks fire. 8 model_validator(mode="after") runs once all individual fields have parsed, which is the documented place for cross-field checks. 8 The five-cent tolerance absorbs rounding from receipts that print line totals to two decimals while computing the printed total from un-rounded internal values.
Image: Pydantic — Models concept page, used for editorial coverage of the BaseModel surface the tutorial uses.
Step 2: send the image to Claude
Save this as extract.py. The base64-encode helper, the prompt, and the response unpack each fit on one screen:
from __future__ import annotations
import base64
import json
import mimetypes
from pathlib import Path
import anthropic
from schema import Receipt
MODEL_ID = "claude-sonnet-4-6"
SYSTEM_PROMPT = """You read shop receipts and return strict JSON.
Schema:
{
"merchant": str,
"purchase_date": "YYYY-MM-DD" or null,
"currency": ISO-4217 code (USD, EUR, INR, GBP, ...),
"items": [
{"description": str, "quantity": number,
"unit_price": number, "line_total": number}
],
"subtotal": number, "tax": number, "total": number
}
Rules: numbers must be JSON numbers, not strings; quantity defaults
to 1 when the receipt omits it; if a field is illegible, omit it
rather than guess. Return ONLY the JSON object, no prose."""
def encode_image(path: Path) -> tuple[str, str]:
media_type, _ = mimetypes.guess_type(path.name)
if media_type not in {"image/jpeg", "image/png", "image/webp", "image/gif"}:
raise ValueError(f"unsupported media type for {path}: {media_type}")
data = base64.standard_b64encode(path.read_bytes()).decode("ascii")
return media_type, data
def extract_receipt(path: Path) -> Receipt:
media_type, data = encode_image(path)
client = anthropic.Anthropic()
message = client.messages.create(
model=MODEL_ID,
max_tokens=1024,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
},
{
"type": "text",
"text": "Parse this receipt and return the JSON.",
},
],
}
],
)
raw = message.content[0].text.strip()
if raw.startswith("```"):
raw = raw.strip("`").lstrip("json").strip()
payload = json.loads(raw)
return Receipt.model_validate(payload)
if __name__ == "__main__":
import sys
receipt = extract_receipt(Path(sys.argv[1]))
print(receipt.model_dump_json(indent=2))
The content-block shape matches the vision guide’s documented base64 example for the Python SDK. 1 The system prompt does two jobs: it fixes the JSON shape so the parser does not see prose, and it tells the model to omit illegible fields rather than hallucinate them. The stripping branch handles models that fence the JSON in a triple-backtick block despite the instruction; both model_validate and model_dump_json are Pydantic v2 surface area documented under “Models”. 6
A first sanity run against any sample receipt prints the parsed JSON to the terminal:
python extract.py samples/coffee-receipt.jpg
If the JSON fails validation (a line total that does not match the subtotal, a negative quantity, an illegal currency length), Pydantic raises a ValidationError listing every failed field. That is the signal to inspect the photograph: a glare patch over a number is a common cause.
Image: Anthropic Python SDK on PyPI, used for editorial coverage of the SDK pin the tutorial uses.
Step 3: store every receipt in SQLite
The store layer is twenty lines of standard-library code. SQLite is built into Python; the stdlib sqlite3 module exposes a DB-API 2.0 cursor, and executemany keeps the line-item insert in one transaction. Save this as store.py:
from __future__ import annotations
import sqlite3
from contextlib import closing
from pathlib import Path
from schema import Receipt
SCHEMA = """
CREATE TABLE IF NOT EXISTS receipts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
merchant TEXT NOT NULL,
purchase_date TEXT,
currency TEXT NOT NULL,
subtotal TEXT NOT NULL,
tax TEXT NOT NULL,
total TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_id INTEGER NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity TEXT NOT NULL,
unit_price TEXT NOT NULL,
line_total TEXT NOT NULL
);
"""
def init_db(path: Path) -> None:
with closing(sqlite3.connect(path)) as conn:
conn.executescript(SCHEMA)
conn.commit()
def insert_receipt(path: Path, receipt: Receipt) -> int:
with closing(sqlite3.connect(path)) as conn:
cur = conn.execute(
"""INSERT INTO receipts
(merchant, purchase_date, currency, subtotal, tax, total)
VALUES (?, ?, ?, ?, ?, ?)""",
(
receipt.merchant,
receipt.purchase_date.isoformat() if receipt.purchase_date else None,
receipt.currency,
str(receipt.subtotal),
str(receipt.tax),
str(receipt.total),
),
)
receipt_id = cur.lastrowid
conn.executemany(
"""INSERT INTO items
(receipt_id, description, quantity, unit_price, line_total)
VALUES (?, ?, ?, ?, ?)""",
[
(
receipt_id,
i.description,
str(i.quantity),
str(i.unit_price),
str(i.line_total),
)
for i in receipt.items
],
)
conn.commit()
return receipt_id
Decimals are stored as text to preserve the exact string representation; SQLite has no native DECIMAL type, and storing money as REAL reintroduces the floating-point error the Pydantic schema is built to avoid. The Python decimal docs document the round-trip from text to Decimal and back as the safe pattern for financial data. 9 The ON DELETE CASCADE keeps the items table clean if a receipt is deleted; the stdlib sqlite3 reference page documents the connection and cursor surface used here. 10
Wire the extractor to the store with a five-line driver:
from pathlib import Path
from extract import extract_receipt
from store import init_db, insert_receipt
DB = Path("receipts.db")
init_db(DB)
receipt_id = insert_receipt(DB, extract_receipt(Path("samples/coffee-receipt.jpg")))
print(f"saved receipt {receipt_id}")
Step 4: export to CSV
The exporter is one more file, export.py. The stdlib csv module’s DictWriter is the documented way to write a header row plus rows from dicts. 11
from __future__ import annotations
import csv
import sqlite3
from contextlib import closing
from pathlib import Path
QUERY = """
SELECT r.id, r.merchant, r.purchase_date, r.currency,
i.description, i.quantity, i.unit_price, i.line_total,
r.subtotal, r.tax, r.total
FROM receipts r
JOIN items i ON i.receipt_id = r.id
ORDER BY r.id, i.id;
"""
FIELDS = [
"receipt_id", "merchant", "purchase_date", "currency",
"description", "quantity", "unit_price", "line_total",
"subtotal", "tax", "total",
]
def export_csv(db_path: Path, csv_path: Path) -> int:
with closing(sqlite3.connect(db_path)) as conn:
rows = conn.execute(QUERY).fetchall()
with csv_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for row in rows:
writer.writerow(dict(zip(FIELDS, row)))
return len(rows)
if __name__ == "__main__":
count = export_csv(Path("receipts.db"), Path("receipts.csv"))
print(f"wrote {count} line-item rows")
The query joins receipts and items, so each line item lands as its own CSV row with the parent receipt’s totals duplicated; that shape imports cleanly into spreadsheet pivots without a second sheet for header data.
Image: Pydantic — Fields concept page, used for editorial coverage of the Field constraints the schema uses.
Where this breaks, and what to do about it
The pipeline is intentionally small, so the failure modes are easy to spot. Three recur in practice:
- Faded thermal receipts. Sonnet 4.6 handles clean shop receipts well in the source-documented vision capabilities, but very faded thermal print loses contrast that no model can recover. The fix is in the photograph: shoot under bright even light, crop tight, and hold the camera parallel to the paper.
- Long receipts that exceed the long-edge resolution. Per the vision guide, images larger than 1,568 px on the long edge for Sonnet and Haiku are resized server-side before inference, which can squash small line-item text into illegibility; pre-resize on the client only if the original is much larger than the target. 4
- Currency drift. A receipt printed in a region the model does not see often (a small-shop receipt in a non-Latin script with no currency symbol) can return the wrong ISO-4217 code. The schema’s three-letter constraint catches the obvious cases; for the harder ones, pass a hint in the user message (
"This receipt is from a shop in Tokyo.").
The Pydantic model_validator is the safety net for the silent failure mode where the model returns plausible-looking but inconsistent numbers; a ValidationError on totals mismatch is a strong signal that one of the line totals was misread.
What is next
The same shape extends in three directions without rewriting the core. Swapping claude-sonnet-4-6 for claude-haiku-4-5 halves the input-token cost per the source pricing page, useful for high-volume scanning of many small receipts. 2 Adding a category column to items and including a short category-vocabulary list in the system prompt turns the same CSV into an expense-report draft. Wrapping the driver in a tiny FastAPI endpoint or a folder-watcher daemon turns the script into a service.
How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.
Sources consulted
Cited Sources
- 1. Anthropic — Vision guide: documents the three image input methods, the base64 content-block shape, and that every current Claude model accepts image input (accessed ) ↩
- 2. Anthropic — Models overview: claude-sonnet-4-6 at \$3 / input MTok and \$15 / output MTok; claude-haiku-4-5 at \$1 / input MTok and \$5 / output MTok (accessed ) ↩
- 3. Anthropic — Vision guide: "On Amazon Bedrock and Vertex AI, only base64-encoded sources are currently available." (accessed ) ↩
- 4. Anthropic — Vision guide: max native resolution is 2,576 px on the long edge for Opus 4.7 and 1,568 px on the long edge for other current models; larger images are resized server-side (accessed ) ↩
- 5. Anthropic Python SDK on PyPI — current release listing (accessed ) ↩
- 6. Pydantic — Models concept: BaseModel, model_validate, model_dump and model_dump_json surface area (accessed ) ↩
- 7. Pydantic — Fields concept: Field with max_digits and decimal_places for Decimal monetary values (accessed ) ↩
- 8. Pydantic — Validators concept: field_validator modes (before, after) and model_validator(mode="after") for cross-field checks (accessed ) ↩
- 9. Python stdlib decimal module — exact decimal arithmetic and string round-trip semantics (accessed ) ↩
- 10. Python stdlib sqlite3 module — Connection, Cursor, executemany, and foreign-key support (accessed ) ↩
- 11. Python stdlib csv module — DictWriter with fieldnames and writeheader (accessed ) ↩
Anonymous · no cookies set