Loop Table Rows in docxtpl Templates

The template has an invoice table with one row per line item. Putting a normal Jinja loop in the cell does not work:

| Description        | Qty | Amount |
| {% for i in items %}{{ i.name }} | {{ i.qty }} | {{ i.amount }}{% endfor %} |

The rendered document shows one row containing every item run together, or a table whose XML Word refuses to open — The file cannot be opened because there are problems with the contents. The loop worked; it repeated the cell contents rather than the row.

Root Cause

docxtpl renders the document's XML through Jinja. A {% for %} tag typed into a table cell lives inside a single w:tc element, so the loop body is the text of that cell — the surrounding w:tr row element is outside the loop and is emitted exactly once. Worse, if the {% for %} and {% endfor %} land in different cells, the generated XML has an opening tag inside one w:tc and its close inside another, producing overlapping elements that are not well-formed. docxtpl solves this with row tags: {%tr ... %} moves the loop boundary out to the enclosing w:tr before rendering, so the loop repeats the whole row. Equivalents exist for columns ({%tc %}), paragraphs ({%p %}) and runs ({%r %}).

Where the loop boundary lands With a plain for tag typed in a cell, the loop body is the contents of that cell, so all items are concatenated into one row and the table structure is unchanged. With a tr tag, docxtpl moves the loop boundary out to the enclosing table row element before rendering, so each iteration emits a complete row and the table grows as expected. {% for %} in a cell loop body = cell text one row, all items joined or invalid XML if split table will not open {%tr for %} loop body = whole w:tr one row per item XML stays well-formed table grows correctly

Minimal Diagnostic

Before debugging the template, ask docxtpl which tags it can see — a tag split across runs by Word's spell-checker is invisible to Jinja.

# pip install docxtpl python-docx
import re
from pathlib import Path
from docx import Document
from docxtpl import DocxTemplate

TEMPLATE = Path("templates/invoice.docx")
TAG = re.compile(r"{[%{].*?[%}]}")

def tag_report(path: Path) -> None:
    template = DocxTemplate(path)
    print("variables docxtpl sees:", sorted(template.get_undeclared_template_variables()))
    doc = Document(path)
    for index, table in enumerate(doc.tables):
        for row_index, row in enumerate(table.rows):
            for cell in row.cells:
                for tag in TAG.findall(cell.text):
                    kind = "row tag" if tag.startswith("{%tr") else "plain tag"
                    print(f"  table {index} row {row_index}: {tag}  <- {kind}")
    for paragraph in doc.paragraphs:
        tags_in_runs = [run.text for run in paragraph.runs if "{" in run.text or "}" in run.text]
        if len(tags_in_runs) > 1 and TAG.search(paragraph.text):
            print(f"  SPLIT ACROSS RUNS: {paragraph.text[:60]!r} -> {tags_in_runs}")

if __name__ == "__main__":
    tag_report(TEMPLATE)
variables docxtpl sees: ['customer', 'items', 'total']
  table 0 row 1: {% for i in items %}  <- plain tag
  table 0 row 1: {% endfor %}  <- plain tag
  SPLIT ACROSS RUNS: '{{ customer.name }}' -> ['{{ customer', '.name }}']

Two problems at once: a plain tag where a row tag belongs, and a placeholder Word has split across two runs because it flagged part of it as a spelling error.

Fix: Use Row Tags for Repeating Rows

Put {%tr for ... %} in the first cell of the row to repeat and {%tr endfor %} in the first cell of a following row. Both marker rows disappear from the output.

The template's table looks like this — each line is one table row:

| Description                | Qty        | Amount       |
| {%tr for item in items %}  |            |              |
| {{ item.name }}            | {{ item.qty }} | {{ item.amount }} |
| {%tr endfor %}             |            |              |
| Total                      |            | {{ total }}  |
# pip install docxtpl
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from docxtpl import DocxTemplate

@dataclass(frozen=True)
class LineItem:
    name: str
    qty: int
    unit: Decimal

    @property
    def amount(self) -> str:
        return f"{self.qty * self.unit:,.2f}"

