pdfmux/blog
python

Extract financial statement data from PDF in Python (balance sheet, income statement)

TL;DRParse balance sheets and income statements from PDF into structured JSON in Python: multi-year comparative tables, arithmetic validation, and custom schema extraction.

Direct answer: Extract balance sheets and income statements from PDF with pip install pdfmux and a custom JSON schema passed to process(path, schema="financial_statement.json", output_format="json") — there’s no built-in preset for financial statements (pdfmux ships invoice, receipt, and contract presets, but not this one), so you define the line items you need once and reuse the schema across every filing. The two things that make financial statements harder than an invoice: they’re usually comparative (2-3 years of numbers side by side in one table) and they carry an internal arithmetic identity — assets equal liabilities plus equity, line items sum to subtotals — that you can and should verify programmatically instead of trusting the extraction blindly.


Why financial statements are a different shape of problem

pdfmux already covers two adjacent document types: bank statement transactions (a long ledger, one column of amounts) and invoices (a single-period document with line items and a total). A balance sheet or income statement is neither.

  1. Comparative columns, not comparative pages. An annual report almost always shows this year and last year (sometimes three years) as adjacent columns in the same table — “Revenue | FY2026 | FY2025 | FY2024.” Get the column-to-year mapping wrong and every number is silently attributed to the wrong year.
  2. Line items nest. “Total current assets” is a subtotal of the rows above it, which is itself a component of “Total assets.” A flat list of rows loses that structure unless you preserve indentation or an explicit parent/child relationship.
  3. The numbers have to add up. Unlike a bank statement’s running balance, a balance sheet’s check is structural: assets must equal liabilities plus equity, and each subtotal must equal the sum of the rows feeding it. That gives you a free correctness check most document types don’t offer.
  4. No universal machine-readable source. US public companies file XBRL alongside their PDF, and tools built for XBRL (SEC-API, EdgarTools) handle that case well. But XBRL only exists for SEC-filed US companies. Private companies, most non-US filers, internal management accounts, and anything scanned have no structured alternative — the PDF is the only copy of the data.

That fourth point is the actual gap. If you only ever need US public-company data, an XBRL-first tool is the right call and you should use it. This guide is for everything XBRL doesn’t cover.

Step 1: Define a custom schema

pdfmux’s schema parameter accepts a file path or a built-in preset name. There’s no financial_statement preset, so point it at your own JSON Schema file — the same mechanism the built-in presets use internally.

// financial_statement.json
{
  "type": "object",
  "properties": {
    "statement_type": { "type": "string" },
    "entity_name": { "type": "string" },
    "period_end_dates": { "type": "array", "items": { "type": "string" } },
    "currency": { "type": "string" },
    "line_items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "label": { "type": "string" },
          "values": { "type": "array", "items": { "type": "number" } },
          "is_subtotal": { "type": "boolean" },
          "indent_level": { "type": "integer" }
        }
      }
    }
  },
  "required": ["line_items"]
}

period_end_dates and each line item’s values array are parallel — index 0 is the most recent period, index 1 the prior period, and so on. That’s what lets one schema handle a two-year or a five-year comparative without changing the shape.

from pdfmux import process

result = process(
    "annual-report.pdf",
    quality="standard",
    output_format="json",
    schema="financial_statement.json",
)

print(f"Confidence: {result.confidence:.0%}")
print(f"Extractor: {result.extractor_used}")

for table in result.tables:
    print(f"Page {table['page']}: {len(table['rows'])} rows, headers={table['headers']}")

quality="standard" runs the full extract-audit-repair pipeline rather than the fast path — worth the extra time on a document where a misread digit changes a reported total. If the statement is a table-dense annual report, install the tables extra so pdfmux routes those pages through Docling instead of plain text extraction:

pip install "pdfmux[tables]"

Step 2: Map comparative-year columns

The header row tells you how many periods the table covers and in what order. Parse it once per table, then apply the same mapping to every row.

import re

def map_year_columns(headers: list[str]) -> list[str]:
    """Return the period label for every column after the line-item label column."""
    periods = []
    for h in headers[1:]:  # column 0 is always the line-item label
        match = re.search(r"(FY\s?)?(\d{4})", h)
        periods.append(match.group(2) if match else h.strip())
    return periods

def parse_amount(raw: str) -> float | None:
    """Financial statements use parens for negatives and dashes for zero/blank."""
    s = raw.strip().replace(",", "")
    if not s or s in ("-", "—", "–"):
        return None
    negative = s.startswith("(") and s.endswith(")")
    s = s.strip("()")
    s = re.sub(r"[^\d.]", "", s)
    if not s:
        return None
    value = float(s)
    return -value if negative else value

headers = result.tables[0]["headers"]
periods = map_year_columns(headers)
print(f"Comparative periods detected: {periods}")

line_items = []
for row in result.tables[0]["rows"]:
    label = row[0].strip()
    if not label:
        continue
    values = [parse_amount(cell) for cell in row[1:]]
    line_items.append({"label": label, "values": values})

