Fix: python-docx Table Column Width Ignored

The code sets the width, Word ignores it:

table = doc.add_table(rows=3, cols=3)
table.columns[0].width = Cm(6)
table.columns[1].width = Cm(3)
table.columns[2].width = Cm(3)
doc.save("out/report.docx")

Open the file and all three columns are the same width, or sized to fit their text. Reading the width back in Python returns the value that was set, so nothing looks wrong from the script's side — only the rendered document disagrees.

Root Cause

Word stores column widths on the cells, not on the columns. table.columns[i].width in python-docx is a convenience that writes a w:gridCol entry in the table grid, but Word treats the grid as a hint and takes the authoritative width from each cell's w:tcW. On top of that, a table created by add_table has autofit enabled (w:tblLayout set to autofit), which tells Word to recompute all widths from the content and the page width, discarding both the grid and the cell widths. Two things therefore have to change: the layout algorithm must be switched to fixed, and every cell in a column must carry the same explicit width.

Where Word actually reads column widths The table layout property decides everything. With autofit on, Word recomputes widths from content and page width and discards what the file says. With fixed layout, Word reads each cell's tcW width, falling back to the table grid's gridCol entry when a cell has none. The column width attribute in python-docx writes only the grid, which is why it appears to have no effect. w:tblLayout = autofit recomputed from content, overrides everything below must be set to fixed w:tcW on each cell the authoritative width set this on every cell w:gridCol in the table grid fallback hint only used when a cell has none column.width in python-docx writes the grid only why the setting looks ignored

Minimal Diagnostic

Print the layout mode and the widths actually stored, at both grid and cell level.

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Emu

DOCX = Path("out/report.docx")

def width_report(path: Path) -> None:
    doc = Document(path)
    for index, table in enumerate(doc.tables):
        tbl_pr = table._tbl.tblPr
        layout = tbl_pr.find(qn("w:tblLayout"))
        mode = layout.get(qn("w:type")) if layout is not None else "autofit (default)"
        tbl_w = tbl_pr.find(qn("w:tblW"))
        print(f"table {index}: layout={mode} "
              f"tblW={tbl_w.get(qn('w:w')) if tbl_w is not None else 'unset'} "
              f"type={tbl_w.get(qn('w:type')) if tbl_w is not None else '-'}")
        grid = [Emu(int(col.get(qn("w:w")))).cm for col in table._tbl.find(qn("w:tblGrid"))]
        print("  grid   :", [f"{value:.2f}cm" for value in grid])
        for row_index, row in enumerate(table.rows[:2]):
            widths = []
            for cell in row.cells:
                tc_w = cell._tc.tcPr.find(qn("w:tcW")) if cell._tc.tcPr is not None else None
                widths.append("unset" if tc_w is None
                              else f"{Emu(int(tc_w.get(qn('w:w')))).cm:.2f}cm"
                              if tc_w.get(qn("w:type")) == "dxa" else tc_w.get(qn("w:type")))
            print(f"  row {row_index}  :", widths)

if __name__ == "__main__":
    width_report(DOCX)
table 0: layout=autofit (default) tblW=0 type=auto
  grid   : ['6.00cm', '3.00cm', '3.00cm']
  row 0  : ['unset', 'unset', 'unset']
  row 1  : ['unset', 'unset', 'unset']

The grid holds the requested widths, the cells hold nothing, and autofit means Word reads neither.

Fix: Fixed Layout Plus Per-Cell Widths

Set the layout to fixed, give the table an absolute total width, then write the width to every cell.

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Length

def set_table_layout_fixed(table) -> None:
    tbl_pr = table._tbl.tblPr
    for tag in ("w:tblLayout", "w:tblW"):
        existing = tbl_pr.find(qn(tag))
        if existing is not None:
            tbl_pr.remove(existing)                              # changed: replace, don't duplicate
    layout = OxmlElement("w:tblLayout")
    layout.set(qn("w:type"), "fixed")                            # changed: stop Word recomputing widths
    tbl_pr.append(layout)

def set_table_width(table, total: Length) -> None:
    tbl_pr = table._tbl.tblPr
    tbl_w = OxmlElement("w:tblW")
    tbl_w.set(qn("w:w"), str(int(total.twips)))
    tbl_w.set(qn("w:type"), "dxa")                               # changed: absolute, not "auto"
    tbl_pr.append(tbl_w)

