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.

Why formatting disappears at the placeholder In the template the placeholder is stored as three runs: the first plain, the second bold, the third plain. docxtpl joins the run text, renders it through Jinja, writes the result into the first run and deletes the others. The rendered output therefore carries only the first run's formatting, so the bold from the middle run is lost. Retyping the placeholder in one pass leaves a single run and preserves its formatting. Template paragraph in the file run 1 "{{ cli" plain run 2 "ent_" bold run 3 "name }}" plain docxtpl joins the text, renders Jinja result written into run 1; runs 2 and 3 removed "Dana Whitfield" — plain run 2's bold went with run 2 After retyping in one pass single run "{{ client_name }}" bold "Dana Whitfield" — bold formatting survives the render One run in, one run out — everything else is a consequence of that

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.

Which fix applies to which symptom Three symptoms map to three fixes. If the placeholder text itself lost its bold or colour, the template has the placeholder split across runs and the fix is to retype it as one run. If the substituted value needs its own styling or a line break, the fix is to pass a RichText object. If a whole paragraph or list item lost its style, the fix is to use the paragraph level Jinja tag form so the tag paragraph is removed cleanly. Match the fix to what lost its formatting The placeholder bold or colour gone split across runs Retype as one run or coalesce in code The value needs its own style or a line break Pass RichText use \a for breaks The paragraph list or style gone tag inside the item Use the {%p %} form {%tr %} for table rows

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.

Run count is the health metric for a template Four template paragraphs with their run counts. A paragraph with one run per placeholder is healthy. A paragraph whose placeholder spans three runs will lose the formatting of two of them. The scan reports run counts so the unhealthy paragraphs can be retyped, and the count returns to one run per placeholder afterwards. Runs per placeholder in the template {{ balance }} 1 run — formatting safe {{ client_name }} 3 runs — two will be dropped {{ issued_on }} 2 runs — one will be dropped Retype any placeholder above one run, then re-scan before shipping the template

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.

Part of Dynamic Mail Merge with Python.