Financial-statement PDFs render negative numbers as (1,234.56) far more consistently than bank statements do, and treat a dash as an explicit zero or “not applicable” rather than leaving the cell blank — both handled above.

Step 3: Validate the arithmetic

This is the check a bank statement can’t offer you but a balance sheet can. Total assets must equal total liabilities plus total equity. If your extraction is complete and correctly parsed, the identity holds for every period column.

def find_line(line_items: list[dict], *keywords: str) -> dict | None:
    for item in line_items:
        label = item["label"].lower()
        if all(k in label for k in keywords):
            return item
    return None

def check_balance_sheet_identity(line_items: list[dict]) -> list[dict]:
    assets = find_line(line_items, "total", "assets")
    liabilities = find_line(line_items, "total", "liabilities")
    equity = find_line(line_items, "total", "equity")

    if not (assets and liabilities and equity):
        return [{"error": "Could not locate all three totals — check extraction manually"}]

    problems = []
    for i, period_assets in enumerate(assets["values"]):
        if period_assets is None:
            continue
        expected = (liabilities["values"][i] or 0) + (equity["values"][i] or 0)
        drift = round(period_assets - expected, 2)
        if abs(drift) > 1.0:  # allow $1 rounding
            problems.append({
                "period_index": i,
                "reported_assets": period_assets,
                "liabilities_plus_equity": expected,
                "drift": drift,
            })
    return problems

problems = check_balance_sheet_identity(line_items)
if problems:
    print(f"⚠️  {len(problems)} period(s) fail the balance-sheet identity — extraction likely dropped a row")
else:
    print("✅ Assets = Liabilities + Equity holds for every period")

A drift that isn’t a rounding artifact points to a specific, fixable problem: a line item the parser missed, a subtotal read as a line item, or a low-confidence page. It does not usually mean the underlying filing itself is wrong — audited financial statements balance by definition, so a mismatch after extraction is almost always yours to fix, not theirs.

You can run the same subtotal check one level down — every subtotal should equal the sum of the rows above it at the next indent level — which is how you catch a single missing line item even when the top-line totals still happen to look plausible.

Step 4: Handle scanned and photographed statements

Older annual reports, private-company statements, and anything filed outside the US are frequently scanned rather than born-digital. Check per-page confidence before trusting the numbers on any given page.

low_conf = [p for p in result.pages if p.confidence < 0.7]
if low_conf:
    print(f"{len(low_conf)} page(s) need manual review: {[p.page_num for p in low_conf]}")

Install the OCR extra for scanned pages, which runs entirely on CPU with no GPU or API key required:

pip install "pdfmux[ocr]"

For statements that are photographs rather than flatbed scans (skewed, uneven lighting), OCR accuracy drops further — treat any table on a page below roughly 0.6 confidence as needing a human to re-key it rather than trusting the arithmetic check to catch every error, since a systematic misread (every 8 read as 3, for example) can still coincidentally balance.

How the options compare

ApproachComparative yearsBalance validationScanned supportCost
pdfmux + custom schemaYes (parallel arrays)You write it (shown above)Yes ([ocr] extra)Free (MIT)
XBRL parsers (EdgarTools, sec-api.io)Yes, nativelyN/A (structured data)N/AUS SEC filers only
pdfplumber / raw tablesManual column mappingYou write itNoFree (MIT)
Cloud document AI (Textract, Document AI)Per-field, no year semanticsYou write itYesPer-page fee

The XBRL tools are the right answer when they apply — don’t reach for PDF extraction on a document that already has structured data attached. Reach for the approach above for the much larger set of financial-statement PDFs that XBRL never covered.

Production checklist

  • Write a custom schema — there’s no built-in financial_statement preset
  • Use quality="standard" and install pdfmux[tables] for table-dense reports
  • Map comparative-year columns from the header once; apply to every row
  • Run the assets = liabilities + equity check on every extracted period
  • Cross-check any arithmetic failure against per-page confidence before assuming the source document is wrong
  • Install pdfmux[ocr] for scanned or photographed statements
  • Route anything below ~0.6 page confidence to manual review, not auto-ingestion

Keep reading

Last updated: July 2026.

Frequently asked questions

Does this work for SEC filings and XBRL data?

If a filing has an XBRL exhibit, use a dedicated XBRL parser instead — it is structured data, not a PDF extraction problem. This guide is for the much larger set of financial statements that never had XBRL: private companies, non-US filers, internal management accounts, and scanned annual reports.

Can it handle statements with more than two comparative years?

Yes. The column-mapping step in step 2 detects however many year columns the header row has, so three-year or five-year comparatives work the same way as two-year ones.

What if the totals don't balance after extraction?

Treat it as a signal, not a bug in your code. A failed balance check almost always means a row was dropped or a number misread on a low-confidence page. Check result.pages for the page confidence before assuming the source document itself is wrong.

Is my financial data sent anywhere?

No. pdfmux runs extraction and OCR locally on your machine. Nothing is uploaded unless you explicitly configure a cloud LLM fallback with your own API key.