Merge Table Cells with python-docx
Merging is one method call, and the surprises start immediately afterwards:
>>> merged = table.cell(0, 0).merge(table.cell(0, 3))
>>> merged.text = "Regional summary"
>>> [c.text for c in table.rows[0].cells]
['Regional summary', 'Regional summary', 'Regional summary', 'Regional summary']
The banner was not written four times. row.cells returns one entry per grid position, and the merged cell occupies four of them, so it appears four times. Code that writes values positionally after a merge puts every value in the same cell and leaves the rest of the row blank — silently, because no exception is raised.
Root Cause
Word stores a table as a grid of w:tc elements. A merge does not remove cells; it marks a span. Horizontally, the surviving cell gains a gridSpan covering the columns it now occupies. Vertically, the top cell is marked vMerge="restart" and the cells below it become continuation cells with an empty vMerge.
python-docx models this faithfully, which is why table.cell(r, c) for any covered position returns the same _Cell object. Two consequences follow. Iterating row.cells yields duplicates, and merging a range that was already partly merged can produce an irregular grid that Word renders unpredictably.
Text is the other trap: merge concatenates the paragraphs of every participating cell. Merging four cells that each say 0.00 gives a cell containing four paragraphs, not one — which is why the examples below clear the text explicitly.
Minimal Diagnostic
Print the grid with duplicates collapsed, so you can see the real shape of a merged table.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
DOCX = Path("out/report.docx")
def grid_report(docx_path: Path, table_index: int = 0) -> None:
"""Show which grid positions share a cell, i.e. which are merged."""
if not docx_path.exists():
raise FileNotFoundError(f"No such document: {docx_path}")
try:
table = Document(str(docx_path)).tables[table_index]
except IndexError as exc:
raise IndexError(f"No table at index {table_index}") from exc
seen: dict[int, str] = {}
for row_index in range(len(table.rows)):
labels = []
for column_index in range(len(table.columns)):
cell = table.cell(row_index, column_index)
key = id(cell._tc) # identity of the underlying element
if key in seen:
labels.append(f"={seen[key]}") # merged: points at the owning position
else:
seen[key] = f"{row_index},{column_index}"
labels.append(cell.text.strip()[:12] or "·")
print(" | ".join(f"{label:<14}" for label in labels))
if __name__ == "__main__":
grid_report(DOCX)
Regional summ | =0,0 | =0,0 | =0,0
Region | Units | Value | Share
EMEA | 1204 | 98,400 | 41%
Any =r,c marker is a covered position. Reading that map before writing data is the reliable way to avoid the positional-write bug.
The Fix: Merge, Then Address by Coordinates
# pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
OUT = Path("out/merged_table.docx")
def clear_cell(cell) -> None:
"""Reduce a cell to a single empty paragraph (merge concatenates paragraphs)."""
for paragraph in list(cell.paragraphs[1:]):
paragraph._p.getparent().remove(paragraph._p)
cell.paragraphs[0].text = ""
def banner_row(table, row_index: int, text: str) -> None:
"""Merge a whole row into one cell and set its text once."""
first = table.cell(row_index, 0)
last = table.cell(row_index, len(table.columns) - 1)
merged = first.merge(last)
clear_cell(merged) # drop the concatenated empties
merged.text = text
merged.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in merged.paragraphs[0].runs:
run.bold = True
def build(frame: pd.DataFrame, dest: Path) -> Path:
"""A table with a merged banner above a normal header and data rows."""
document = Document()
table = document.add_table(rows=len(frame) + 2, cols=len(frame.columns))
table.style = "Table Grid"
banner_row(table, 0, "Regional summary — Q3 2026")
for position, name in enumerate(frame.columns):
table.cell(1, position).text = str(name)
for row_index, record in enumerate(frame.itertuples(index=False), start=2):
for position, value in enumerate(record):
# Address by coordinates: never index row.cells after a merge
table.cell(row_index, position).text = "" if pd.isna(value) else str(value)
dest.parent.mkdir(parents=True, exist_ok=True)
document.save(str(dest))
return dest
if __name__ == "__main__":
df = pd.DataFrame({"Region": ["EMEA", "AMER"], "Units": [1204, 980], "Share": ["41%", "33%"]})
print(build(df, OUT))
Two rules carry all the weight. Merge before writing the data rows, so the grid is stable when the positional writes happen. And address every cell as table.cell(row, column) rather than row.cells[i], which is the API that reports duplicates.
Variant: Vertical Merges for Grouped Rows
A common report shape groups consecutive rows under one label. Merge downward once per group, after the data is written.
# pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
import pandas as pd
def merge_group_column(table, frame: pd.DataFrame, column: str,
column_index: int = 0, first_data_row: int = 1) -> None:
"""Merge consecutive rows sharing the same value in one column."""
start = None
previous = object()
for offset, value in enumerate(frame[column].tolist() + [object()]):
row = first_data_row + offset
if value != previous:
if start is not None and row - 1 > start:
merged = table.cell(start, column_index).merge(table.cell(row - 1, column_index))
clear_cell(merged)
merged.text = str(previous)
start, previous = row, value
# groups of one need no merge — their single cell already holds the label
if __name__ == "__main__":
frame = pd.DataFrame({
"Region": ["EMEA", "EMEA", "EMEA", "AMER", "AMER"],
"Country": ["UK", "DE", "FR", "US", "CA"],
"Units": [412, 388, 404, 610, 370],
})
merge_group_column(table, frame, "Region")
Sort the frame by the grouping column first. Merging non-adjacent rows is not possible in Word's model, and attempting it produces a merge that spans the rows in between, silently absorbing their labels.
Variant: Unmerging
There is no unmerge. Removing a horizontal merge means deleting the gridSpan and inserting fresh cells, which is fiddly enough that rebuilding the table is usually cheaper. The one case worth handling in code is clearing a vertical merge, which is a single attribute:
# pip install "python-docx>=1.1,<2"
from docx.oxml.ns import qn
def clear_vertical_merge(table, column_index: int) -> None:
"""Remove vMerge markers from a column, splitting it back into single cells."""
for row in table.rows:
cell = row.cells[column_index]
properties = cell._tc.tcPr
if properties is None:
continue
for element in properties.findall(qn("w:vMerge")):
properties.remove(element)
If a table needs several different merge layouts across a document set, generate it fresh each time from the data rather than editing a merged template — the same conclusion reached about charts in fix openpyxl chart not showing in Excel.
Verification
Assert the merge produced the span you intended and that no data cell was overwritten by a positional bug.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
def assert_merges(docx_path: Path, expected_spans: dict[tuple[int, int], int],
table_index: int = 0) -> None:
"""Check each named position spans the expected number of grid columns."""
table = Document(str(docx_path)).tables[table_index]
for (row_index, column_index), expected in expected_spans.items():
anchor = table.cell(row_index, column_index)
span = sum(
1
for c in range(len(table.columns))
if table.cell(row_index, c)._tc is anchor._tc
)
assert span == expected, (
f"cell ({row_index},{column_index}) spans {span} column(s), expected {expected}"
)
# No data row may contain the same cell object twice unless it was merged on purpose
for row_index in range(2, len(table.rows)):
elements = [table.cell(row_index, c)._tc for c in range(len(table.columns))]
assert len(set(id(e) for e in elements)) == len(elements), (
f"row {row_index} has unintended merged cells — data may have been overwritten"
)
print(f"{len(expected_spans)} merge(s) verified, data rows intact")
if __name__ == "__main__":
assert_merges(Path("out/merged_table.docx"), {(0, 0): 3})
The second loop is the important one: it fails on exactly the bug this page opens with, where a positional write collapsed a data row into one cell. Add it to the document tests alongside the shape checks in building and editing Word tables, and a merge regression cannot reach a client pack.
FAQ
Why does the merged cell contain several empty paragraphs?
Because merge concatenates the paragraph content of every participating cell. Clear the cell after merging, as clear_cell does, then set the text once.
Can I merge cells that are not adjacent? No. Word merges a rectangular range; the call absorbs everything between the two corners. Sort the data so the rows you want merged are adjacent.
Does merging change len(table.columns)?
No. The grid keeps its column count; only the rendering spans. That is why the diagnostic reports covered positions rather than a smaller grid.
Do merged cells survive conversion to PDF?
Yes with LibreOffice and Word, both of which honour gridSpan and vMerge. Verify the rendered output anyway when the conversion runs headless, as described in converting DOCX to PDF with Python.
How do I centre a label vertically in a tall merged cell?
Set the cell's vertical anchor: cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER from docx.enum.table. It applies to the merged cell as a whole, not to the covered positions.
Merges and Accessibility
A merged banner row reads well on screen and badly to a screen reader, which announces a table by its column headers and expects each data cell to map to one. Heavily merged layout tables produce announcements that make no sense.
Where the document will be read assistively, keep merges to genuine structural spans — a section heading across the width, a label spanning the rows it describes — and mark the header row as a header so the association survives. Anything more decorative is better achieved with paragraph styling above the table than with merged cells inside it.
Related
- Building and Editing Word Tables with Python — the table workflow this belongs to
- Fix python-docx Table Style Not Found — styling the merged table
- Automating Word Document Creation — document structure around the table
- Converting DOCX to PDF with Python — checking merges survive rendering
- Dynamic Mail Merge with Python — merged layouts inside templated documents