def set_column_widths(table, widths: list[Length]) -> None:
    assert len(widths) == len(table.columns), "one width per column required"
    table.autofit = False                                        # changed: python-docx-level switch
    set_table_layout_fixed(table)
    set_table_width(table, Cm(sum(w.cm for w in widths)))
    for column, width in zip(table.columns, widths):
        column.width = width                                     # writes the grid
    for row in table.rows:
        for cell, width in zip(row.cells, widths):
            cell.width = width                                   # changed: the width Word actually reads

if __name__ == "__main__":
    doc = Document()
    table = doc.add_table(rows=3, cols=3, style="Table Grid")
    set_column_widths(table, [Cm(8), Cm(3.5), Cm(3.5)])
    headers = ["Document", "Pages", "Status"]
    for cell, text in zip(table.rows[0].cells, headers):
        cell.text = text
    for row, values in zip(table.rows[1:], [["invoice-2026-0041.pdf", "3", "processed"],
                                            ["statement-q3.pdf", "12", "queued"]]):
        for cell, value in zip(row.cells, values):
            cell.text = value
    Path("out").mkdir(exist_ok=True)
    doc.save("out/report.docx")

table.autofit = False alone is not enough in every Word version, which is why set_table_layout_fixed writes the XML property directly. The absolute w:tblW matters too: with type="auto" Word may still scale the table to the text column, proportionally shrinking every width that was just set.

Autofit versus fixed layout With autofit, the requested eight, three point five and three point five centimetre columns render as roughly equal thirds because Word recomputes them from the text. With fixed layout, an absolute table width and a width written to every cell, the rendered columns match the request exactly, and long filenames wrap inside the wide first column instead of stretching it. autofit on requested: 8.0 / 3.5 / 3.5 cm rendered: 5.0 / 5.0 / 5.0 cm widths recomputed from text grid ignored, cells unset fixed layout requested: 8.0 / 3.5 / 3.5 cm rendered: 8.0 / 3.5 / 3.5 cm tblLayout = fixed tcW written on every cell

Variant Fix 1: Percentage Widths That Follow the Page

Absolute centimetres break when a section switches to landscape or the margins change. Percentages of the text column survive both:

# pip install python-docx
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

def set_column_percentages(table, percentages: list[float]) -> None:
    assert abs(sum(percentages) - 100) < 0.01, "percentages must total 100"
    table.autofit = False
    set_table_layout_fixed(table)
    tbl_pr = table._tbl.tblPr
    tbl_w = OxmlElement("w:tblW")
    tbl_w.set(qn("w:w"), "5000")                       # fiftieths of a percent: 5000 = 100%
    tbl_w.set(qn("w:type"), "pct")
    tbl_pr.append(tbl_w)
    for row in table.rows:
        for cell, percentage in zip(row.cells, percentages):
            tc_pr = cell._tc.get_or_add_tcPr()
            existing = tc_pr.find(qn("w:tcW"))
            if existing is not None:
                tc_pr.remove(existing)
            tc_w = OxmlElement("w:tcW")
            tc_w.set(qn("w:w"), str(int(percentage * 50)))
            tc_w.set(qn("w:type"), "pct")
            tc_pr.append(tc_w)

Word's percentage unit is fiftieths of a percent, so 100% is 5000 and a 55% column is 2750. Mixing pct on the table with dxa on the cells gives unpredictable results — keep the unit consistent across both.

Variant Fix 2: Widths Lost After Merging Cells

Merged cells replace the original w:tc elements, so widths set beforehand disappear:

# pip install python-docx
from docx.shared import Cm

def merge_then_size(table, widths: list[Cm]) -> None:
    merged = table.cell(0, 0).merge(table.cell(0, 2))    # merge first
    merged.text = "Processing summary"
    set_column_widths(table, widths)                     # changed: re-apply widths afterwards
    for row in table.rows[1:]:                           # merged row has one cell, skip it
        for cell, width in zip(row.cells, widths):
            cell.width = width

After a horizontal merge the merged cell carries a w:gridSpan, and its own width should equal the sum of the columns it spans. Applying widths after merging — rather than before — avoids reasoning about that: the helper writes each unmerged row correctly, and the merged cell inherits the grid total.

Order of operations for sized tables Create the table and add all rows and merges first, because merging replaces cell elements and discards their widths. Then disable autofit and set the table layout to fixed. Then set an absolute or percentage table width. Then write the width to every cell in every row. Save, and verify by reading the cell widths back from the saved file rather than from the in-memory object. Build the table rows, merges, text Disable autofit tblLayout = fixed Set table width absolute dxa or pct Write cell widths every cell, every row Save the file widths are now on disk Read widths back assert on the saved file

