Add Cell Shading and Borders with python-docx
python-docx exposes fonts, alignment and widths, but searching for a way to colour a cell turns up nothing:
cell = table.cell(0, 0)
cell.background_color = "DDEBF7" # AttributeError: 'ROW' object has no attribute ...
cell.borders.bottom = "single" # no such API
There is no attribute for either. Both are real Word features, so the file format supports them — the library simply does not wrap them, and the fix is to append the elements yourself.
Root Cause
python-docx models the parts of WordprocessingML that map cleanly onto a document object model: paragraphs, runs, tables, sections. Cell shading (w:shd) and cell borders (w:tcBorders) live in a cell's properties element, w:tcPr, and have never been wrapped by the library. The table style can carry both — which is why style="Light Grid Accent 1" produces shading without any custom code — but a style applies uniformly, so per-cell decisions like colouring only the rows that failed validation still need direct XML. Everything needed is one OxmlElement per property, appended to cell._tc.get_or_add_tcPr().
Minimal Diagnostic
Show what a cell currently carries, so it is clear whether a colour comes from the style or from the cell.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
DOCX = Path("out/table.docx")
def cell_report(path: Path, max_rows: int = 3) -> None:
doc = Document(path)
table = doc.tables[0]
print(f"table style: {table.style.name if table.style else 'none'}")
for row_index, row in enumerate(table.rows[:max_rows]):
parts = []
for column_index, cell in enumerate(row.cells):
tc_pr = cell._tc.tcPr
shading = tc_pr.find(qn("w:shd")) if tc_pr is not None else None
borders = tc_pr.find(qn("w:tcBorders")) if tc_pr is not None else None
fill = shading.get(qn("w:fill")) if shading is not None else "-"
edges = ",".join(child.tag.split("}")[1] for child in borders) if borders is not None else "-"
parts.append(f"c{column_index} fill={fill} borders={edges}")
print(f" row {row_index}: " + " | ".join(parts))
if __name__ == "__main__":
cell_report(DOCX)
table style: Table Grid
row 0: c0 fill=- borders=- | c1 fill=- borders=- | c2 fill=- borders=-
row 1: c0 fill=- borders=- | c1 fill=- borders=- | c2 fill=- borders=-
Every cell is bare: the gridlines visible in Word come from the Table Grid style, not from the cells, so removing a single border needs a cell-level override.
Fix: Write Shading and Borders Directly
Two helpers cover nearly every requirement — one for fill colour, one for individual edges.
# 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 Pt
def shade_cell(cell, fill: str, pattern: str = "clear") -> None:
"""fill is a six-digit hex colour without '#', or 'auto'."""
tc_pr = cell._tc.get_or_add_tcPr()
existing = tc_pr.find(qn("w:shd"))
if existing is not None:
tc_pr.remove(existing) # changed: replace rather than stack
shading = OxmlElement("w:shd")
shading.set(qn("w:val"), pattern)
shading.set(qn("w:color"), "auto")
shading.set(qn("w:fill"), fill.lstrip("#").upper()) # changed: Word wants bare hex
tc_pr.append(shading)
EDGES = ("top", "left", "bottom", "right", "insideH", "insideV")
def set_cell_border(cell, **kwargs) -> None:
"""set_cell_border(cell, bottom={"sz": 12, "color": "2563EB", "val": "single"})"""
tc_pr = cell._tc.get_or_add_tcPr()
borders = tc_pr.find(qn("w:tcBorders"))
if borders is None:
borders = OxmlElement("w:tcBorders")
tc_pr.append(borders) # changed: one container, reused
for edge in EDGES:
spec = kwargs.get(edge)
if spec is None:
continue
tag = f"w:{edge}"
element = borders.find(qn(tag))
if element is None:
element = OxmlElement(tag)
borders.append(element)
element.set(qn("w:val"), str(spec.get("val", "single")))
element.set(qn("w:sz"), str(spec.get("sz", 8))) # eighths of a point: 8 = 1pt
element.set(qn("w:space"), str(spec.get("space", 0)))
element.set(qn("w:color"), str(spec.get("color", "000000")).lstrip("#").upper())
if __name__ == "__main__":
doc = Document()
table = doc.add_table(rows=4, cols=3, style="Table Grid")
rows = [("Document", "Pages", "Status"),
("invoice-2026-0041.pdf", "3", "processed"),
("statement-q3.pdf", "12", "failed"),
("contract-a.pdf", "7", "processed")]
for row, values in zip(table.rows, rows):
for cell, value in zip(row.cells, values):
cell.text = value
for cell in table.rows[0].cells:
shade_cell(cell, "2563EB") # changed: header band
cell.paragraphs[0].runs[0].font.bold = True
cell.paragraphs[0].runs[0].font.color.rgb = None
set_cell_border(cell, bottom={"sz": 16, "color": "1E40AF"})
for index, row in enumerate(table.rows[1:], start=1):
failed = row.cells[2].text == "failed"
fill = "FEF2F2" if failed else ("F6F8FB" if index % 2 else "FFFFFF")
for cell in row.cells:
shade_cell(cell, fill) # changed: banding plus data-driven colour
Path("out").mkdir(exist_ok=True)
doc.save("out/table.docx")
Border width is in eighths of a point, so sz: 8 is a hairline and sz: 16 is 2pt — the most common cause of a border that looks far heavier than intended. val accepts single, double, dashed, dotted, thick and none; none is how a specific gridline gets removed from a table that otherwise has them.
Variant Fix 1: Shade a Whole Row or Column in One Call
Per-cell calls get verbose. Wrap them:
# pip install python-docx
def shade_row(table, row_index: int, fill: str) -> None:
for cell in table.rows[row_index].cells:
shade_cell(cell, fill)
def shade_column(table, column_index: int, fill: str, skip_header: bool = True) -> None:
for row in table.rows[1:] if skip_header else table.rows:
if column_index < len(row.cells):
shade_column_cell = row.cells[column_index]
shade_cell(shade_column_cell, fill)
def band_rows(table, odd: str = "F6F8FB", even: str = "FFFFFF", start: int = 1) -> None:
for offset, row in enumerate(table.rows[start:]):
for cell in row.cells:
shade_cell(cell, odd if offset % 2 == 0 else even)
Guarding on column_index < len(row.cells) matters once merges are involved: a row containing a horizontally merged cell has fewer cells than the grid has columns, and indexing past the end raises IndexError on what looks like a rectangular table. The same caution applies when setting widths, covered in column width ignored.
Variant Fix 2: A Reusable Table Style Instead of Per-Cell XML
When every table in a document should look the same, define the appearance once in a template and apply it by name:
# pip install python-docx
from docx import Document
def apply_house_style(table, style_name: str = "House Table") -> bool:
available = {s.name for s in table.part.document.styles}
if style_name not in available:
return False # template lacks it; fall back to direct XML
table.style = style_name
return True
def style_or_shade(table) -> None:
if not apply_house_style(table):
for cell in table.rows[0].cells:
shade_cell(cell, "2563EB")
band_rows(table)
A style defined in the template carries its own conditional formatting for the header row and banding, so the document stays editable by hand afterwards — a reviewer can add a row and it picks up the band automatically, which direct shading cannot do. Keep the direct-XML path as the fallback for documents built from a blank template.
The ordering matters more than it looks. Assigning table.style after shading cells resets the direct formatting on some Word versions, because applying a style rewrites the table properties; applying it first and shading afterwards always works. The same trap catches merges and widths: both replace the underlying cell elements, taking any shading with them. Build the structure, then colour it.
Making Colour Accessible
Colour alone is a poor signal: it disappears in greyscale printing and is invisible to some readers. Pair every meaningful fill with a text cue.
# pip install python-docx
STATUS_STYLES = {
"processed": ("DCFCE7", "OK"),
"queued": ("FFF7ED", "WAIT"),
"failed": ("FEF2F2", "FAIL"),
}
def mark_status(cell, status: str) -> None:
fill, badge = STATUS_STYLES.get(status, ("FFFFFF", ""))
shade_cell(cell, fill)
cell.text = f"{badge} {status}" if badge else status
if badge:
run = cell.paragraphs[0].runs[0]
run.font.bold = True
The badge survives greyscale printing, copy-paste into plain text, and conversion to PDF. It also survives someone re-sorting the table in Word: shading is attached to the cell and moves with the row, but a reader scanning a printed copy has nothing else to go on.
Verification
Read the saved file and assert the fills and borders landed where intended.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
def cell_fill(cell) -> str | None:
tc_pr = cell._tc.tcPr
shading = tc_pr.find(qn("w:shd")) if tc_pr is not None else None
return shading.get(qn("w:fill")) if shading is not None else None
def verify_styling(path: Path, header_fill: str, expected_flagged: int) -> None:
doc = Document(path)
table = doc.tables[0]
header = {cell_fill(cell) for cell in table.rows[0].cells}
assert header == {header_fill.upper()}, f"header fills {header}, expected {header_fill}"
flagged = sum(1 for row in table.rows[1:] if cell_fill(row.cells[0]) == "FEF2F2")
assert flagged == expected_flagged, f"{flagged} flagged row(s), expected {expected_flagged}"
for row in table.rows[1:]:
fills = {cell_fill(cell) for cell in row.cells}
assert len(fills) == 1, f"row has mixed fills {fills}"
assert None not in fills, "row has an unshaded cell"
print(f"{path.name}: header {header_fill}, {flagged} flagged row(s), banding consistent")
if __name__ == "__main__":
verify_styling(Path("out/table.docx"), "2563EB", expected_flagged=1)
Asserting that each row has exactly one fill catches the most common styling bug: a loop that shades row.cells[:2] because a merge made the third cell disappear, leaving one cell white in an otherwise banded row.
FAQ
Why is my hex colour rejected?
Word wants six hex digits with no # and no alpha channel. #2563EB and 2563EBFF both fail silently, rendering as no fill.
Can I shade only part of a cell's text?
Yes, but that is run-level highlighting (w:highlight or run shading), not cell shading — it colours behind the characters only.
How do I remove all borders from one cell?
Call set_cell_border with val: "none" on each of top, left, bottom and right; the table style's gridlines will no longer show for that cell.
Does shading print? Word's print settings include a background-graphics option that is off by default in some configurations, which is why a shaded table can look right on screen and print white. Cell shading usually prints regardless, but a page or paragraph background may not.
Why did my shading disappear after I merged cells? Merging replaces the cell elements, discarding their properties. Apply shading after every merge is done.
Do fills survive conversion to PDF?
Yes — LibreOffice renders w:shd faithfully. See Converting DOCX to PDF with Python.
Related
- Building and Editing Word Tables — the full table workflow
- Fix python-docx Table Column Width Ignored — sizing the columns before styling them
- Set Fonts and Styles with python-docx — text appearance inside cells
- Converting DOCX to PDF with Python — how styling carries into PDF
Part of Building and Editing Word Tables.