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 %}).
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.
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.
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.
Related
- Dynamic Mail Merge with Python — the templating workflow end to end
- Add Conditional Sections to docxtpl Templates — showing and hiding blocks
- Generate One DOCX per Row from Excel — driving the merge from a spreadsheet
- Building and Editing Word Tables — building tables in code instead
Part of Dynamic Mail Merge with Python.