Fix docxtpl Jinja2 UndefinedError

jinja2.exceptions.UndefinedError: 'xxx' is undefined surfaces when template.render(context) encounters a {{ xxx }} placeholder in the .docx template whose name is not present as a key in the context dict. The exception aborts the render entirely — no output file is written.

The three most common causes are: a typo between a template placeholder and a CSV column header, a column that is present in some rows but NaN in others, and an attribute access on a dict key that does not exist (e.g. {{ item.price }} when item is a plain string).

Root Cause

Jinja2's default Undefined strategy is strict: referencing a missing name raises UndefinedError immediately rather than rendering an empty string. docxtpl inherits this behavior unchanged. The full traceback points to the exact variable name in the exception message, making diagnosis straightforward once you know where to look.

jinja2.exceptions.UndefinedError: 'client_tier' is undefined

The name after the colon (client_tier) is the key your template expects but your context dict does not contain.

Minimal Reproducible Diagnostic

Isolate the problem by rendering a single row with an explicit context dict and printing what you have vs what the template expects.

# pip install docxtpl
from pathlib import Path
from docxtpl import DocxTemplate

TEMPLATE = Path("templates/letter_template.docx")

# Step 1 — print every placeholder the template uses
tpl = DocxTemplate(str(TEMPLATE))
print("Template variables:", tpl.get_undeclared_template_variables())

# Step 2 — print what your context actually contains
context = {
    "first_name": "Alice",
    "last_name":  "Smith",
    # "client_tier" is intentionally missing to reproduce the error
}
print("Context keys:", list(context.keys()))

# Step 3 — attempt render; the UndefinedError names the missing key
try:
    tpl.render(context)
except Exception as exc:
    print(f"Error: {exc}")

get_undeclared_template_variables() returns the set of all names the template references. Diff that set against your context keys to find every gap at once, not just the first failure.

Fix 1 — Align Context Keys with Template Placeholders

The most direct fix: make sure every name the template uses exists in the context dict.

# pip install docxtpl pandas
import pandas as pd
from docxtpl import DocxTemplate
from pathlib import Path

TEMPLATE = Path("templates/letter_template.docx")
DATA     = Path("data/recipients.csv")

tpl      = DocxTemplate(str(TEMPLATE))
required = tpl.get_undeclared_template_variables()   # {'first_name', 'client_tier', ...}

df = pd.read_csv(DATA)
print("CSV columns :", df.columns.tolist())
print("Template vars:", sorted(required))

# Find the gap
missing = required - set(df.columns.tolist())
if missing:
    raise ValueError(
        f"Template references variables not in CSV: {missing}. "
        "Rename the CSV column or the template placeholder."
    )

Running this before the render loop surfaces every mismatch immediately. Fix by either renaming the column in the CSV (client_tiertier) or updating the template placeholder ({{ client_tier }}{{ tier }}). Renaming in the template is safer when you do not control the CSV schema.

Fix 2 — Provide Defaults for Optional Keys

When a key is genuinely optional (some rows may not have it), supply a fallback in build_context rather than patching the template.

# pip install pandas
import pandas as pd


