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.

What each conditional tag removes A plain if tag removes the text between the tags but leaves the paragraphs that held the tags, producing blank lines. The p variant removes the enclosing paragraph including the tag line itself. The tr variant removes the enclosing table row, so no empty row with gridlines remains. The tc variant removes the cell, collapsing a column. Choose the variant matching the element that should disappear. {% if %} removes the text only, leaves blank paragraphs the usual mistake {%p if %} removes the paragraph for body text and headings {%tr if %} removes the table row for optional line items {%tc if %} removes the table cell for optional columns

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.

Choosing the right conditional tag If a whole clause of body text or a heading should disappear, use the p variant on the paragraphs holding the tags. If an optional line in a table should disappear, use the tr variant so no empty row remains. If an entire column is optional, use the tc variant. If only a few words inside a sentence change, a plain inline if tag is correct, because no element needs removing. If a whole page should appear or not, combine the p variant with a conditional page break. What should disappear when false? pick the tag by element a clause or heading {%p if %} removes the paragraphs a table row {%tr if %} no empty row left a column {%tc if %} removes the cell words in a sentence {% if %} inline nothing to remove

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.

Rendering conditionals safely Validate the data and the combinations it allows before rendering, so an impossible combination fails in the job rather than in the document. Compare the template's declared variables with the context to catch typos. Render. Scan the output for leftover tag text, which indicates a tag docxtpl could not see. Count blank paragraphs and empty table rows against the expected number for that branch. Only then deliver. Validate the data reject impossible combinations Check template variables catches typos Render docxtpl writes the DOCX Scan for leftover tags {{ or {% in the text Count blanks and rows per branch expectation Deliver or quarantine with reason

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.

Part of Dynamic Mail Merge with Python.