Add Conditional Sections to docxtpl Templates
One contract template, three customer tiers, and a clause that should only appear for two of them. The obvious attempt leaves debris:
{% if tier == "enterprise" %}
9. Dedicated Support
The Supplier shall provide a named account manager...
{% endif %}
When the condition is false the clause text vanishes, but two empty paragraphs remain where the tag lines were, the numbering of subsequent clauses is now wrong, and in a table the same pattern leaves an empty row with visible gridlines.
Root Cause
A {% if %} typed on its own line in Word is not a control line — it is a paragraph. Jinja removes the text between the tags, but the paragraphs that contained the tags are still w:p elements in the document, so they render as empty lines. The same applies to table rows: the row holding {% endif %} survives as an empty row. docxtpl's answer is the same family of tags used for loops: {%p if %} removes the enclosing paragraph, {%tr if %} removes the enclosing table row, and {%tc if %} removes the cell. The tag consumes the element it lives in, so nothing is left behind.
Minimal Diagnostic
Render with each branch of the condition and count what is actually left in the document.
# pip install docxtpl python-docx
from pathlib import Path
from docx import Document
from docxtpl import DocxTemplate
TEMPLATE = Path("templates/contract.docx")
def render_and_measure(context: dict, label: str) -> None:
template = DocxTemplate(TEMPLATE)
template.render(context)
out = Path(f"out/probe-{label}.docx")
out.parent.mkdir(exist_ok=True)
template.save(out)
doc = Document(out)
blank = sum(1 for p in doc.paragraphs if not p.text.strip())
empty_rows = sum(1 for t in doc.tables for row in t.rows
if not any(cell.text.strip() for cell in row.cells))
print(f" {label:<12} paragraphs={len(doc.paragraphs):<4} blank={blank:<4} "
f"empty table rows={empty_rows}")
if __name__ == "__main__":
for tier in ("enterprise", "standard"):
render_and_measure({"tier": tier, "customer": "Northgate", "discount": 0}, tier)
enterprise paragraphs=64 blank=6 empty table rows=0
standard paragraphs=58 blank=12 empty table rows=3
The false branch is six paragraphs shorter but has six extra blank ones and three empty table rows — exactly the tag lines that were not removed.
Fix: Use Element-Scoped Conditional Tags
Replace each plain tag with the variant matching the element that should disappear.
The template becomes:
{%p if tier == "enterprise" %}
9. Dedicated Support
The Supplier shall provide a named account manager...
{%p endif %}
and inside a table, the optional row is:
| {%tr if discount %} Discount | | -{{ discount }}% |
| {%tr endif %} | | |
# pip install docxtpl
from dataclasses import dataclass
from pathlib import Path
from docxtpl import DocxTemplate
@dataclass(frozen=True)
class Terms:
tier: str
discount: int = 0
sla_hours: int | None = None
data_residency: str | None = None
def context(self) -> dict:
return {
"tier": self.tier,
"is_enterprise": self.tier == "enterprise", # changed: name the condition
"discount": self.discount,
"sla_hours": self.sla_hours,
"has_sla": self.sla_hours is not None, # changed: explicit, not truthiness
"data_residency": self.data_residency,
"has_residency": bool(self.data_residency),
}
def render_contract(template_path: Path, out: Path, customer: str, terms: Terms) -> Path:
template = DocxTemplate(template_path)
context = {"customer": customer, **terms.context()}
missing = template.get_undeclared_template_variables() - context.keys()
if missing:
raise KeyError(f"template needs: {sorted(missing)}")
template.render(context)
out.parent.mkdir(parents=True, exist_ok=True)
template.save(out)
return out
if __name__ == "__main__":
render_contract(Path("templates/contract.docx"), Path("out/northgate.docx"),
"Northgate Logistics", Terms(tier="enterprise", sla_hours=4, discount=12))
Exposing is_enterprise and has_sla as named booleans rather than writing {%p if sla_hours %} in the document is worth doing. sla_hours = 0 is falsy but meaningful, and a reviewer reading the template understands has_sla without knowing Python's truthiness rules. Conditions in a contract template are read by lawyers, not programmers.
Variant Fix 1: Optional Sections With Their Own Page Breaks
An appendix that only some customers get should take its page break with it:
# pip install docxtpl python-docx
from docx import Document
def strip_orphan_breaks(path, out) -> int:
"""Remove page breaks left at the start of a document or doubled by a hidden section."""
from docx.oxml.ns import qn
doc = Document(path)
removed = 0
previous_was_break = True # a break on the first paragraph is orphaned
for paragraph in doc.paragraphs:
breaks = paragraph._p.findall(f".//{qn('w:br')}")
page_breaks = [b for b in breaks if b.get(qn("w:type")) == "page"]
is_break_only = page_breaks and not paragraph.text.strip()
if is_break_only and previous_was_break:
paragraph._p.getparent().remove(paragraph._p)
removed += 1
continue
previous_was_break = is_break_only
doc.save(out)
return removed
The cleaner approach is to put the page break inside the conditional block — as the first paragraph after {%p if %} — so it is removed with everything else. This cleanup function is for templates that cannot be changed, and for the case where two adjacent optional sections are both hidden, leaving their breaks stacked.
Variant Fix 2: Choosing Among Several Variants
elif works exactly as in Jinja, and keeps three near-identical templates from existing at all:
{%p if tier == "enterprise" %}
Support is provided 24/7 with a {{ sla_hours }}-hour response commitment.
{%p elif tier == "business" %}
Support is provided during business hours with a next-business-day commitment.
{%p else %}
Support is provided by email on a best-effort basis.
{%p endif %}
# pip install docxtpl
VALID_TIERS = {"enterprise", "business", "standard"}
def validated_context(terms: Terms) -> dict:
if terms.tier not in VALID_TIERS:
raise ValueError(f"unknown tier {terms.tier!r}; expected one of {sorted(VALID_TIERS)}")
if terms.tier == "enterprise" and terms.sla_hours is None:
raise ValueError("enterprise terms require sla_hours") # the template will print a blank otherwise
return terms.context()
Validating the combination, not just the value, is the part that prevents a silent blank. The else branch in the template catches an unknown tier at render time, but by then a document has been produced; raising before rendering means the failure lands in the job log rather than in a customer's inbox.
Why Blank Paragraphs Are Worth Chasing
It is tempting to leave a stray empty line rather than fight the template. In a contract it is not cosmetic. Automatic clause numbering counts paragraphs, so a hidden section that leaves its tag paragraphs behind produces a document whose clause 9 is followed by clause 11. Cross-references elsewhere in the document then point at the wrong clause, and because the references are fields Word resolves at open time, the error appears only on the recipient's screen. Removing the element rather than its text keeps the numbering — and every reference to it — correct.
The same reasoning applies to tables of contents and to any conversion step downstream. Converting a document with stray empty paragraphs to PDF preserves them faithfully, and a signature block pushed onto its own page by three invisible blank lines is a question the customer asks before signing.
Testing Every Branch, Not Every Document
A template with four independent conditions has sixteen possible documents, and rendering all sixteen on every change is cheap compared with discovering a broken combination in production. Enumerate them from the flags themselves:
# stdlib only
from itertools import product
FLAGS = ["is_enterprise", "has_sla", "has_residency", "discount"]
def all_branches(base: dict) -> list[dict]:
contexts = []
for values in product([False, True], repeat=len(FLAGS)):
context = dict(base)
context.update(dict(zip(FLAGS, values)))
context["discount"] = 12 if context["discount"] else 0
context["sla_hours"] = 4 if context["has_sla"] else None
contexts.append(context)
return contexts
Rendering sixteen documents takes under a second, and the assertions below run on each. Combinations that should be impossible — a standard tier with an SLA, say — should raise from the validation function rather than produce a document, and a test that asserts the raise is how that rule stays true after the next template edit.
Verification
Render every branch the template supports and assert the result for each.
# pip install docxtpl python-docx
from pathlib import Path
from docx import Document
def verify_branch(path: Path, must_contain: list[str], must_not_contain: list[str],
max_blank: int = 4) -> None:
doc = Document(path)
text = "\n".join(p.text for p in doc.paragraphs)
for table in doc.tables:
text += "\n" + "\n".join(cell.text for row in table.rows for cell in row.cells)
for phrase in must_contain:
assert phrase in text, f"{path.name}: missing expected phrase {phrase!r}"
for phrase in must_not_contain:
assert phrase not in text, f"{path.name}: hidden section leaked {phrase!r}"
assert "{%" not in text and "{{" not in text, f"{path.name}: unrendered tag left in output"
blank = sum(1 for p in doc.paragraphs if not p.text.strip())
assert blank <= max_blank, f"{path.name}: {blank} blank paragraphs, expected at most {max_blank}"
empty_rows = [i for t in doc.tables for i, row in enumerate(t.rows)
if not any(cell.text.strip() for cell in row.cells)]
assert not empty_rows, f"{path.name}: empty table row(s) at {empty_rows}"
print(f"{path.name}: branch verified, {blank} blank paragraph(s)")
if __name__ == "__main__":
verify_branch(Path("out/northgate.docx"),
must_contain=["Dedicated Support", "4-hour response"],
must_not_contain=["best-effort basis"])
Asserting on what must not appear is the half that gets skipped and matters most: a conditional that never evaluates false looks perfect in testing and sends enterprise terms to a standard-tier customer.
FAQ
Can I combine a loop and a condition on one row?
Not on the same row. Use {%tr for %} on one marker row and {%tr if %} on another, or filter the list in Python before rendering.
Does {%p if %} work inside a table cell?
Yes — it removes the paragraph within the cell, leaving the cell and row intact. Use {%tr if %} to remove the row itself.
How do I keep the numbering correct across hidden clauses? Use Word's automatic numbering rather than typed numbers, and remove whole paragraphs so the count stays right.
Why does my condition always evaluate true?
Usually a string: "False" and "0" are both truthy. Pass real booleans from Python, as the fix does.
Related
- Dynamic Mail Merge with Python — the templating workflow end to end
- Loop Table Rows in docxtpl Templates — repeating rows with the same tag family
- Generate One DOCX per Row from Excel — driving conditions from spreadsheet data
- Converting DOCX to PDF with Python — delivering the rendered document
Part of Dynamic Mail Merge with Python.