Insert Images into Table Cells with python-docx

Building a product catalogue or an inspection report means a picture in every row. The obvious call puts it somewhere else entirely:

table = doc.add_table(rows=3, cols=2)
for row, (name, photo) in zip(table.rows, products):
    row.cells[0].text = name
    doc.add_picture(photo, width=Cm(4))     # lands after the table, not in the cell

Every image appears stacked below the table. Moving to cell.add_paragraph().add_run().add_picture(...) puts them in the right place but leaves a blank line above each one, and any image wider than the column silently stretches the whole table past the page margin.

Root Cause

Document.add_picture is a convenience that appends a paragraph to the document body and puts the picture in it — it has no notion of the cell being worked on. Pictures live in runs, so placing one inside a cell means reaching a run inside one of that cell's paragraphs. That is where the second problem starts: every table cell is created containing exactly one empty paragraph, so calling cell.add_paragraph() adds a second one and the first renders as a blank line above the image. The third problem is sizing. A picture inserted without a width uses its native pixel dimensions at 96 DPI, so a 1500-pixel photo becomes about 40cm wide; with the table set to autofit, Word widens the column to match and pushes the table off the page.

Where a picture actually lives A table cell contains one or more paragraphs. Each paragraph contains runs. A picture is an inline shape inside a run. To place an image in a cell, reach the cell's existing first paragraph, add a run to it, and add the picture to that run. Calling add_picture on the document object instead appends a new paragraph to the document body, below the table, which is why the images appear in the wrong place. Table cell (w:tc) starts with one empty paragraph reuse it or get a blank line Paragraph (w:p) reuse the first one alignment is set here Run (w:r) add one to the paragraph holds the image Inline picture run.add_picture(path, width=…) size it explicitly

Minimal Diagnostic

Report where the pictures actually ended up and how wide they are.

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

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

def picture_report(path: Path) -> None:
    doc = Document(path)
    body_pictures = sum(len(p.runs and [r for r in p.runs if r._r.findall(
        ".//{http://schemas.openxmlformats.org/drawingml/2006/main}blip")]) for p in doc.paragraphs)
    print(f"pictures in the document body: {body_pictures}")
    for table_index, table in enumerate(doc.tables):
        for row_index, row in enumerate(table.rows):
            for column_index, cell in enumerate(row.cells):
                shapes = cell._tc.findall(
                    ".//{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}inline")
                blanks = sum(1 for p in cell.paragraphs if not p.text.strip() and not p.runs)
                if shapes or blanks:
                    widths = []
                    for inline in shapes:
                        extent = inline.find(
                            "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}extent")
                        widths.append(f"{Emu(int(extent.get('cx'))).cm:.1f}cm" if extent is not None else "?")
                    print(f"  table {table_index} r{row_index}c{column_index}: "
                          f"{len(shapes)} picture(s) {widths}, {blanks} blank paragraph(s), "
                          f"{len(cell.paragraphs)} paragraph(s)")

if __name__ == "__main__":
    picture_report(DOCX)
pictures in the document body: 3
  table 0 r0c1: 0 picture(s) [], 1 blank paragraph(s), 1 paragraph(s)
  table 0 r1c1: 0 picture(s) [], 1 blank paragraph(s), 1 paragraph(s)

Three pictures in the body, none in the cells: the exact signature of doc.add_picture inside a cell loop.

Fix: Reuse the Cell's First Paragraph

Reach the cell's existing paragraph, add a run to it, and size the picture against the column width.

# pip install python-docx pillow
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Cm, Length
from PIL import Image

def cell_paragraph(cell):
    """The cell's first paragraph, reused if it is empty."""
    paragraph = cell.paragraphs[0]
    if paragraph.text or paragraph.runs:
        paragraph = cell.add_paragraph()                       # only add when the first is in use
    return paragraph                                           # changed: no leading blank line

def fitted_size(image_path: Path, max_width: Length, max_height: Length | None = None):
    with Image.open(image_path) as image:
        pixel_width, pixel_height = image.size
    ratio = pixel_height / pixel_width
    width = max_width
    height = Cm(width.cm * ratio)
    if max_height is not None and height.cm > max_height.cm:    # changed: fit both dimensions
        height = max_height
        width = Cm(height.cm / ratio)
    return width, height

