DocMods

Python DOCX Templates: Beyond Jinja2 Placeholders

docxtpl gets you 80% there. The last 20%—conditional tables, nested loops, images, and track changes—is where it falls apart. Here's the complete picture.

Python DOCX Templates: Beyond Jinja2 Placeholders

What You'll Learn

docxtpl vs python-docx vs direct OOXML
Conditional content and nested loops
Image insertion that preserves layout
Maintaining styles during template rendering
Track changes in generated documents

The Template Library Landscape

Python has several options for Word document templating:

LibraryApproachComplexityBest For
python-docxProgrammatic creationMediumBuilding documents from scratch
docxtplJinja2 templates in WordLowMost template use cases
python-docx-templateSame as docxtplLow(It's the same library)
docx-mailmergeMail merge fieldsLowSimple merge operations
OOXML directRaw XML manipulationHighFull control, edge cases

For 80% of use cases, docxtpl is the right choice. This guide covers both the easy path and what to do when docxtpl isn't enough.

docxtpl: The Standard Approach

Basic Setup

pip install docxtpl

Creating a Template

In Microsoft Word, create a document with Jinja2 placeholders:

INVOICE

Date: {{invoice_date}}
Invoice #: {{invoice_number}}

Bill To:
{{client_name}}
{{client_address}}

{% for item in items %}
{{item.description}} - {{item.quantity}} x ${{item.price}} = ${{item.total}}
{% endfor %}

Subtotal: ${{subtotal}}
Tax ({{tax_rate}}%): ${{tax_amount}}
TOTAL: ${{total}}

Save as invoice_template.docx.

Rendering the Template

from docxtpl import DocxTemplate

# Load template
tpl = DocxTemplate("invoice_template.docx")

# Define context
context = {
    'invoice_date': 'January 15, 2025',
    'invoice_number': 'INV-2025-001',
    'client_name': 'Acme Corporation',
    'client_address': '123 Business Ave, Suite 100',
    'items': [
        {'description': 'Consulting Services', 'quantity': 40, 'price': 150, 'total': 6000},
        {'description': 'Software License', 'quantity': 1, 'price': 2500, 'total': 2500},
        {'description': 'Training Session', 'quantity': 2, 'price': 500, 'total': 1000},
    ],
    'subtotal': 9500,
    'tax_rate': 8,
    'tax_amount': 760,
    'total': 10260
}

# Render and save
tpl.render(context)
tpl.save("invoice_2025_001.docx")

The Run Splitting Problem

This is the #1 cause of template failures. Here's why it happens:

The Problem

You type {{customer_name}} in Word. But in the OOXML, Word might store it as:

<w:p>
  <w:r>
    <w:rPr><w:b/></w:rPr>
    <w:t>{{customer</w:t>
  </w:r>
  <w:r>
    <w:rPr><w:b/></w:rPr>
    <w:t>_name}}</w:t>
  </w:r>
</w:p>

Word split your text into two "runs" - maybe because you typed it in two sessions, or spellcheck touched it, or formatting was applied inconsistently.

Jinja2 sees {{customer and _name}} as separate text. It doesn't recognize either as a valid variable.

Solution 1: Paste as Unformatted Text

  1. Select the entire placeholder in Word
  2. Cut (Ctrl+X)
  3. Paste Special → Unformatted Text (Ctrl+Shift+V)

This forces Word to store it as a single run.

Solution 2: Clear Formatting

  1. Select the placeholder
  2. Press Ctrl+Space to clear character formatting
  3. Or use Home → Clear All Formatting

Solution 3: Type in a Code Block

  1. Insert a plain text content control
  2. Type your placeholder inside it
  3. Remove the content control wrapper

Solution 4: Merge Runs in Code

from docxtpl import DocxTemplate

tpl = DocxTemplate("template.docx")

# Merge runs in all paragraphs before rendering
for para in tpl.docx.paragraphs:
    tpl.merge_runs(para)

tpl.render(context)
tpl.save("output.docx")

This merges adjacent runs with the same formatting, reassembling split placeholders.

Conditional Content

Simple Conditionals

Template:

{% if premium_member %}
Thank you for being a Premium member! Your discount has been applied.
{% else %}
Upgrade to Premium for exclusive discounts!
{% endif %}

Code:

context = {
    'premium_member': True  # or False
}

Conditional Entire Paragraphs

Template:

{%p if show_warranty %}
WARRANTY INFORMATION
This product is covered by our 2-year limited warranty.
{%p endif %}

The {%p tag makes the entire paragraph conditional. If show_warranty is False, the whole paragraph is removed (not just the text).

Conditional Table Rows

Template (in a table):

| Product | Price |
|---------|-------|
| Standard Package | $99 |
{%tr if premium_available %}| Premium Package | $199 |{%tr endif %}
| Enterprise Package | $499 |

The {%tr tag controls table rows.

Loops and Tables

Simple Table Loop

Template:

| Item | Quantity | Price | Total |
|------|----------|-------|-------|
{% for item in items %}
| {{item.name}} | {{item.qty}} | ${{item.price}} | ${{item.total}} |
{% endfor %}

Wait—this won't work correctly. For tables, use row-level tags:

Correct Table Row Loop

Template (put tags in the first cell):

| Item | Quantity | Price | Total |
|------|----------|-------|-------|
{%tr for item in items %}| {{item.name}} | {{item.qty}} | ${{item.price}} | ${{item.total}} |{%tr endfor %}

Code:

context = {
    'items': [
        {'name': 'Widget', 'qty': 10, 'price': '9.99', 'total': '99.90'},
        {'name': 'Gadget', 'qty': 5, 'price': '19.99', 'total': '99.95'},
        {'name': 'Gizmo', 'qty': 2, 'price': '49.99', 'total': '99.98'},
    ]
}

Nested Loops

Template:

{% for department in departments %}
DEPARTMENT: {{department.name}}

{%tr for employee in department.employees %}
| {{employee.name}} | {{employee.role}} | {{employee.email}} |
{%tr endfor %}

{% endfor %}

Code:

context = {
    'departments': [
        {
            'name': 'Engineering',
            'employees': [
                {'name': 'Alice', 'role': 'Lead', 'email': 'alice@co.com'},
                {'name': 'Bob', 'role': 'Senior', 'email': 'bob@co.com'},
            ]
        },
        {
            'name': 'Marketing',
            'employees': [
                {'name': 'Carol', 'role': 'Director', 'email': 'carol@co.com'},
            ]
        }
    ]
}

Images

Inserting Images

from docxtpl import DocxTemplate, InlineImage
from docx.shared import Inches, Mm

tpl = DocxTemplate("template.docx")

context = {
    'company_name': 'Acme Corp',
    # Image with width constraint (height auto-scales)
    'logo': InlineImage(tpl, 'logo.png', width=Inches(2)),
    # Image with both dimensions
    'signature': InlineImage(tpl, 'signature.png', width=Mm(50), height=Mm(20)),
}

tpl.render(context)
tpl.save("output.docx")

Template (just put the variable where you want the image):

{{logo}}

Dear Customer,
...

Best regards,
{{signature}}
John Smith

Dynamic Images from Data

from docxtpl import DocxTemplate, InlineImage
from docx.shared import Inches
import requests
from io import BytesIO

tpl = DocxTemplate("product_catalog.docx")

def fetch_image(url):
    """Fetch image from URL and return as InlineImage."""
    response = requests.get(url)
    image_stream = BytesIO(response.content)
    return InlineImage(tpl, image_stream, width=Inches(1.5))

products = [
    {
        'name': 'Widget Pro',
        'price': 29.99,
        'image_url': 'https://example.com/widget.png'
    },
    # ...
]

context = {
    'products': [
        {
            'name': p['name'],
            'price': p['price'],
            'image': fetch_image(p['image_url'])
        }
        for p in products
    ]
}

tpl.render(context)
tpl.save("catalog.docx")

RichText for Formatted Content

Sometimes you need to insert formatted text, not just plain strings:

from docxtpl import DocxTemplate, RichText

tpl = DocxTemplate("template.docx")

# Create rich text with formatting
rt = RichText()
rt.add('This is ', style='Normal')
rt.add('bold', bold=True)
rt.add(' and this is ')
rt.add('red', color='FF0000')
rt.add(' and this is ')
rt.add('both', bold=True, color='0000FF')

context = {
    'formatted_text': rt
}

tpl.render(context)
tpl.save("output.docx")
rt = RichText()
rt.add('Visit our ')
rt.add('website', url_id=tpl.build_url_id('https://example.com'))
rt.add(' for more information.')

context = {'link_text': rt}

Subdocuments (Including Other Documents)

from docxtpl import DocxTemplate

tpl = DocxTemplate("main_template.docx")

# Include another docx file
sub = tpl.new_subdoc('appendix.docx')

context = {
    'title': 'Main Report',
    'appendix': sub  # {{appendix}} in template
}

tpl.render(context)
tpl.save("complete_report.docx")

In your main template:

MAIN REPORT
{{title}}

... main content ...

APPENDIX
{{appendix}}

Headers and Footers

docxtpl can access headers and footers, but templating them requires extra work:

from docxtpl import DocxTemplate

tpl = DocxTemplate("template.docx")

# Access header
for section in tpl.docx.sections:
    header = section.header
    for para in header.paragraphs:
        # Manual replacement in header
        if '{{company}}' in para.text:
            para.text = para.text.replace('{{company}}', 'Acme Corp')

# Render body
context = {'body_var': 'value'}
tpl.render(context)
tpl.save("output.docx")

Headers/footers don't go through the Jinja2 engine automatically. You need to handle them separately or use a different approach.

When docxtpl Isn't Enough

Problem: Track Changes

docxtpl doesn't support generating documents with track changes. If you need:

  • New text marked as insertions
  • Changes visible for review
  • Author attribution

You need a different approach:

from docxagent import DocxClient

client = DocxClient()

# Upload template
doc_id = client.upload("contract_template.docx")

# Fill with track changes
client.edit(
    doc_id,
    """Fill this contract template with:
    - Client: Acme Corporation
    - Effective Date: February 1, 2025
    - Payment Terms: Net 30
    - Contract Value: $50,000

    Track all changes so the client can review what was filled in."""
)

client.download(doc_id, "contract_draft.docx")

Problem: Complex Conditional Formatting

docxtpl handles conditional text, but not conditional formatting (making text bold based on a condition):

# This doesn't work in docxtpl:
# {% if important %}**{{message}}**{% else %}{{message}}{% endif %}

You need RichText:

from docxtpl import DocxTemplate, RichText

tpl = DocxTemplate("template.docx")

def format_message(text, important):
    rt = RichText()
    if important:
        rt.add(text, bold=True, color='FF0000')
    else:
        rt.add(text)
    return rt

context = {
    'message': format_message('Check this out!', important=True)
}

Problem: Dynamic Table Structure

docxtpl can loop over rows, but can't dynamically add columns:

# Can't do this in template:
# {% for col in columns %}<th>{{col}}</th>{% endfor %}

For dynamic columns, build the table programmatically:

from docx import Document
from docx.shared import Inches

doc = Document()

# Dynamic columns
columns = ['Name', 'Q1', 'Q2', 'Q3', 'Q4', 'Total']
data = [
    ['Alice', 100, 120, 110, 130, 460],
    ['Bob', 90, 95, 100, 105, 390],
]

# Create table with dynamic column count
table = doc.add_table(rows=1, cols=len(columns))
table.style = 'Table Grid'

# Add headers
header_cells = table.rows[0].cells
for i, col in enumerate(columns):
    header_cells[i].text = col

# Add data rows
for row_data in data:
    row_cells = table.add_row().cells
    for i, value in enumerate(row_data):
        row_cells[i].text = str(value)

doc.save("dynamic_table.docx")

Problem: Replacing Existing Images

docxtpl's InlineImage adds new images at placeholder locations. It doesn't replace existing images in the document:

# This replaces the PLACEHOLDER TEXT, not an existing image
context = {'logo': InlineImage(tpl, 'new_logo.png')}

To replace an actual image, you need OOXML manipulation:

from zipfile import ZipFile
import shutil
import os

def replace_image(docx_path, old_image_name, new_image_path, output_path):
    """Replace an image in a DOCX file."""
    import tempfile

    temp_dir = tempfile.mkdtemp()

    # Extract DOCX
    with ZipFile(docx_path, 'r') as zf:
        zf.extractall(temp_dir)

    # Find and replace image
    media_dir = os.path.join(temp_dir, 'word', 'media')
    for filename in os.listdir(media_dir):
        if old_image_name in filename:
            old_path = os.path.join(media_dir, filename)
            # Copy new image with same name
            shutil.copy(new_image_path, old_path)
            break

    # Repack DOCX
    with ZipFile(output_path, 'w') as zf:
        for root, dirs, files in os.walk(temp_dir):
            for file in files:
                file_path = os.path.join(root, file)
                arc_name = os.path.relpath(file_path, temp_dir)
                zf.write(file_path, arc_name)

    shutil.rmtree(temp_dir)

# Usage
replace_image('template.docx', 'image1', 'new_logo.png', 'output.docx')

Production Patterns

Error Handling

from docxtpl import DocxTemplate
from docxtpl.exceptions import UndefinedError

def safe_render(template_path, context, output_path):
    """Render template with error handling."""
    try:
        tpl = DocxTemplate(template_path)
        tpl.render(context)
        tpl.save(output_path)
        return True, None
    except UndefinedError as e:
        return False, f"Missing variable: {e}"
    except Exception as e:
        return False, f"Render error: {e}"

success, error = safe_render('template.docx', {'name': 'John'}, 'output.docx')
if not success:
    print(f"Failed: {error}")

Validation Before Rendering

import re

def validate_template(template_path):
    """Check template for common issues."""
    from docx import Document

    doc = Document(template_path)
    issues = []

    all_text = ""
    for para in doc.paragraphs:
        all_text += para.text + "\n"

    # Find all Jinja2 variables
    variables = set(re.findall(r'\{\{(\w+)\}\}', all_text))

    # Check for unclosed tags
    opens = len(re.findall(r'\{\{', all_text))
    closes = len(re.findall(r'\}\}', all_text))
    if opens != closes:
        issues.append(f"Mismatched braces: {opens} opens, {closes} closes")

    # Check for potentially split variables (contains space)
    for var in variables:
        if ' ' in var:
            issues.append(f"Variable may be split by runs: {var}")

    return variables, issues

vars, issues = validate_template('template.docx')
print(f"Variables found: {vars}")
print(f"Issues: {issues}")

Batch Processing

from docxtpl import DocxTemplate
from concurrent.futures import ThreadPoolExecutor
import os

def render_single(args):
    """Render a single document."""
    template_path, context, output_path = args
    tpl = DocxTemplate(template_path)
    tpl.render(context)
    tpl.save(output_path)
    return output_path

def batch_render(template_path, records, output_dir, filename_field='id'):
    """Render multiple documents in parallel."""
    os.makedirs(output_dir, exist_ok=True)

    tasks = [
        (
            template_path,
            record,
            os.path.join(output_dir, f"{record[filename_field]}.docx")
        )
        for record in records
    ]

    with ThreadPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(render_single, tasks))

    return results

# Usage
records = [
    {'id': 'INV001', 'client': 'Acme', 'amount': 1000},
    {'id': 'INV002', 'client': 'Beta', 'amount': 2000},
    # ... hundreds more
]

files = batch_render('invoice_template.docx', records, './invoices/')
print(f"Generated {len(files)} invoices")

The Bottom Line

For most Python DOCX templating needs:

  1. Start with docxtpl - It handles 80% of use cases simply
  2. Watch for run splitting - The #1 cause of template failures
  3. Use {%p and {%tr - For paragraph and table row conditionals
  4. Use RichText - For dynamic formatting within templates
  5. Know the limits - Track changes, dynamic columns, image replacement need other tools

When docxtpl isn't enough, you have options:

  • Direct python-docx for programmatic document building
  • OOXML manipulation for full control
  • APIs like DocMods for AI-powered generation with track changes

The right tool depends on your specific requirements. Most template jobs don't need the complexity—but when they do, know where to look.

Frequently Asked Questions

Ready to Transform Your Document Workflow?

Let AI help you review, edit, and transform Word documents in seconds.

No credit card required • Free trial available