Extract Table Data
Tables Are Structured Data Hidden in Documents
Tables carry some of the most valuable information in business documents: invoices, price lists, financial statements, test results, and inventory counts. Their meaning comes from structure: which value belongs to which row, and under which column heading. Reading a table as plain text often loses that structure, and that is where extraction errors come from.
Claude can read tables in text, in PDFs, and in images. The extraction approach is the same in each case: describe the rows as a schema, ask for the values exactly as they appear, and check the results against the table's own arithmetic.
Describe Rows, Not Tables
A table is a list of rows, so the schema should describe one row, and the result should be a list of rows:
class InvoiceLine(BaseModel):
product: str = Field(description="Product name, exactly as written in the table")
quantity: int = Field(description="Quantity as a whole number")
unit_price_inr: int = Field(description="Unit price in whole rupees")
total_inr: int = Field(description="Line total in whole rupees, exactly as written in the table")
class InvoiceTable(BaseModel):
lines: list[InvoiceLine] = Field(description="Every data row of the table, in order; no header or total rows")
grand_total_inr: int | None = Field(description="The grand total from the table's total row, or null if none")
Two details matter:
- Exclude header and total rows from
lines. Totals look like products to a model that is not told otherwise, and would be double-counted in later checks. - Copy values exactly. Tell Claude not to correct arithmetic. If a source table contains an error, the extraction should preserve it, so that your check can detect it. A "helpful" correction would hide the error.
Checking Tables Against Themselves
Tables contain redundant information: a line total should equal quantity times unit price, and the lines should add up to the grand total. These relationships give you a free, objective check on the extraction, and on the source:
| Check | Meaning |
|---|---|
quantity × unit_price = total for each line | The row is internally consistent |
| Sum of line totals = grand total | The table adds up |
| Row count matches what the document says | No rows were missed |
If an extraction fails these checks, either the extraction is wrong, or the source table is inconsistent. Both are worth knowing about, and a person should review them.
Practical Example: Checking an Invoice Table
This example extracts an invoice table from text, then checks each row and the total. The sample table contains one deliberate arithmetic error, so the checks have something to find.
"""invoice_table.py: extract a table as rows, then check the arithmetic."""
from anthropic import Anthropic
from pydantic import BaseModel, Field
client = Anthropic()
MODEL = "claude-opus-5-5"
TABLE = """Invoice INV-2026-118, 9 November 2026
| Product | Qty | Unit price (INR) | Line total (INR) |
|------------------|-----|------------------|------------------|
| Python Basics | 2 | 499 | 1000 |
| SQL Workbook | 1 | 899 | 899 |
| Django Starter | 3 | 1200 | 3600 |
Grand total (INR): 5599
"""
class InvoiceLine(BaseModel):
product: str = Field(description="Product name, exactly as written in the table")
quantity: int = Field(description="Quantity as a whole number")
unit_price_inr: int = Field(description="Unit price in whole rupees")
total_inr: int = Field(description="Line total in whole rupees, exactly as written in the table")
class InvoiceTable(BaseModel):
lines: list[InvoiceLine] = Field(description="Every data row of the table, in order; no header or total rows")
grand_total_inr: int | None = Field(description="The grand total from the table's total line, or null")
def extract_table(text: str) -> InvoiceTable:
response = client.messages.parse(
model=MODEL,
max_tokens=2048,
system="Extract the table exactly as written. Copy every value as it appears, and do not "
"correct any arithmetic, even if a value looks wrong.",
output_format=InvoiceTable,
messages=[{"role": "user", "content": f"<document>\n{text}\n</document>"}],
)
if response.stop_reason != "end_turn" or response.parsed_output is None:
raise RuntimeError(f"no table extracted (stop_reason={response.stop_reason})")
return response.parsed_output
def check_table(table: InvoiceTable) -> list[str]:
problems = []
for line in table.lines:
expected = line.quantity * line.unit_price_inr
if expected != line.total_inr:
problems.append(f"{line.product}: {line.quantity} x {line.unit_price_inr} = {expected}, "
f"but the table says {line.total_inr}")
if table.grand_total_inr is not None:
computed = sum(line.total_inr for line in table.lines)
if computed != table.grand_total_inr:
problems.append(f"lines add up to {computed}, but the grand total says {table.grand_total_inr}")
return problems
if __name__ == "__main__":
table = extract_table(TABLE)
for line in table.lines:
print(f"{line.product:<16} {line.quantity:>3} x {line.unit_price_inr:>5} = {line.total_inr:>5}")
print("grand total:", table.grand_total_inr)
print("problems:", check_table(table) or "none")
Expected output (the extracted rows should match the table; the checks find two problems):
Python Basics 2 x 499 = 1000
SQL Workbook 1 x 899 = 899
Django Starter 3 x 1200 = 3600
grand total: 5599
problems: ['Python Basics: 2 x 499 = 998, but the table says 1000', 'lines add up to 5499, but the grand total says 5599']
How It Works
InvoiceLinedescribes one row, andInvoiceTableholds the list of rows plus the grand total. The total is nullable, because not every table has one.- The system prompt forbids correction. Claude is asked to copy values exactly, which is what makes the check meaningful. A table with an arithmetic error must come out with the error still in it.
extract_table()raises on incomplete responses, so a truncated table is never mistaken for a complete one.check_table()verifies each line. For the first product, 2 × 499 = 998, but the table says 1000. The check reports the discrepancy with both numbers, so a person can compare them with the source.- The total check adds the line totals as the table states them (1000 + 899 + 3600 = 5499) and compares the sum with the stated grand total of 5599. The two problems have different causes: one row is wrong, and the table's total does not match its own rows.
- The printed rows confirm the extraction before the checks run, which is useful during development.
Tables in PDFs and Images
The same approach applies to a table in a PDF or image (Lessons 111–114). Send the document as the content block, and keep the schema and checks unchanged. Scanned or photographed tables, however, are more error-prone than digital tables, because misread digits break the arithmetic checks. The checks are especially valuable there, because they expose misreadings that would otherwise go unnoticed. When a check fails, ask Claude to re-read the table, or send the table for human verification.
Common Mistakes
Including Header and Total Rows
What happens: totals are counted as products, and sums double.
Fix: state explicitly that only data rows belong in lines.
Asking Claude to Correct Arithmetic
What happens: source errors disappear, and the checks pass for the wrong reason.
Fix: instruct Claude to copy values exactly, and check the arithmetic in code.
Describing the Table as Text Instead of Rows
What happens: the output is a paragraph, not records that code can use.
Fix: use a row model and a list.
Losing Column Meaning in Merged Cells
What happens: values shift into the wrong columns, or repeat across merged rows.
Fix: describe how merged cells should be handled, and verify the checks.
Trusting Scanned Tables Without Checks
What happens: a misread digit goes into the database unnoticed.
Fix: use arithmetic checks, and route failures to review.
Best Practices
- Model one row, and return a list.
- Exclude headers and totals from the rows, and capture totals separately.
- Copy values exactly, and never ask for corrections.
- Verify arithmetic in code, since tables carry their own redundancy.
- Report problems with both numbers, so reviewers can compare.
- Route failed checks to review rather than storing them silently.
Exercise
- Change the table's
Django Startertotal from 3600 to 3500, and run the checks. Which problems appear, and what does each one tell you? - Add a
row_count_in_documentfield toInvoiceTable, and check that it equalslen(lines). - Write a check that every product name is unique, and explain whether it belongs in the schema or in code.
- Explain why the system prompt tells Claude not to correct arithmetic, even though correcting it would produce a cleaner result.
Step 1: two problems appear. First, the Django Starter row is internally inconsistent: 3 × 1200 = 3600, but the table now says 3500. Second, the line totals now add up to 1000 + 899 + 3500 = 5399, which does not match the grand total of 5599. The Python Basics row still shows its own problem, so the checks report three messages in total. Each message points to a different cause, and the fix belongs in the source document, not in the extraction.
Step 4: a corrected value would look like good data, and the arithmetic check would then pass. The error would still be in the source, but nothing would reveal it, and a person might trust a total that does not reflect the document. Preserving the value keeps the discrepancy visible, which is the purpose of the check. Correction is a decision for a human reviewer, not a silent step in the extraction.