The Template Library Landscape
Python has several options for Word document templating:
| Library | Approach | Complexity | Best For |
|---|---|---|---|
| python-docx | Programmatic creation | Medium | Building documents from scratch |
| docxtpl | Jinja2 templates in Word | Low | Most template use cases |
| python-docx-template | Same as docxtpl | Low | (It's the same library) |
| docx-mailmerge | Mail merge fields | Low | Simple merge operations |
| OOXML direct | Raw XML manipulation | High | Full 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
- Select the entire placeholder in Word
- Cut (Ctrl+X)
- Paste Special → Unformatted Text (Ctrl+Shift+V)
This forces Word to store it as a single run.
Solution 2: Clear Formatting
- Select the placeholder
- Press Ctrl+Space to clear character formatting
- Or use Home → Clear All Formatting
Solution 3: Type in a Code Block
- Insert a plain text content control
- Type your placeholder inside it
- 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")
RichText with Links
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:
- Start with docxtpl - It handles 80% of use cases simply
- Watch for run splitting - The #1 cause of template failures
- Use
{%pand{%tr- For paragraph and table row conditionals - Use RichText - For dynamic formatting within templates
- 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.