Why Reading the Width Back Can Still Mislead

table.columns[0].width returns the grid value whether or not the cells agree, so a script can report the width it wanted while Word renders something else. Read from a cell instead:

# pip install python-docx
from docx.oxml.ns import qn
from docx.shared import Emu

def effective_width(cell) -> str:
    tc_pr = cell._tc.tcPr
    tc_w = tc_pr.find(qn("w:tcW")) if tc_pr is not None else None
    if tc_w is None:
        return "unset (grid or autofit decides)"
    kind = tc_w.get(qn("w:type"))
    value = int(tc_w.get(qn("w:w")))
    return f"{Emu(value).cm:.2f}cm" if kind == "dxa" else f"{value / 50:.1f}%" if kind == "pct" else kind

A cell reporting unset is the signature of the original bug. Once every cell reports a concrete width and the table layout is fixed, what the file says and what Word draws are the same thing.

One more mismatch is worth knowing about. Word's absolute widths are stored in twentieths of a point — twips — while python-docx works in English Metric Units, and Cm(3.5).twips rounds to the nearest whole twip. A column asked for as 3.5cm comes back as 3.4999cm, which is why the verification below compares with a tolerance rather than for equality. Tolerances of a hundredth of a centimetre absorb the rounding without hiding a real discrepancy; exact comparison produces failures that no amount of code will fix.

Widths also interact with the cell margins. Word's default cell padding is 0.19cm on each side, taken from the width rather than added to it, so a 3.5cm column offers about 3.12cm of usable text space. When a heading in a narrow column wraps unexpectedly, that padding is usually the missing 0.38cm; narrowing it through w:tblCellMar buys back the space without widening the table.

Verification

Assert the layout mode and the per-cell widths on the saved file — in-memory objects hide the problem.

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Cm, Emu

def verify_widths(path: Path, expected_cm: list[float], tolerance: float = 0.02) -> None:
    doc = Document(path)
    table = doc.tables[0]
    layout = table._tbl.tblPr.find(qn("w:tblLayout"))
    assert layout is not None and layout.get(qn("w:type")) == "fixed", "table layout is not fixed"
    for row_index, row in enumerate(table.rows):
        if len(row.cells) != len(expected_cm):
            continue                                     # merged row
        for column_index, (cell, expected) in enumerate(zip(row.cells, expected_cm)):
            tc_w = cell._tc.tcPr.find(qn("w:tcW"))
            assert tc_w is not None, f"row {row_index} col {column_index}: width unset"
            actual = Emu(int(tc_w.get(qn("w:w")))).cm
            assert abs(actual - expected) < tolerance, (
                f"row {row_index} col {column_index}: {actual:.2f}cm, expected {expected}cm")
    total = sum(expected_cm)
    tbl_w = table._tbl.tblPr.find(qn("w:tblW"))
    assert tbl_w is not None and tbl_w.get(qn("w:type")) == "dxa", "table width is not absolute"
    assert abs(Emu(int(tbl_w.get(qn("w:w")))).cm - total) < tolerance, "table width does not match columns"
    print(f"{path.name}: fixed layout, {len(table.rows)} rows, widths {expected_cm} confirmed")

if __name__ == "__main__":
    verify_widths(Path("out/report.docx"), [8.0, 3.5, 3.5])

Checking that the table total equals the sum of the columns catches the other half of the problem: a table narrower than its columns makes Word rescale everything proportionally, which looks exactly like the widths being ignored again.

FAQ

Does table.autofit = False ever work on its own? Sometimes, on recent Word versions, but only when the cells also carry widths. Writing w:tblLayout explicitly is what makes it reliable across Word, LibreOffice and Google Docs.

Why do my widths change when the document goes to landscape? Absolute widths do not scale with the page. Use percentage widths, or reapply absolute widths per section — see insert page breaks and landscape sections.

Can I stop a long word from stretching a column? With fixed layout the column cannot stretch; the text wraps. Without it, an unbreakable string forces the column wider than requested.

Why is my table wider than the page? Fixed layout does not clamp the total to the text column. If the widths sum to more than the usable page width, Word lets the table overflow into the margin; check the sum against the section's page width minus its margins.

Do the widths survive PDF conversion? Yes with fixed layout — LibreOffice honours w:tcW. With autofit it re-runs its own layout, which is a common source of PDF-versus-DOCX differences.

Part of Building and Editing Word Tables.