Skip to main content
Lesson 6 of 16 · Document AI: OCR and Document Intelligence

Table and Structure Extraction

Lesson 6 of 16 Updated November 2, 2026 Developer
On this page
  1. Tables: The Historical Weakness of OCR
  2. The table_format Parameter
  3. Retrieving Tables as Data
  4. Moving to pandas for Analysis
  5. When Tables Get Complicated
  6. Key Takeaways

Updated November 2, 2026 — OCR. OCR 4.0 (mistral-ocr-4-0) was retired on September 30, 2026. Use OCR 4.1 (mistral-ocr-4-1), generally available since August 31 at the same price, or the alias mistral-ocr-latest, which has pointed to it since July 16. OCR 4.1 adds confidence scores at the block level.

Tables: The Historical Weakness of OCR

Ask Tesseract to extract the summary table from a financial report, and you’ll get a string of lines where values from the “Total” column get mixed up with those from the “Quantity” column, with no clue linking them back to their original rows. The problem isn’t character recognition, which works fine; it’s the total lack of tabular structure awareness. Traditional solutions read left-to-right, top-to-bottom, losing the relationships between columns and rows along the way.

OCR 4 approaches the page differently: it sees it. This visual understanding lets it restore a table as a table, not as flattened text.

The table_format Parameter

You control table rendering with a single parameter passed to the OCR call:

import os
from mistralai.client import Mistral

client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])

# Markdown format (recommended for most cases)
ocr_response = client.ocr.process(
    model="mistral-ocr-latest",
    document={
        "type": "document_url",
        "document_url": "https://example.com/financial-report.pdf"
    },
    table_format="markdown"
)

Three values are possible, and the choice isn’t cosmetic: it determines what you can do with the result downstream.

With null, the default value, tables are integrated into the Markdown flow without specific tabular formatting. This setting works when you aren’t processing tabular documents and just want text to index.

With "markdown", each table is rendered in standard pipe format, producing output that is readable as-is and easy to parse:

| Product | Quantity | Unit Price | Total |
|---------|----------|------------|-------|
| Widget A | 100 | €12.50 | €1,250 |
| Widget B | 50 | €25.00 | €1,250 |
| **Total** | **150** | | **€2,500** |

With "html", the table is rendered as full HTML, preserving cell merges and advanced formatting that Markdown can’t express:

<table>
  <thead>
    <tr>
      <th>Product</th>
      <th>Quantity</th>
      <th>Unit Price</th>
      <th>Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Widget A</td>
      <td>100</td>
      <td>€12.50</td>
      <td>€1,250</td>
    </tr>
  </tbody>
</table>

In practice, Markdown covers most needs: RAG indexing, search, or display in an editor. Switch to HTML as soon as your tables include merged cells or the result needs to be embedded in a web page. Keep null only when tables don’t matter in your workflow—say, a narrative report or correspondence.

Retrieving Tables as Data

A Markdown table is still text. To turn it into manipulable data, you need to spot it in the flow and split it up. The function below walks through the lines, identifies those belonging to a table, ignores the separator line, and closes the current table as soon as a regular line appears:

import os
import re
import json
from mistralai.client import Mistral

client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])

def extract_markdown_tables(markdown: str) -> list[list[list[str]]]:
    """Extracts Markdown tables and converts them to lists."""
    tables = []
    lines = markdown.split("\n")
    current_table = []
    in_table = False

    for line in lines:
        if "|" in line and line.strip().startswith("|"):
            # Ignore separator line (|---|---|)
            if re.match(r"^\|[\s\-:|]+\|$", line.strip()):
                continue
            cells = [
                c.strip() for c in line.strip().strip("|").split("|")
            ]
            current_table.append(cells)
            in_table = True
        else:
            if in_table and current_table:
                tables.append(current_table)
                current_table = []
            in_table = False

    if current_table:
        tables.append(current_table)

    return tables


# OCR extraction with Markdown tables
ocr_response = client.ocr.process(
    model="mistral-ocr-latest",
    document={
        "type": "document_url",
        "document_url": "https://example.com/invoice.pdf"
    },
    table_format="markdown"
)

# Iterate through pages and extract tables
for page in ocr_response.pages:
    tables = extract_markdown_tables(page.markdown)
    for i, table in enumerate(tables):
        print(f"Page {page.index + 1}, Table {i + 1}:")
        print(json.dumps(table, indent=2, ensure_ascii=False))

The final check outside the loop isn’t decorative: without it, a table at the end of a page—a common case for an invoice summary—would never be added to the list, since no regular line comes along to close it.

Moving to pandas for Analysis

A list of lists is enough for export, not for calculation. Converting to a DataFrame means promoting the first row to headers:

import pandas as pd

def tables_to_dataframes(markdown: str) -> list[pd.DataFrame]:
    """Converts Markdown tables to pandas DataFrames."""
    tables = extract_markdown_tables(markdown)
    dataframes = []

    for table in tables:
        if len(table) < 2:
            continue
        headers = table[0]
        data = table[1:]
        df = pd.DataFrame(data, columns=headers)
        dataframes.append(df)

    return dataframes

# Usage
for page in ocr_response.pages:
    dfs = tables_to_dataframes(page.markdown)
    for df in dfs:
        print(df.to_string())
        print()

The len(table) < 2 test filters out false positives: an isolated line containing a pipe, inside a code block or an address, won’t produce a broken, empty DataFrame. Keep in mind that values remain strings; converting “€1,250” to a number is still up to you.

When Tables Get Complicated

Merged cells, two-level headers, or irregular layouts push Markdown beyond its expressive limits. Switch to HTML then, and let BeautifulSoup and pandas handle the analysis:

from bs4 import BeautifulSoup
import pandas as pd

ocr_response = client.ocr.process(
    model="mistral-ocr-latest",
    document={
        "type": "document_url",
        "document_url": "https://example.com/complex-report.pdf"
    },
    table_format="html"
)

for page in ocr_response.pages:
    soup = BeautifulSoup(page.markdown, "html.parser")
    tables = soup.find_all("table")

    for table in tables:
        df = pd.read_html(str(table))[0]
        print(df)

This approach saves you from writing a parser: pd.read_html handles rowspan and colspan attributes that your manual splitting would miss. The cost is an extra dependency and slightly more verbose output—a trade-off well worth it for financial reports or regulatory tables.

Key Takeaways

  • The table_format parameter controls table rendering (null, markdown, html)
  • Markdown format suits most use cases
  • HTML format preserves merged cells and advanced formatting
  • Extracted tables can be converted to pandas DataFrames for analysis
  • OCR 4 faithfully reconstructs tabular structure, even on complex layouts