def build_context(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()

    # Replace NaN with empty string for every value in one pass
    ctx = {k: ("" if pd.isna(v) else v) for k, v in ctx.items()}   # NaN → ""

    # Explicit defaults for keys the template always references
    ctx.setdefault("client_tier", "standard")      # template: {{ client_tier }}
    ctx.setdefault("discount_pct", 0)              # template: {{ discount_pct }}
    ctx.setdefault("notes", "")                    # template: {{ notes }}

    return ctx

The pd.isna sweep handles float('nan') that pandas injects for empty CSV cells — those are truthy in Python but raise UndefinedError when Jinja2 tries to render them as strings.

Fix 3 — Use | default() in the Template Itself

When you cannot change the Python code (e.g. the template is maintained by a different team), add a Jinja2 filter directly in the .docx placeholder:

{{ client_tier | default('standard') }}
{{ discount_pct | default(0) }}
{{ notes | default('') }}

The | default(value) filter returns value whenever the left-hand side is undefined or falsy. To return the default only when the variable is strictly undefined (not just empty), use | default('', boolean=False) — but the plain form covers the common case.

Important: retype each placeholder in Word after adding the filter. Word often splits | default( across separate XML runs, causing a silent render failure rather than the filter being applied.

Variant — Whitespace or Typo Inside {{ }}

Jinja2 is case-sensitive and whitespace inside the delimiters matters in one specific way: leading/trailing spaces are stripped, but a wrong character anywhere in the name still raises UndefinedError.

{{ FirstName }}   →  UndefinedError: 'FirstName' is undefined  (context key is 'first_name')
{{ first name }}  →  TemplateSyntaxError (space in identifier)
{{first_name}}    →  renders correctly  (no spaces required around the name)

Verify with a quick diagnostic:

# pip install docxtpl
from docxtpl import DocxTemplate
from pathlib import Path

tpl = DocxTemplate(str(Path("templates/letter_template.docx")))
for var in sorted(tpl.get_undeclared_template_variables()):
    print(repr(var))   # repr() exposes hidden whitespace or non-ASCII chars

repr() will show '\\xa0first_name' if a non-breaking space crept into the placeholder text — a common copy-paste artefact from Word.

Variant — Attribute Access on a Missing Dict Key

When the template iterates a list ({%tr for item in line_items %}) and accesses {{ item.price }}, Jinja2 first looks for a dict key "price", then falls back to an attribute. If neither exists you get:

jinja2.exceptions.UndefinedError: 'price' is undefined

Fix by normalising each item dict before passing it to the context:

# pip install pandas
import pandas as pd


def normalise_item(raw: dict) -> dict:
    """Ensure every key the template references exists in the item dict."""
    return {
        "description": str(raw.get("description", "—")),
        "qty":         str(raw.get("qty", raw.get("quantity", "1"))),  # tolerate both names
        "unit_price":  f"${float(raw.get('unit_price', raw.get('price', 0))):,.2f}",
    }

Using .get() with a fallback prevents the error even when upstream data is inconsistent.

Variant — Validate Before Rendering

When data comes from an external API or unreliable CSV, validate the full context before handing it to docxtpl. A ValueError with a clear message is better than a cryptic UndefinedError mid-batch.

# pip install docxtpl pandas
import pandas as pd
from docxtpl import DocxTemplate
from pathlib import Path

TEMPLATE = Path("templates/letter_template.docx")


def validate_context(ctx: dict, template_path: Path) -> None:
    """Raise ValueError listing every variable the template needs but context lacks."""
    tpl      = DocxTemplate(str(template_path))
    required = tpl.get_undeclared_template_variables()
    missing  = [k for k in required if k not in ctx]
    if missing:
        raise ValueError(
            f"Context is missing {len(missing)} required key(s): {missing}\n"
            f"Available keys: {sorted(ctx.keys())}"
        )


def render_safe(row: pd.Series, template_path: Path, out_path: Path) -> bool:
    ctx = row.to_dict()
    ctx = {k: ("" if pd.isna(v) else v) for k, v in ctx.items()}
    ctx.setdefault("client_tier", "standard")

    try:
        validate_context(ctx, template_path)   # raises ValueError if keys are missing
        tpl = DocxTemplate(str(template_path))
        tpl.render(ctx)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        tpl.save(str(out_path))
        return True
    except (ValueError, Exception) as exc:
        print(f"Skipped {out_path.name}: {exc}")
        return False

Variant — UndefinedError Inside a Conditional Block

A subtler case: {{ variable }} sits inside a {% if condition %} block and you assume it will never be evaluated when condition is false. Jinja2 does not short-circuit attribute lookups on undefined variables — it still resolves every name in the block regardless of the conditional outcome.

{% if show_discount %}
  Discount: {{ discount_code }} — {{ discount_pct }}%
{% endif %}

If discount_code is absent from the context, Jinja2 raises UndefinedError even when show_discount is False.

Fix: supply the default in the context dict regardless of whether the condition will be true:

# pip install pandas
import pandas as pd


def build_context(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()
    ctx = {k: ("" if pd.isna(v) else v) for k, v in ctx.items()}

    # Always provide keys referenced inside conditionals
    ctx.setdefault("show_discount", False)
    ctx.setdefault("discount_code", "")    # needed even when show_discount is False
    ctx.setdefault("discount_pct", 0)

    return ctx

Alternatively, guard with the | default() filter directly in the template:

{% if show_discount %}
  Discount: {{ discount_code | default('N/A') }} — {{ discount_pct | default(0) }}%
{% endif %}

Variant — Accessing a Nested Key That Does Not Exist

When a context value is itself a dict and the template accesses a sub-key that is absent, the error reads:

jinja2.exceptions.UndefinedError: 'address' has no attribute 'city'

This happens when address is in the context but was constructed without a city key:

ctx["address"] = {"street": "123 Main St"}   # no 'city'
# template: {{ address.city }}  → UndefinedError

Fix by normalising nested dicts with explicit defaults:

# pip install pandas
def normalise_address(raw: dict) -> dict:
    """Return an address dict with all template-required keys present."""
    return {
        "street":  raw.get("street", ""),
        "city":    raw.get("city", ""),
        "state":   raw.get("state", ""),
        "postcode": raw.get("postcode", raw.get("zip", "")),  # tolerate both field names
    }

Verification

After applying a fix, run the following assertions to confirm the error is gone and the output contains real values:

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

out = Path("output/doc_test.docx")
assert out.exists(), f"Output file not written: {out}"
assert out.stat().st_size > 1000, "Output file suspiciously small — likely corrupt"

doc       = Document(str(out))
full_text = " ".join(p.text for p in doc.paragraphs)

# No unrendered placeholders remain
leftover = re.findall(r"\{\{.*?\}\}", full_text)
assert not leftover, f"Unrendered placeholders found: {leftover}"

# At least one expected value is present
assert "Alice" in full_text, "Expected first name not found in output"

print("Verification passed.")

If leftover is non-empty, the placeholder was in a run that Jinja2 never processed — most likely because it was split across XML runs in the template. Retype it in Word.

Part of Dynamic Mail Merge with Python.