def render_invoice(template_path: Path, out: Path, customer: dict, items: list[LineItem]) -> Path:
    template = DocxTemplate(template_path)
    total = sum(item.qty * item.unit for item in items)
    context = {
        "customer": customer,
        "items": items,                                       # changed: the loop's iterable
        "total": f"{total:,.2f}",
        "item_count": len(items),
    }
    missing = template.get_undeclared_template_variables() - context.keys()
    if missing:
        raise KeyError(f"template needs variables not supplied: {sorted(missing)}")   # changed: fail loudly
    template.render(context)
    out.parent.mkdir(parents=True, exist_ok=True)
    template.save(out)
    return out

if __name__ == "__main__":
    items = [LineItem("Document processing, September", 1200, Decimal("0.04")),
             LineItem("Priority queue surcharge", 180, Decimal("0.02")),
             LineItem("Archive storage, per GB month", 46, Decimal("0.11"))]
    render_invoice(Path("templates/invoice.docx"), Path("out/invoice-2026-0041.docx"),
                   {"name": "Northgate Logistics", "ref": "NGL-2026-0041"}, items)

Checking get_undeclared_template_variables() against the context before rendering is worth the three lines. Jinja renders an undefined variable as an empty string by default, so a typo in the template produces a blank cell in a customer-facing invoice with no error anywhere.

Note that item.amount is a property on the dataclass rather than a Jinja expression. Formatting money in the template means {{ "%.2f"|format(item.qty * item.unit) }} typed into a Word cell, which is exactly the kind of expression Word's autocorrect mangles. Compute in Python, present in the template.

docxtpl tag types and what each repeats A plain Jinja for tag repeats only the text inside the cell or paragraph that contains it. The tr tag repeats the enclosing table row and is used for line items. The tc tag repeats the enclosing table cell, producing extra columns. The p tag repeats the enclosing paragraph and suits repeated blocks of body text. The r tag repeats the enclosing run, for inline repetition within a sentence. Tag Repeats Use for {% for %} text in the cell rarely what you want {%tr for %} the whole table row line items, one row each {%tc for %} the table cell dynamic columns {%p for %} the paragraph repeated body blocks {%r for %} the run inline lists in a sentence {%tr if %} row, conditionally hide a row entirely

Variant Fix 1: Nested Loops and Grouped Tables

Grouping line items under headings needs a loop inside a loop, and both boundaries must be row tags:

| {%tr for group in groups %}     |     |        |
| {{ group.title }}               |     |        |
| {%tr for item in group.items %} |     |        |
| {{ item.name }}                 | {{ item.qty }} | {{ item.amount }} |
| {%tr endfor %}                  |     |        |
| Subtotal                        |     | {{ group.subtotal }} |
| {%tr endfor %}                  |     |        |
# pip install docxtpl
from itertools import groupby
from operator import attrgetter

def group_items(items: list[LineItem], key: str = "category") -> list[dict]:
    ordered = sorted(items, key=attrgetter(key))              # groupby needs sorted input
    groups = []
    for title, members in groupby(ordered, key=attrgetter(key)):
        members = list(members)
        subtotal = sum(item.qty * item.unit for item in members)
        groups.append({"title": title, "items": members, "subtotal": f"{subtotal:,.2f}"})
    return groups

groupby on unsorted input silently produces repeated groups with the same title, which in a document looks like a template bug rather than a data bug. Sorting first is not optional.

Variant Fix 2: Tags Split Across Runs

Word splits a tag across runs whenever formatting, a spell-check mark or a language change falls inside it. Repair the template rather than the data:

# pip install python-docx
from pathlib import Path
from docx import Document

def merge_tag_runs(path: Path, out: Path) -> int:
    doc = Document(path)
    repaired = 0
    targets = list(doc.paragraphs)
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                targets.extend(cell.paragraphs)
    for paragraph in targets:
        if "{" not in paragraph.text or len(paragraph.runs) < 2:
            continue
        text = paragraph.text
        for run in paragraph.runs[1:]:
            run.text = ""                                     # collapse into the first run
        paragraph.runs[0].text = text                         # keeps the first run's formatting
        repaired += 1
    doc.save(out)
    return repaired

