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().

Three places a cell's appearance comes from A built-in or custom table style supplies a uniform look including banded rows and header emphasis, and needs no code beyond naming it. Conditional formatting within a style handles first row, first column and banding automatically. Direct cell properties, written as a shd or tcBorders element in the cell's tcPr, override both and are the only way to colour individual cells based on their data. Direct properties win wherever they are set. Where does the colour come from? later layers override earlier ones uniform look Table style style="Table Grid" and friends banding Style conditional formats header row and banded rows per cell w:shd in w:tcPr data-driven colour per cell edges w:tcBorders in w:tcPr one edge at a time

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.

Common shading and border recipes Header band uses a shd element with a strong fill and white bold text. Banded rows alternate a pale fill on odd rows. Status highlighting picks the fill from the row's data. A total row uses a double top border. Removing a gridline sets the edge value to none. Print-safe output avoids relying on fill alone by adding a border as well. Requirement Element Values Header band w:shd fill=2563EB, bold white text Banded rows w:shd fill=F6F8FB on odd rows Flag failed rows w:shd fill=FEF2F2 from the data Total row rule w:tcBorders top val=double sz=12 Remove one gridline w:tcBorders val=none on that edge Print-safe both fill plus a visible border

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.

Order to apply table styling Apply the table style first, because a style applied later resets direct formatting. Set column widths next, since merging and sizing replace cell elements and would discard shading. Shade the header row and set its rule. Then walk the data rows, choosing each fill from the row's own values. Add any total-row or section borders last. Finally save and read the fills back from the file to confirm they landed. Apply the table style style resets direct formats Set widths and merges cell elements are replaced Shade the header fill plus bottom rule Shade data rows banding and status colours Add borders totals, section rules Save and read back assert fills on disk

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.

Part of Building and Editing Word Tables.