A PDF has no table object
In a PDF, a table is a visual arrangement. The file stores glyphs at coordinates and, sometimes, line segments and rectangles that draw a grid. There is no element that says "this is row 3, column 2". To recover the table you have to infer the grid from geometry, then assign each piece of text to a cell.
The simplest approach, extracting the text of the page and splitting on whitespace, shows the problem immediately.
# pip install pypdf
from pypdf import PdfReader
page = PdfReader("statement.pdf").pages[0]
for line in (page.extract_text() or "").splitlines()[:15]:
print(line.split())
On a clean statement you may get ["2026-09-01", "Opening", "balance", "4,210.00"]. The description "Opening balance" is two tokens, so you cannot tell where the description ends and the amount begins. An empty debit column simply disappears, so the credit amount shifts left into the debit position. A description that wraps onto a second line becomes a separate row with no date. None of this raises an error, the data is just wrong.
Why character based approaches break tables
Text extraction gives you characters and their positions. Every downstream rule then has to guess.
- Word gaps and column gaps are both whitespace. A wide word gap in justified text looks like a column boundary, and a narrow column gap looks like a word gap.
- Empty cells leave no characters, so the column position of the next value is the only clue.
- Wrapped cell text produces several lines inside one row, and a line based reader turns them into several rows.
- Right aligned numbers and left aligned text in the same column have different x positions.
- Some PDFs draw each character separately or out of order, so even reading a single cell needs sorting.
Rules that fix one of these usually break another. That is why table extraction needs a model of the grid, not a set of string splits.
Ruled tables with an open source library
When a table has visible ruling lines, layout aware libraries can use those lines to find cells. This is the best case, and it is worth trying first on born digital documents.
# pip install pdfplumber pandas
import pdfplumber
import pandas as pd
with pdfplumber.open("statement.pdf") as pdf:
page = pdf.pages[0]
tables = page.extract_tables({
"vertical_strategy": "lines",
"horizontal_strategy": "lines",
})
for raw in tables:
header, *rows = raw
df = pd.DataFrame(rows, columns=header)
print(df.head())
For tables without lines, the same library can infer columns from text alignment with the "text" strategy. It needs tuning per layout: tolerances for snapping, minimum words per column, and an explicit crop box when the page also holds paragraphs that would otherwise be read as rows.
with pdfplumber.open("report.pdf") as pdf:
page = pdf.pages[3]
region = page.crop((40, 180, page.width - 40, 620))
tables = region.extract_tables({
"vertical_strategy": "text",
"horizontal_strategy": "text",
"snap_tolerance": 3,
"intersection_tolerance": 5,
})
This is a solid approach when you have a few fixed templates. It does not handle scanned pages (there are no characters to align), and it leaves merged cells, multi page tables and header detection to you. Those three problems are the rest of this guide.
Finding the table before reading it
Before you can read a table, you have to find it. A page may hold a paragraph, a chart, a table and a footnote, and a table detector has to draw a box around the table alone. Get the box wrong and either the last rows are cut off or a paragraph below the table is read as extra rows.
Signals that a region is a table include aligned columns of short text, repeated numeric patterns down a column, ruling lines, and a short header line above. Signals that it is not include long lines of prose and text that wraps across the full width. Lists with two columns, such as a glossary, are a common false positive, and key value blocks on forms sit somewhere in between.
A quick diagnostic on a born digital page is to print the x positions where words start and look for strong vertical alignment.
from collections import Counter
import pdfplumber
with pdfplumber.open("report.pdf") as pdf:
words = pdf.pages[3].extract_words()
starts = Counter(round(w["x0"] / 5) * 5 for w in words)
for x, count in sorted(starts.items()):
if count >= 6:
print(f"x={x:>4} {count} words start here")
Several strong alignment columns stacked over many lines usually mean a table. One strong column at the left margin is just a paragraph. This kind of diagnostic is useful when you tune a template, and it shows why detection has to look at the whole page rather than at one line at a time.
PdfParse detects tables on every page and returns each one as a block of type "table" with its bounding box, so you can see exactly which region was read and compare it with the page.
Merged cells and multi level headers
A merged cell covers more than one grid position. In financial and scientific tables they appear in two places: header cells that group columns ("2025" over "Q1 Q2 Q3 Q4") and row labels that group rows ("Europe" next to three country rows).
Most extractors return a merged cell once and leave the other positions empty or None. If you load that straight into a dataframe, three of the four quarter columns lose their year, and two of the three country rows lose their region.
The fix is to propagate the merged value. For a header, flatten levels into one label per column. For row groups, fill down within the group.
import pandas as pd
def flatten_header(header_rows):
"""header_rows is a list of header lines, None where a cell is merged."""
filled = []
for row in header_rows:
current, out = None, []
for cell in row:
if cell not in (None, ""):
current = cell
out.append(current or "")
filled.append(out)
return [" ".join(part for part in col if part).strip() for col in zip(*filled)]
header_rows = [
["Region", "2025", None, None, None],
[None, "Q1", "Q2", "Q3", "Q4"],
]
print(flatten_header(header_rows))
# ['Region', '2025 Q1', '2025 Q2', '2025 Q3', '2025 Q4']
def fill_row_groups(df, column):
df[column] = df[column].replace("", None).ffill()
return df
Filling left works for header spans because the merged label is printed in the first covered cell. This is a convention, not a rule. Centered labels in some documents start in the middle cell. A reliable extractor returns the span explicitly (row span and column span per cell) so you do not have to guess.
PdfParse returns each table with its rows and columns already expanded, so a merged header is repeated into each column it covers and the CSV for that table has one clean header row. The pdf table extraction page shows the output on real statements and reports.
Tables split across pages
Long tables continue onto the next page. Sometimes the header repeats, sometimes it does not, and there is usually a running page header and footer in between. Read page by page and you get several fragments that look like unrelated tables.
Joining them needs three decisions: is this table on page N+1 a continuation of the last table on page N, does it repeat the header, and is the first row on the new page a continuation of a cell that wrapped across the break.
def same_columns(a, b, tolerance=4.0):
"""a and b are lists of column x positions in points."""
return len(a) == len(b) and all(abs(x - y) <= tolerance for x, y in zip(a, b))
def merge_page_tables(fragments):
"""fragments is a list of dicts with keys page, header, rows, columns_x."""
merged = []
for frag in fragments:
prev = merged[-1] if merged else None
continues = (
prev is not None
and frag["page"] == prev["last_page"] + 1
and same_columns(prev["columns_x"], frag["columns_x"])
)
if continues:
rows = frag["rows"]
if rows and rows[0] == prev["header"]:
rows = rows[1:]
prev["rows"].extend(rows)
prev["last_page"] = frag["page"]
else:
merged.append({**frag, "last_page": frag["page"]})
return merged
This works when column positions match exactly across pages. It fails when the continuation page has a slightly different layout, when the previous table is followed by a short paragraph before the page break, or when a row is split across the break so the first line on the next page has no date. Handling those cases needs the page layout, not only the table geometry.
With PdfParse, a table that spans pages comes back as one table. The "tables" array in the response lists it once with its starting page, its "rows" and "columns" counts, and the full CSV, with repeated headers removed.
Scanned, rotated and low resolution tables
On a scanned page there are no characters to align, only pixels. The grid has to be detected in the image, the text recognized with OCR, and each word assigned to a cell. Faint lines, stamps over the table, and handwriting in cells all add noise. Landscape tables printed sideways on a portrait page need rotation before any of this.
Low resolution matters more for tables than for prose, because numbers have no context to correct them. A misread "8" in a sentence is often obvious from the word around it, a misread "8" in an amount column is not. Scan at 300 DPI when you control the scanner, and treat low confidence cells as values to check, not values to trust. The ocr api python guide covers rotation, resolution and confidence scores in detail.
Approaches compared
| Approach | Ruled tables | Whitespace tables | Merged cells | Multi page | Scans |
|---|---|---|---|---|---|
| Split extracted text on spaces | Breaks | Breaks | No | No | No |
| Layout library with line strategy | Good | Weak | Manual | Manual | No |
| Layout library with text strategy and crop boxes | Good | Per template tuning | Manual | Manual | No |
| Local OCR plus your own grid code | Possible | Hard | Manual | Manual | Yes |
| PdfParse API | Yes | Yes | Expanded per column | Joined into one table | Yes, with OCR |
Extract tables from PDF in Python with PdfParse
Install the SDK, set your key and ask for JSON and CSV in one call. Every table comes back with its page, its CSV text and its size.
pip install pdfparse-ai
export PDFPARSE_API_KEY="pp_live_your_key"
import io
import os
import pandas as pd
from pdfparse_ai import PdfParse
client = PdfParse(api_key=os.environ["PDFPARSE_API_KEY"])
result = client.parse("bank-statement.pdf", outputs=["json", "csv"], ocr="auto")
frames = []
for table in result["tables"]:
df = pd.read_csv(io.StringIO(table["csv"]))
df["source_page"] = table["page"]
frames.append(df)
print(f'page {table["page"]}: {table["rows"]} rows x {table["columns"]} columns')
statement = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
The same call with curl, when you want to inspect the raw response first:
curl https://api.pdfparse.ai/v1/parse \
-H "Authorization: Bearer $PDFPARSE_API_KEY" \
-F "[email protected]" \
-F 'outputs=["json","csv"]' \
-F "ocr=auto" \
-F "pages=1-5"
Each table also appears as a block of type "table" in the page JSON, with a bounding box and a confidence, so you can draw it on the rendered page for review. The pdf to csv page explains the CSV export, and the python pdf parser page lists all SDK options.
Turn cell text into typed values
Even a perfect table gives you strings. Amounts carry thousands separators, currency symbols and sometimes parentheses for negatives. Dates come in several formats in one document. Convert them in one place and keep the original string next to the parsed value for audit.
import re
from decimal import Decimal, InvalidOperation
def parse_amount(text):
if text is None:
return None
s = str(text).strip()
negative = s.startswith("(") and s.endswith(")") or s.endswith("-") or s.startswith("-")
s = re.sub(r"[^\d.,]", "", s)
if "," in s and "." in s:
s = s.replace(",", "")
elif s.count(",") == 1 and len(s.split(",")[1]) == 2:
s = s.replace(",", ".")
else:
s = s.replace(",", "")
try:
value = Decimal(s)
except InvalidOperation:
return None
return -value if negative else value
for col in ("Debit", "Credit", "Balance"):
if col in statement.columns:
statement[col + "_value"] = statement[col].map(parse_amount)
Decimal separators are locale dependent. A value such as "1,234" is one thousand two hundred thirty four in one country and one point two three four in another. If your documents come from several countries, decide the locale per document, not per value.
Validate every table before you trust it
Tables are full of internal checks you can use. A running balance must equal the previous balance plus credits minus debits. A total row must equal the sum of the rows above it. Every row must have the same number of cells as the header.
def check_running_balance(df, tolerance=Decimal("0.01")):
errors = []
rows = df.to_dict("records")
for prev, row in zip(rows, rows[1:]):
if None in (prev.get("Balance_value"), row.get("Balance_value")):
continue
expected = prev["Balance_value"] + (row.get("Credit_value") or 0) - (row.get("Debit_value") or 0)
if abs(expected - row["Balance_value"]) > tolerance:
errors.append((row.get("source_page"), row.get("Date"), expected, row["Balance_value"]))
return errors
problems = check_running_balance(statement)
print(len(problems), "rows to review")
When a check fails, the source page and bounding box tell you exactly where to look. Most failures come from one missed row at a page break or one misread digit on a scan, and both are fast to confirm against the original.
- Check cell counts against the header on every row.
- Recompute totals and running balances where the document prints them.
- Flag cells with low confidence and review them with the page image.
- Keep the original string, the parsed value and the source page together.
Running this at volume
Table extraction is included on every plan. Starter covers 2,000 pages a month with files up to 25 MB and 100 pages. Growth adds webhooks and batch jobs of up to 500 files, which is the comfortable setup for monthly statement runs. Scale adds a priority queue and form field and checkbox detection. Full limits are on pdf extraction api pricing, and the endpoint reference is in the document parsing api documentation. If the tables feed a language model rather than a dataframe, read convert pdf to markdown next.