Fix docxtpl Formatting Lost in Output
The template looks right in Word and the rendered document does not:
Template: Dear {{ client_name }}, your balance is {{ balance }}.
^bold^ ^bold red^
Rendered: Dear Dana Whitfield, your balance is £1,204.50.
(all plain — the bold and the colour are gone)
Or a value that should contain a line break arrives as one run-on line:
context = {"address": "17 Fenwick Road\nLeeds\nLS8 2QT"}
# renders as: 17 Fenwick Road Leeds LS8 2QT
Neither is a bug in docxtpl. Both follow from how Word stores formatted text and from the fact that a template variable is substituted as plain text unless told otherwise.
Root Cause
Word stores a paragraph as a sequence of runs, each carrying its own formatting. Typing {{ client_name }} into a styled document rarely produces one clean run: a spell-check pass, an autocorrect, or simply editing the placeholder after typing it splits the text across several runs, so the file contains {{ cli, ent_, name }} as three separately formatted fragments.
docxtpl renders the paragraph's combined text through Jinja, then writes the result back into the first run of the group and removes the rest. Formatting that lived on the removed runs disappears, and the surviving run's formatting wins for everything.
The second symptom has a simpler cause: a substituted string is inserted as literal characters. Word does not treat \n as a break — a paragraph break is an XML element — so the newline is normalised away.
Minimal Diagnostic
Inspect the template's runs. If a placeholder spans more than one, the template is the problem, not the rendering code.
# pip install "python-docx>=1.1,<2"
import re
from pathlib import Path
from docx import Document
TEMPLATE = Path("data/letter_template.docx")
PLACEHOLDER = re.compile(r"\{\{.*?\}\}|\{%.*?%\}")
def split_placeholders(docx_path: Path) -> list[dict]:
"""Find placeholders whose text is spread across several runs."""
if not docx_path.exists():
raise FileNotFoundError(f"No such template: {docx_path}")
try:
document = Document(str(docx_path))
except Exception as exc:
raise RuntimeError(f"Could not open {docx_path}: {exc}") from exc
problems = []
paragraphs = list(document.paragraphs)
for table in document.tables:
for row in table.rows:
for cell in row.cells:
paragraphs.extend(cell.paragraphs)
for paragraph in paragraphs:
joined = paragraph.text
if not PLACEHOLDER.search(joined):
continue
run_texts = [run.text for run in paragraph.runs]
intact = [text for text in run_texts if PLACEHOLDER.search(text)]
if len(intact) != len(PLACEHOLDER.findall(joined)):
problems.append({
"paragraph": joined[:70],
"runs": run_texts,
"run_count": len(run_texts),
})
return problems
if __name__ == "__main__":
for entry in split_placeholders(TEMPLATE):
print(entry["paragraph"])
print(" runs:", entry["runs"])
Dear {{ client_name }}, your balance is {{ balance }}.
runs: ['Dear ', '{{ cli', 'ent_', 'name }}', ', your balance is ', '{{ balance }}', '.']
client_name is split across three runs; balance is intact. Only the first will lose formatting.
The Fix: One Placeholder, One Run
The reliable repair is manual and takes seconds: in Word, select the whole placeholder including both braces, delete it, and retype it in one pass without moving the cursor mid-token. Then apply the formatting you want to that single run.
For templates you cannot edit by hand — generated templates, or a large legacy set — merge the runs programmatically:
# pip install "python-docx>=1.1,<2"
import re
from pathlib import Path
from docx import Document
PLACEHOLDER_START = re.compile(r"\{\{|\{%")
def coalesce_runs(paragraph) -> None:
"""Merge every run in a paragraph into the first, keeping its formatting."""
if len(paragraph.runs) < 2:
return
keeper = paragraph.runs[0]
keeper.text = "".join(run.text for run in paragraph.runs)
for run in paragraph.runs[1:]:
run._r.getparent().remove(run._r)
def repair_template(src: Path, dest: Path) -> Path:
"""Coalesce runs in any paragraph that contains a template placeholder."""
document = Document(str(src))
targets = list(document.paragraphs)
for table in document.tables:
for row in table.rows:
for cell in row.cells:
targets.extend(cell.paragraphs)
repaired = 0
for paragraph in targets:
if PLACEHOLDER_START.search(paragraph.text):
coalesce_runs(paragraph)
repaired += 1
dest.parent.mkdir(parents=True, exist_ok=True)
document.save(str(dest))
print(f"coalesced {repaired} paragraph(s)")
return dest
if __name__ == "__main__":
repair_template(Path("data/letter_template.docx"), Path("out/letter_template_fixed.docx"))
Note what this trades away: the whole paragraph now carries the first run's formatting, so a paragraph with genuinely mixed styling loses the mix. Apply it only to paragraphs that contain placeholders, as above, and prefer hand-repair for templates where mid-paragraph styling matters.
Variant: Insert Styled Values with RichText
When the value needs formatting — a red overdue balance, a bold client name, an embedded line break — pass a RichText object instead of a string. docxtpl inserts it as real runs.
# pip install docxtpl
from pathlib import Path
from docxtpl import DocxTemplate, RichText
TEMPLATE = Path("data/letter_template.docx")
OUT = Path("out/letter.docx")
def render_letter(template: Path, dest: Path, client: str, balance: float, overdue: bool) -> Path:
"""Render a letter with a styled balance and a multi-line address."""
document = DocxTemplate(str(template))
amount = RichText(
f"£{balance:,.2f}",
bold=True,
color="B91C1C" if overdue else "166534", # six hex digits, no hash
)
address = RichText()
for index, line in enumerate(["17 Fenwick Road", "Leeds", "LS8 2QT"]):
if index:
address.add("\a") # \a inserts a real paragraph break
address.add(line)
document.render({"client_name": client, "balance": amount, "address": address})
dest.parent.mkdir(parents=True, exist_ok=True)
document.save(str(dest))
return dest
if __name__ == "__main__":
print(render_letter(TEMPLATE, OUT, "Dana Whitfield", 1204.50, overdue=True))
Three details worth memorising. Colours are six hex digits with no leading hash. \a inside a RichText becomes a paragraph break — a plain \n does not. And RichText bypasses the split-run problem entirely for the value, because the value is inserted as its own runs regardless of what the placeholder looked like.
Variant: Paragraph Style Lost on a Whole Block
When a rendered block loses its paragraph style — a bulleted list rendering as body text — the cause is usually a Jinja tag placed in the wrong paragraph. A {% for %} written inside the list item makes the tag part of the list, and docxtpl removes that paragraph along with the tag.
WRONG RIGHT
• {% for line in lines %} {%p for line in lines %}
• {{ line }} • {{ line }}
• {% endfor %} {%p endfor %}
The {%p ... %} form tells docxtpl the tag owns the whole paragraph, so it removes the paragraph cleanly and leaves the list item intact. There are matching {%tr ... %} and {%tc ... %} forms for table rows and cells, which are what keep a repeated table row from collapsing the table — the pattern used in dynamic mail merge with Python.
Verification
Assert on the rendered document: the value is present, and it carries the formatting you asked for.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
def assert_rendered_formatting(docx_path: Path, needle: str,
expect_bold: bool = True, expect_colour: str | None = None) -> None:
"""Find the run containing a value and check its formatting survived."""
document = Document(str(docx_path))
paragraphs = list(document.paragraphs)
for table in document.tables:
for row in table.rows:
for cell in row.cells:
paragraphs.extend(cell.paragraphs)
matches = [run for p in paragraphs for run in p.runs if needle in run.text]
assert matches, f"{needle!r} not found in any run — did the render substitute it?"
run = matches[0]
if expect_bold:
assert run.bold, f"{needle!r} is not bold in the output"
if expect_colour:
actual = run.font.color.rgb
assert actual is not None and str(actual).upper() == expect_colour.upper(), (
f"{needle!r} colour is {actual}, expected {expect_colour}"
)
leftovers = [p.text for p in paragraphs if "{{" in p.text or "{%" in p.text]
assert not leftovers, f"unrendered placeholder(s) survived: {leftovers[:3]}"
print(f"{needle!r} verified with the expected formatting")
if __name__ == "__main__":
assert_rendered_formatting(Path("out/letter.docx"), "£1,204.50", expect_bold=True,
expect_colour="B91C1C")
The leftover-placeholder check is worth keeping permanently. A misspelled context key renders as an empty string under Jinja's default, but a malformed tag survives into the output verbatim — and a letter that reaches a client showing {{ balance }} is the failure this whole page exists to prevent. Pair it with the undefined-variable handling in fix docxtpl jinja2 undefined error.
FAQ
Why does the same template work for one placeholder and not another? Because only some placeholders are split across runs. The diagnostic prints the run breakdown per paragraph, which shows exactly which ones are intact.
Can I stop Word splitting runs in the first place? Not entirely — spell-check and autocorrect create runs as you type. Typing the placeholder in one uninterrupted pass, then formatting it, gives a single run in practice. Re-run the diagnostic after any template edit.
Does RichText support fonts and sizes?
Yes: RichText("text", bold=True, italic=True, size=22, color="B91C1C", font="Calibri"). Size is in half-points, so 22 is 11pt.
How do I insert a real line break rather than a paragraph break?
Use RichText with \n mapped by docxtpl to a line break element, or build the value as several paragraphs with \a. A line break keeps the paragraph's spacing; a paragraph break applies the paragraph style again.
Is there a way to keep mixed formatting inside one paragraph? Yes — split the sentence into several placeholders, each in its own correctly formatted run, and supply each value separately. Trying to preserve mixed formatting around a single placeholder is what the run-coalescing repair gives up.
Related
- Dynamic Mail Merge with Python — the templating workflow this belongs to
- Fix docxtpl Jinja2 Undefined Error — missing context keys and strict mode
- Set Fonts and Styles with python-docx — run-level formatting explained
- Building and Editing Word Tables with Python — repeated table rows in templates
- Converting DOCX to PDF with Python — confirming the rendered look in the final artefact
Part of Dynamic Mail Merge with Python.