IronStratum

Merged cell PDF table extraction: why tools fail

Merged cells are the part of PDF table extraction that open-source tools have declined to solve: pdfplumber returns None cells, PyMuPDF duplicates values and invents column names, Camelot recovers some merged headers and produces phantom columns elsewhere, and the feature requests have sat open for years. This guide shows each failure on a test table you can rebuild with one script, using outputs captured from the real tools, then walks the routes that work: the text-versus-scanned fork, the repair step and the values it silently fabricates, the Excel conversion paths, and the per-page economics of repairing it yourself versus routing documents to a parsing service.

Every output block below was captured by running the tools, not written by hand; the one before/after block joins two of those captures side by side. The corpus and capture scripts ship with this guide, so you can regenerate each exhibit before trusting a single claim in it.

Why merged cells break every extractor

A PDF file has no table object. There is no row, no column, and no cell in the format, only positioned characters plus optional lines and rectangles. A merged cell in Word or Excel is one object with a span. A merged cell in a PDF is a text run placed near the visual center of an area whose ruling lines happen not to cross, and the span exists only in the head of the person reading it.

The PyMuPDF maintainer put it plainly while closing the request to support merged cells in November 2024 (issue #4030): "PDF tables are no tables ... in PDF is literally nothing but an amorphous collection of characters and vector graphic atoms", and the table finder "does not actually locate the table, but synthesizes" structure from those atoms. Extraction is reconstruction from geometry, and geometry is ambiguous exactly where cells merge.

That ambiguity lands on the two edges every detector needs. A vertical merge removes the horizontal line that would split two rows, so the label lands in one row and the next starts with a gap. A horizontal merge removes the vertical line that would split two columns, so the detector either invents a boundary and you get a phantom column, or one column absorbs two. Hierarchical headers, the stacked "first half / Q1, Q2" pattern in financial and clinical tables, do both at once.

None of this is a bug in a specific library. It is the cost of encoding a layout without encoding a structure. Tools differ only in which compromise they pick, and the sections below show the exact compromise for each, with the output to prove it.

The failure, shown on a table you can rebuild

The test corpus is a one-page PDF with one ruled table, drawn the way Word or Excel export one, containing three kinds of merge: a title row spanning all five columns, two vertical merges in the first column (plant labels covering their data rows), and a two-column merge in the grand-total row. The generator is short enough to read in full and is the reproducibility artifact for this guide:

"""Build a one-page test PDF whose table contains merged cells.

This is the reproducibility artifact for the guide "Merged-cell PDF table
extraction". Running this script regenerates corpus-merged-cells.pdf exactly;
running run_extract.py after it reproduces every tool output quoted in the
guide. The table below is drawn with real ruling lines (reportlab Table with
a grid), the way Word or Excel export a table to PDF, and includes:

- a full-width horizontal merge (the title row)
- two vertical merges in the first column (plant labels spanning data rows)
- a two-column horizontal merge in the data area (the grand-total row)

Usage:  python make_corpus.py
Output: corpus-merged-cells.pdf
"""

from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle

DATA = [
    ["Regional production summary 2026", "", "", "", ""],
    ["Plant", "Product line", "Q1", "Q2", "H1"],
    ["North", "Widgets", "1200", "1350", "2550"],
    ["", "Gears", "800", "910", "1710"],
    ["", "Bearings", "400", "460", "860"],
    ["South", "Widgets", "900", "940", "1840"],
    ["", "Gears", "700", "680", "1380"],
    ["Grand total", "", "4000", "4340", "8340"],
]

# reportlab span coordinates are (col, row), not (row, col)
SPANS = [
    ("SPAN", (0, 0), (4, 0)),    # title row across all 5 columns
    ("SPAN", (0, 2), (0, 4)),    # "North" down 3 rows
    ("SPAN", (0, 5), (0, 6)),    # "South" down 2 rows
    ("SPAN", (0, 7), (1, 7)),    # "Grand total" across 2 columns
]


def build(path: str = "corpus-merged-cells.pdf") -> None:
    doc = SimpleDocTemplate(path, pagesize=letter)
    table = Table(DATA, colWidths=[130, 105, 75, 75, 75], rowHeights=[22] * len(DATA), style=[
        ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
        ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
        ("BACKGROUND", (0, 1), (-1, 1), colors.whitesmoke),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("ALIGN", (2, 1), (-1, -1), "RIGHT"),
        *SPANS,
    ])
    doc.build([table])


if __name__ == "__main__":
    build()
    print("wrote corpus-merged-cells.pdf")

Run pdfplumber's default extraction on that file and this is the output, captured verbatim:

page.extract_tables()[0]
['Regional production summary 2026', None, None, None, None]
['Plant', 'Product line', 'Q1', 'Q2', 'H1']
['North', 'Widgets', '1200', '1350', '2550']
[None, 'Gears', '800', '910', '1710']
[None, 'Bearings', '400', '460', '860']
['South', 'Widgets', '900', '940', '1840']
[None, 'Gears', '700', '680', '1380']
['Grand total', None, '4000', '4340', '8340']

The geometry is perfect: eight rows, five columns, every value in the right cell. The merges are the holes. The title survives only in its anchor cell. "North" appears in its first row and the next two rows start with None. The grand-total row has a None where "Grand total" continues into the second column. This is the output shape documented in pdfplumber issue #79 in 2018, and it has not changed.

PyMuPDF's extract() returns the same None-cell list on this file. Its markdown route makes a different compromise, and it is worth seeing because it is the one that changes your data:

|Regional production summary 2026|Col2|Col3|Col4|Col5|
|---|---|---|---|---|
|Plant|Product line|Q1|Q2|H1|
|North|Widgets|1200|1350|2550|
|North|Gears|800|910|1710|
|North|Bearings|400|460|860|
|South|Widgets|900|940|1840|
|South|Gears|700|680|1380|
|Grand total|Grand total|4000|4340|8340|

Three things happened. The merged values were duplicated into the cells they cover, which is a defensible choice for plain tables. The tool invented column names, Col2 through Col5, because markdown requires a header row and the real header was merged. And every duplicated value is now indistinguishable from a value that was genuinely repeated in the source. If your table marks "continuous monitoring" with one merged cell and "five separate visits" with five identical entries, duplication erases the difference. A clinical engineer who tested twelve tools on exactly that class of table in 2025 made the same point: copying merged values to every covered cell "loses the semantics attached to merged cells".

To check that these captures are not an artifact of my corpus, the same scripts were run on the PDFs attached inside the GitHub threads themselves. On the file attached to pdfplumber discussion #623 in March 2022, this run's pdfplumber output:

['Row-M1', 'Row-M2', 'column-merged']
['R1-M', None, 'C1']
['R2A', 'R2B', 'C2']
['R3A', 'R3B', 'C3-M']
['R4A', 'R4B', None]
['R-C5-M', None, 'C5-M']

That is byte-for-byte the output the user posted in 2022 when asking for the values to be duplicated instead. Four years later the answer is the same. On the Word-made file attached to issue #79 in 2018, the geometry reframes the criticism. The 8.0 on one data row is a single merged cell spanning both index columns, and duplicating such values is what users requested in issue #420 and discussion #623. The footnote line is also a full-width merged cell, and the markdown route renders it as four repeated values across the data columns, making a note indistinguishable from data. That is the real cost of duplication: not values invented from nothing, but wanted merges, notes, and genuine repeats flattened into one repeated-value shape, with nothing marking which is which.

What each library does with merged cells

LibraryMerged-cell behavior as capturedWhere the span information goesMaintenance state, September 2026
pdfplumberNone in every continuation cell; geometry of merges detectable afterwards.cells coordinates on the Table object let you compute spans yourselfactive, 10.7k stars
PyMuPDFNone cells in extract(); duplication plus invented headers in to_markdown()none exposed; merged-cell support closed wontfixvery active, 10.7k stars
Camelotlattice mode recovers merged header rows on ruled tables (its own docs); merge intersections can yield phantom columnsnot exposed as spansactive, 2.0.0 released June 2026
Doclingstructure model predicts cells, including spans, but can mis-combine adjacent columns on hard tablespredicted cell grid, not measured linesvery active, 66k stars

The pdfplumber row needs one qualifier. The None cells are the stable, documented output, and the maintainer said in 2022 that "there is no specific merged-cell capability at the moment". But the same reply points at the escape hatch: the coordinates are available through page.find_tables(...)[0].cells, so you can compute which cells span which and rebuild the spans yourself. The twelve-tool test author ended exactly there: pdfplumber as the geometry layer, custom code above it, and an OpenCV fallback for shaded cells.

Camelot's row needs its own qualifier. Version 2.0.0, released June 2026, ships five parsers, and its own comparison documentation demonstrates recovering a merged header row on a ruled table without manual hints, with per-tool CSV outputs published in its benchmark directory. That is a real, scoped improvement on ruled inputs. It is not the general fix: a 2026 practitioner write-up on multi-column filings reports the lattice tracer creating "six columns where there were four" at merge intersections, where no cell boundary exists, and the whitespace-based stream parser interleaving the columns of two side-by-side tables.

The Docling row is the newest bet: a transformer model (TableFormer) trained on roughly a million table images predicts the cell grid, spans included, instead of tracing lines. On the 2025 twelve-tool test it still "struggled with the merged cells" and combined adjacent columns on a schedule table with hierarchical headers. Predicted structure can be wrong in ways measured structure cannot.

Open source has had this answer for years

The request history, stated plainly, with dates verifiable on the official trackers:

pdfplumber: issue #79, "Dealing with merged table cells", opened August 2018 with a Word-generated table whose extraction returns "many None cells". Closed in July 2020 without the capability. Asked in February 2022 and answered that March, the maintainer confirmed: "There is no specific merged-cell capability at the moment." Users were still arriving in July 2025 asking whether anything had changed. The companion request to duplicate values instead of returning None, issue #420, has been open since April 2021. Discussion #623 from March 2022 proposes exactly the duplicate option and was answered that "there's no super-easy way for users to do that now", with the cells-coordinate route suggested instead.

PyMuPDF: issue #427 asked in January 2020 how to transform merged tables to Excel and was closed as a question within two days. The direct feature request, #4030, "Allow table extraction to handle merged cells", was opened November 8, 2024 and closed wontfix on November 16, 2024. In between, the maintainer explained the format truth quoted above, noted that markdown output cannot represent row or column spans without polluting it with HTML, and stated there were no plans for HTML or JSON table output either. The repository pushed commits the same day this guide was verified. That combination, an actively maintained library with a standing refusal on this one feature for about 22 months, is the durability signal: this is a settled position, not a backlog item.

The wontfix thread still receives traffic: its most recent activity, September 1, 2026, is a third-party comment promising a "geometrical reconstructor" for nested tables "within a week". Whether or not that ships, the arrival pattern is the point: buyers keep landing on a two-year-old refusal looking for a way out.

First fork: is the PDF text-based or scanned

Before any merged-cell work, check which kind of PDF you have, because it decides everything downstream. A text-based PDF contains character objects and the tools above apply. A scanned PDF is an image in a PDF container, and no line-tracer will ever see a table there, only pixels.

The test is one line of pdfplumber:

import pdfplumber

with pdfplumber.open("doc.pdf") as pdf:
    print(len(pdf.pages[0].chars))

Zero or near-zero characters means scanned. The route then becomes OCR-first: rasterize the pages, run an OCR engine, and only then attempt table structure, or use a layout model that reads the image directly. Every library comparison agrees on this limit, and it is where the commercial tools concentrate their value: cloud OCR services read the image and the table in one pass. A data engineering thread from January 2024 that compared approaches on inconsistent government PDFs landed on the same split, praising one cloud OCR service for segmentation while noting its OCR layer introduced its own character errors, while the original poster's verdict on the Python libraries was that they "fall short when tables get a bit complicated in terms of structure".

Mixed documents exist too: born-digital pages with a scanned appendix, or a digital table whose text layer was flattened. Run the character count per page, not per file, and route pages separately.

The standard repair fabricates data

The most common fix suggested for None cells is a forward-fill with pandas, and it is the right first move for vertical merges when you can verify the result. It is also where quiet data corruption enters, so here it is, run on the captured #623 output from above:

import pandas as pd

df = pd.DataFrame(TABLE[1:], columns=TABLE[0])
repaired = df.ffill()
before                          after
   Row-M1 Row-M2 column-merged     Row-M1 Row-M2 column-merged
0    R1-M    NaN            C1   0    R1-M    NaN            C1
1     R2A    R2B            C2   1     R2A    R2B            C2
2     R3A    R3B          C3-M   2     R3A    R3B          C3-M
3     R4A    R4B           NaN   3     R4A    R4B          C3-M
4  R-C5-M    NaN          C5-M   4  R-C5-M    R4B          C5-M

Read the diff carefully, because the three changed cells are three different truths, and the extracted list alone cannot tell you which is which. Row 0's merge was not repaired at all: the value lives to its left, and a downward fill has nothing above it to copy. Row 4 was filled with R4B from the row above when its true value is its left neighbor, R-C5-M: the wrong value, written confidently. Row 3, which looks fabricated, is the one cell the repair got right: the .cells geometry shows no line between that position and the row above it, so the two are one merged cell and C3-M is the correct reconstruction, exactly the duplication the discussion's author asked for. That is the trap. A None in the list is either the continuation of a merge, whose correct fill is its anchor, or a genuinely empty cell, whose correct value is empty, and blind forward-fill repairs the first while fabricating on the second with no flag on either. Two corruptions and one silent correct repair on a six-row table, and only the geometry tells you which cells are which.

Forward-fill is safe only when every merge is vertical, every label sits in the first row of its span, and empty cells are acceptable to pollute; for financial and clinical tables those conditions rarely all hold. The repair that holds up is span-aware: compute the spans from the .cells coordinates, fill each covered cell from its anchor deliberately, and leave truly empty cells empty. That is the code the twelve-tool test author ended up writing, and it is the point where "free library plus a weekend" quietly becomes "a maintained internal tool".

Getting the table into Excel

The Excel intent in the original requests is explicit: issue #427 is titled "Question / Comment: Is there a way to transform Merged PDF tables to excel". What "into Excel" means decides the path.

If a flat sheet is enough, None cells and all repaired values write straight through:

import pandas as pd
import pdfplumber

with pdfplumber.open("corpus-merged-cells.pdf") as pdf:
    rows = pdf.pages[0].extract_table()

df = pd.DataFrame(rows[2:], columns=rows[1])
df = df.ffill()          # only after reading the section above
df.to_excel("out.xlsx", index=False)

If the deliverable must preserve the merges visually, the round trip is openpyxl, and the spans have to come from somewhere, either your knowledge of the document or the computed .cells geometry:

from openpyxl import Workbook

wb = Workbook()
ws = wb.active
for row in rows:
    ws.append(row)

ws.merge_cells("A1:E1")   # the title row, spans known from the corpus
wb.save("merged.xlsx")

The one route to avoid is the fully automatic converter, online or offline, applied blindly to merged tables. A converter that guesses spans will write a spreadsheet that looks right, and looking right is the failure mode: the None-cell problem becomes a wrong-value problem with no None left to flag it. If a converter is in the path, diff its output against the pdfplumber capture on a few known pages first; the None cells are your tripwire.

What an independent twelve-tool test found

In August 2025 a distinguished chief engineer at MITRE published a writeup of testing twelve "best-in-class" table extraction tools on clinical study schedules, tables whose merged headers carry treatment-cycle semantics. His results, in brief: six commercial tools each failed differently (missed marks, unrecoverable CSV output, ignored merges, misaligned columns, randomly merged cells); two leading general-purpose AI models failed on alignment and merges with both PDF and image input; the open-source structure model struggled with the merges; and the one component that worked was pdfplumber's cell geometry, which detected the test table's cell layout perfectly. His production solution was pdfplumber for raw geometry plus custom code for spans, footnotes, and multi-page stitching. He was not permitted to release his code, and no corpus was published, which is why this guide ships its own.

Two facts in that writeup generalize beyond clinical documents. Benchmark scores did not predict merged-cell behavior: one tool advertising a 90.2 percent table-similarity score on its own benchmark still produced misalignments and randomly merged cells on real merges. And the AI-model route is not a shortcut around the problem, because the model reads the same ambiguous geometry and guesses, and a guess with no None flag is harder to audit than a None.

The community signal points the same direction. The 2024 data engineering thread's top-voted workaround was to leave PDFs entirely and fetch the same data from the source's API where one exists, with manual re-entry named as the terminal fallback for scanned documents. When the escape routes are "avoid the PDF" and "type it in", the extraction lane is unserved.

What it costs per page at volume

The libraries are free; the work is not. A rough model for the build route: engineering hours times your loaded rate, plus the recurring cost of every new document shape that breaks your repair logic. The buy route is simpler: price per page times pages, plus integration.

One public anchor for the buy side, from AWS's own pricing page, accessed September 11, 2026: table analysis under the Textract Analyze Document API lists at 1.5 cents per page for the first million pages per month and 1 cent per page beyond that, in the US West Oregon region. At low volume that is 15 per thousand pages, in dollars. The same thread that praised that service's table segmentation also reported its OCR layer introducing character errors, so per-page price is a floor on cost, not a ceiling on quality; diff bought output against a pdfplumber capture the same way.

Worked symbolically: if you process N pages a month at P dollars per page, the service costs N times P. If your repair pipeline costs H engineer-hours a month at rate R, it costs H times R, and H grows with document variety while N times P does not. The more document shapes you see, the sooner the library route costs more than the metered one. For planning metered spend on any API, the console's pricing page lists current per-unit rates in one place.

When to stop maintaining your own extractor

The signals that the library-plus-repairs route has stopped paying are concrete: you are patching the span logic monthly, your test set grows faster than your confidence, someone has re-typed a table by hand to make a deadline, or the audit conversation has shifted from "missing" to "wrong". Missing values are recoverable; fabricated ones are not, and every duplication-based output in this guide is a fabrication vector.

At that point the alternative is a parsing service you pay per page, and the properties worth demanding from one are the properties this guide has been demonstrating: captured outputs you can check, stated behavior on merges rather than accuracy claims, and billing that stops when you stop. A prepaid wallet is the hard version of that last property; on this platform the wallet is in active use and a key whose balance reaches zero is denied at request time, so the worst case is a top-up rather than an unplanned balance. Spend caps per key, which several providers offer, are worth wanting as an industry practice regardless of where you route the work; for the failure modes and controls around runaway metered spend, see how to avoid API bill shock.

This platform's document parsing lane is on the roadmap: per-page parsing of PDFs and images into structured text is being built, and when it lands it will sit behind the same wallet and the same OpenAI-compatible endpoint family that serve the platform today. When it does, this guide's corpus and scripts will be the yardstick: run them, capture the output, and hold the service to the None-cell and duplication standards you now know how to see. If what you need is the whole document as clean markdown for a RAG pipeline rather than repaired tables, that pipeline is worked end to end in PDF to markdown for RAG. If the work in front of you is bigger than one guide, you can sign up and create a key against the wallet in a few minutes; if it is one broken table, the repair section may be all you needed today.

Questions people ask about merged-cell extraction

Is there a way to extract tables from a PDF?

Yes, with conditions. On text-based PDFs, pdfplumber, PyMuPDF, and Camelot all find ruled tables and extract their cell contents; the captures above show each exact output on merged cells. On scanned PDFs none work directly; the route is OCR-first. On merged cells all require post-processing or a service that handles spans natively.

How can I extract tables from a PDF and convert them to Excel?

Extract with pdfplumber, load the rows into a pandas DataFrame, repair the merge continuation cells deliberately (not with a blind forward-fill), then write with df.to_excel(). If the spreadsheet must show the merges visually, write with openpyxl and apply merge_cells using computed or known spans. The Excel section carries both code paths.

Is my PDF text-based or scanned, and why does it change everything?

Count the characters on a page with len(pdf.pages[0].chars). Near zero means the page is an image and line-tracing extractors have nothing to trace; you need OCR or an image-reading service. A nonzero count means the tools here apply directly. Run the count per page, since mixed documents are common.

Why does pdfplumber return None cells on merged cells?

Because the PDF contains no cell objects, only text and lines; pdfplumber rebuilds the visible grid, and each covered cell of a merge holds no text of its own, so its slot in the row list is None. Documented since issue #79 in 2018, confirmed unchanged by the maintainer in 2022, with the feature requests (#420, discussion #623) still open or unresolved.

How do I transform merged PDF tables to Excel?

PyMuPDF issue #427 from January 2020 asked the same thing ("Is there a way to transform Merged PDF tables to excel"), and the honest answer has not moved: no open-source extractor outputs spans, so you either repair the None cells into flat values, rebuild the merges in openpyxl, or route the document to a service that returns structure natively.

Which PDF table extraction tool actually handles merged cells?

No open-source line-tracer handles all merges natively. pdfplumber gives you the geometry to do it yourself, PyMuPDF has closed the request wontfix, Camelot 2.0 recovers merged header rows on ruled tables but can invent phantom columns at merge intersections, and Docling predicts spans with a model that can mis-combine adjacent columns. A 2025 independent test of twelve tools found none correct on a merged-header clinical schedule.

What does table extraction cost per page at volume?

The open-source libraries cost engineering time rather than pages. On the metered side, AWS Textract's table analysis listed at 1.5 cents per page for the first million pages per month as of September 11, 2026, with lower rates beyond that tier. Compare your monthly repair hours against pages times price, and remember that quality failures, not invoice totals, are where extraction projects actually fail.

Last verified: 2026-09-11. Tool versions used for the captured exhibits: reportlab, pdfplumber, and PyMuPDF as installed by pip on Python 3.12 on the verification date; the corpus and capture scripts ship alongside this guide so every output above can be regenerated and checked.