def add_picture_to_cell(cell, image_path: Path, max_width: Length,
                        max_height: Length | None = None, centre: bool = True) -> None:
    paragraph = cell_paragraph(cell)
    if centre:
        paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
    width, height = fitted_size(image_path, max_width, max_height)
    run = paragraph.add_run()                                  # changed: pictures live in runs
    run.add_picture(str(image_path), width=width, height=height)

if __name__ == "__main__":
    products = [("Ledger scanner A2", Path("img/a2.png")),
                ("Batch feeder C40", Path("img/c40.png")),
                ("Archive shelf unit", Path("img/shelf.png"))]
    doc = Document()
    table = doc.add_table(rows=len(products), cols=2, style="Table Grid")
    table.autofit = False
    text_width, image_width = Cm(9), Cm(5.5)
    for row, (name, image_path) in zip(table.rows, products):
        for cell, width in zip(row.cells, (text_width, image_width)):
            cell.width = width                                 # changed: fix the column before sizing images
        row.cells[0].text = name
        add_picture_to_cell(row.cells[1], image_path,
                            max_width=image_width - Cm(0.5),   # changed: allow for cell padding
                            max_height=Cm(4))
    Path("out").mkdir(exist_ok=True)
    doc.save("out/catalogue.docx")

Subtracting half a centimetre from the column width accounts for Word's default cell padding of 0.19cm per side; a picture exactly as wide as the column overflows it and widens the table. Passing both width and height from fitted_size keeps the aspect ratio under control — passing only one lets python-docx scale the other, which is fine until an image arrives with a non-square pixel aspect and the result looks subtly stretched.

Three ways to put an image in a cell Calling add_picture on the document object places every image below the table. Calling cell.add_paragraph and adding the picture there puts it in the right cell but leaves the cell's original empty paragraph as a blank line above it, making every row taller. Reusing the cell's first paragraph and adding a run to it places the image with no blank line and lets the paragraph's alignment centre it. doc.add_picture(...) image lands below the table cells stay empty rows misaligned with text silently wrong reuse cell.paragraphs[0] image lands in the cell no blank line above centred by the paragraph row height fits the image

Variant Fix 1: Sizing Against the Real Column Width

Hard-coding the column width works until the table changes. Read it from the cell instead:

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

CELL_PADDING = Cm(0.19)

def usable_width(cell, fallback: Cm = Cm(5)) -> Cm:
    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 or tc_w.get(qn("w:type")) != "dxa":
        return fallback                                  # autofit or percentage: no fixed width to read
    return Cm(Emu(int(tc_w.get(qn("w:w")))).cm - 2 * CELL_PADDING.cm)

Returning a fallback rather than raising matters because a cell with no explicit width is the normal state of an autofit table — the width simply does not exist yet. Setting the widths first, as described in column width ignored, is what makes this function useful.

Variant Fix 2: Missing and Broken Image Files

In a catalogue of four hundred products, some images will be missing, truncated or in a format Word cannot embed. Handle that per cell rather than letting the whole job fail:

# pip install python-docx pillow
from pathlib import Path
from PIL import Image, UnidentifiedImageError

USABLE = {"PNG", "JPEG", "GIF", "BMP", "TIFF"}

def add_picture_or_note(cell, image_path: Path, max_width, max_height=None) -> str:
    if not image_path.exists():
        cell.text = "[image not supplied]"
        return "missing"
    try:
        with Image.open(image_path) as image:
            image.verify()                               # catches truncated files
            fmt = image.format
    except (UnidentifiedImageError, OSError) as error:
        cell.text = f"[unreadable image: {type(error).__name__}]"
        return "unreadable"
    if fmt not in USABLE:
        cell.text = f"[unsupported format: {fmt}]"
        return "unsupported"
    add_picture_to_cell(cell, image_path, max_width, max_height)
    return "ok"

Writing the reason into the cell is deliberate. A blank cell in a four-hundred-row catalogue is indistinguishable from a product that has no photo; a cell reading [image not supplied] tells whoever reviews the document exactly what to chase. Returning a status lets the caller count the outcomes and fail the job if too many rows are incomplete.

