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.

What a merge does to the underlying grid Two four by three grids. On the left a horizontal merge across the top row leaves one cell carrying a gridSpan of four, while the grid still has four positions, so addressing any of them returns the same cell. On the right a vertical merge down the first column marks the top cell vMerge restart and the two below it as continuation cells with empty vMerge, which render as one tall cell. Horizontal merge (gridSpan) Vertical merge (vMerge) one cell, gridSpan=4 (1,0) (1,1) (1,2) (1,3) dashed lines: grid positions that still exist and return the same cell vMerge restart EMEA 1204 AMER 980 APAC 655 continuation cells keep their position but draw nothing Merging never deletes grid positions so row.cells reports duplicates — address cells by (row, column) and deduplicate on the element

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.

Order of operations for a merged table Four ordered steps. Create the table at its final size. Apply every merge while the grid is still empty. Clear the concatenated paragraphs in each merged cell. Write the data addressing cells by row and column coordinates rather than by iterating row cells. A warning notes that writing data before merging causes text from covered cells to be concatenated into the merged cell. Merge first, write second 1. Create grid final row count no add_row loop 2. Merge while cells are empty nothing to concatenate 3. Clear text one paragraph left then set the label 4. Write data table.cell(r, c) never row.cells[i] Writing before merging concatenates every covered cell's text the merged cell ends up with four paragraphs instead of one banner

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.

Two ways to enumerate a merged row A row of four grid positions where the first three are merged into one cell. Iterating row.cells returns four entries, three of which are the same underlying cell, so a positional write puts three values into one place. Addressing table.cell by row and column returns the same objects but makes the sharing visible, and deduplicating on the underlying element gives the two distinct cells that actually exist. One row: 4 grid positions, 2 real cells merged cell (positions 0, 1, 2) cell at position 3 row.cells → 4 entries dedupe on _tc → 2 cells A positional write over row.cells puts three values into the merged cell and blanks the rest

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.

Part of Building and Editing Word Tables with Python.