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.
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.
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.
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.
Related
- Building and Editing Word Tables — the full table workflow
- Add Cell Shading and Borders with python-docx — styling the cells once they are sized
- Insert Page Breaks and Landscape Sections — wide tables on rotated pages
- Converting DOCX to PDF with Python — how layout carries into PDF
Part of Building and Editing Word Tables.