Picking a target width for common table layouts A two column catalogue on A4 portrait with a nine centimetre text column leaves five and a half centimetres for images, of which about five is usable after padding. A three column comparison gives each column roughly five centimetres and about four and a half usable. A thumbnail column beside a wide description uses two and a half centimetres, about two usable. A full width figure row spanning the table uses the whole text column less padding. In every case subtract roughly four millimetres for the default cell margins. Layout Column width Image max width 2-col catalogue, A4 9.0 + 5.5cm 5.0cm 3-col comparison 5.0cm each 4.5cm Thumbnail + text 2.5 + 13cm 2.0cm Full-width figure row 16cm merged 15.5cm Landscape, 2-col 12.5 + 9cm 8.5cm

Keeping the File Size Sensible

python-docx embeds each image file exactly as supplied. A catalogue with four hundred 4-megapixel photographs produces a document of several hundred megabytes that Word opens slowly and email rejects. Downscaling before insertion costs a few lines and usually removes ninety percent of the size:

# pip install pillow
import io
from pathlib import Path
from PIL import Image

def prepared(image_path: Path, target_cm: float, dpi: int = 200) -> io.BytesIO:
    target_pixels = int(target_cm / 2.54 * dpi)
    with Image.open(image_path) as image:
        image = image.convert("RGB") if image.mode in ("RGBA", "P") else image
        if image.width > target_pixels:
            ratio = target_pixels / image.width
            image = image.resize((target_pixels, int(image.height * ratio)), Image.LANCZOS)
        buffer = io.BytesIO()
        image.save(buffer, format="JPEG", quality=85, optimize=True)
    buffer.seek(0)
    return buffer

run.add_picture accepts a file-like object, so the buffer goes straight in without a temporary file. Two hundred DPI is the useful ceiling for a document that may be printed; ninety-six is enough for one that will only be read on screen. Converting RGBA to RGB avoids the black-background artefact that appears when a transparent PNG is saved as JPEG — keep PNG for logos and line art where transparency matters.

Verification

Confirm the pictures are in the cells, sized within the columns, and that no blank paragraphs crept in.

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

NS_WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"

def verify_cell_images(path: Path, expected: int, max_width_cm: float) -> None:
    doc = Document(path)
    body = [p for p in doc.paragraphs if p._p.findall(f".//{NS_WP}inline")]
    assert not body, f"{len(body)} picture(s) in the document body, expected all in cells"
    found, oversized, blanks = 0, [], []
    for table in doc.tables:
        for row_index, row in enumerate(table.rows):
            for column_index, cell in enumerate(row.cells):
                inlines = cell._tc.findall(f".//{NS_WP}inline")
                found += len(inlines)
                for inline in inlines:
                    extent = inline.find(f"{NS_WP}extent")
                    width_cm = Emu(int(extent.get("cx"))).cm
                    if width_cm > max_width_cm + 0.01:
                        oversized.append(f"r{row_index}c{column_index}={width_cm:.2f}cm")
                if inlines and len([p for p in cell.paragraphs if not p.text and not p.runs]):
                    blanks.append(f"r{row_index}c{column_index}")
    assert found == expected, f"{found} picture(s) in cells, expected {expected}"
    assert not oversized, f"picture(s) wider than the column: {oversized}"
    assert not blanks, f"blank paragraph(s) beside a picture at {blanks}"
    print(f"{path.name}: {found} picture(s) in cells, none oversized, no blank lines")

if __name__ == "__main__":
    verify_cell_images(Path("out/catalogue.docx"), expected=3, max_width_cm=5.0)

The body-picture assertion is the one that catches the original bug, and it is worth keeping even after the code is correct: it fails immediately if someone adds a figure with doc.add_picture inside a cell loop again.

FAQ

Can I put text and an image in the same cell? Yes — set the first paragraph's text, then add a second paragraph for the picture. The helper does this automatically when the first paragraph is in use.

Why is my row much taller than the image? Usually a blank paragraph below the picture, or a row height set explicitly. Check len(cell.paragraphs).

Does the image keep its aspect ratio? Only if one dimension is given, or both are computed from the source as fitted_size does. Setting both to arbitrary values stretches it.

Can I anchor an image behind the text? Not through python-docx's API — floating pictures need direct XML. Inline pictures in cells cover nearly every report layout.

Part of Inserting Images into Word Documents.

/html>