This flattens formatting within the repaired paragraph, so run it only on paragraphs that contain tags — which the { check does. The durable fix is editorial: type tags in one go without moving the cursor, and turn off autocorrect in the template.

Authoring a looping template that works first time Type each tag in a single uninterrupted run with autocorrect disabled, so Word does not split it. Use row tags for anything that repeats a table row. Load the template in Python and list the variables docxtpl can see, which catches split and misspelled tags. Render with a context checked against that list. Open the output and assert the table has the expected number of rows before delivering it. Type tags in one run autocorrect off Row tags for rows {%tr for %} … {%tr endfor %} List visible variables catches split tags Check context vs template fail on missing names Render docxtpl writes the DOCX Assert row count header + items + total

Keeping the Template Reviewable

A template full of tags is still a Word document that someone in finance has to proofread. Two habits keep it readable: give the marker rows a visible label, and keep expressions out of the document.

Marker rows containing only {%tr for item in items %} are invisible to a reviewer, who then deletes them while tidying. Typing {%tr for item in items %} in the first cell and a plain note such as repeat for each line item in the second makes the row's purpose obvious, and both disappear on render because the whole row is consumed by the tag. The second habit — computing values in Python — means the reviewer sees {{ item.amount }} rather than a formatting expression, and a change to how money is rounded happens in code that has tests rather than in a document that does not.

It also helps to keep the template under version control alongside the code that renders it. A DOCX is a zip of XML, so git stores it as a binary blob and diffs are useless, but the history still answers the question that matters after a customer complaint: which version of the template produced this document. Recording the template's hash in the render log closes the loop, since the file on disk may have been edited since.

Verification

Open the rendered document and assert its structure, not just that the file exists.

# pip install python-docx
from pathlib import Path
from docx import Document

def verify_rendered(path: Path, expected_items: int) -> None:
    doc = Document(path)
    assert doc.tables, "no table in the rendered document"
    table = doc.tables[0]
    expected_rows = 1 + expected_items + 1                    # header + items + total
    assert len(table.rows) == expected_rows, \
        f"{len(table.rows)} rows, expected {expected_rows} — loop tag may be wrong"
    leftovers = [cell.text for row in table.rows for cell in row.cells
                 if "{%" in cell.text or "{{" in cell.text]
    assert not leftovers, f"unrendered tag(s) left in the output: {leftovers[:3]}"
    body = "\n".join(p.text for p in doc.paragraphs)
    assert "{{" not in body and "{%" not in body, "unrendered tag outside the table"
    blank = [i for i, row in enumerate(table.rows[1:-1], start=1) if not row.cells[0].text.strip()]
    assert not blank, f"empty line-item row(s) at {blank} — check for a stray marker row"
    print(f"{path.name}: {len(table.rows)} rows, {expected_items} items, no tags left")

if __name__ == "__main__":
    verify_rendered(Path("out/invoice-2026-0041.docx"), expected_items=3)

Searching the output for {{ and {% is the check that earns its place. A tag docxtpl could not see is not an error — it is copied through verbatim — so an invoice can reach a customer with {{ customer.name }} printed where the name should be, and only a scan of the rendered text catches it.

FAQ

Can I use a row tag in a nested table? Yes, and the boundaries must be in the same table. A loop opened in an outer table and closed in a nested one produces invalid XML.

Why did my marker row leave a blank row behind? The {%tr %} tag must be the only content of that row's first cell. Stray text or a space in another cell keeps the row alive.

How do I hide a whole row conditionally?{%tr if item.discount %}{%tr endif %} — the same mechanism, covered in conditional sections.

Does this work for repeating list items? Use {%p for %} around the list paragraph — see bullet and numbered lists for how the numbering behaves.

Part of Dynamic Mail Merge with Python.