Generating Word documents programmatically is a common need in DevOps and data workflows: automated reports, invoice generation, compliance summaries, and
Generating Word documents programmatically is a common need in DevOps and data workflows: automated reports, invoice generation, compliance summaries, and templated client deliverables. Manually editing .docx files in Word is fine for one-off work, but breaks down when you need to produce hundreds of documents from structured data (JSON, database rows, API responses).
This script solves that by treating a .docx file as a template with placeholder tokens (e.g. {{customer_name}}), then substituting values from a JSON file. It preserves all formatting, styles, headers, and embedded images from the template — you only swap the text. This is the same pattern used by mail-merge tools, but scriptable, CI-friendly, and dependency-light.
The script loads a template .docx using python-docx, walks every paragraph and table cell, and performs a token replacement using a {{key}} syntax. Replacements are applied at the run level first to preserve inline formatting, then at the paragraph level as a fallback for tokens that span multiple runs (which happens when Word splits text during editing).
Key design decisions:
.docx before doing any work, and warn (but don't crash) on missing variables.#!/usr/bin/env python3
"""
Word DOCX Generator with python-docx
Reads a .docx template containing {{placeholder}} tokens, substitutes
values from a JSON variables file, and writes the rendered document.
Usage:
python3 docx_gen.py --template base.docx --vars vars.json --output report.docx
"""
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
from docx import Document
from docx.document import Document as DocumentType
from docx.table import Table
from docx.text.paragraph import Paragraph
# ---------------------------------------------------------------------------
# Configuration (overridable via environment variables)
# ---------------------------------------------------------------------------
LOG_LEVEL = os.environ.get("DOCX_LOG_LEVEL", "INFO").upper()
TOKEN_PATTERN = os.environ.get("DOCX_TOKEN_PATTERN", r"\{\{\s*([\w\.\-]+)\s*\}\}")
STRICT_MODE = os.environ.get("DOCX_STRICT", "false").lower() in ("1", "true", "yes")
# ---------------------------------------------------------------------------
# Logging setup — stderr so stdout stays clean for piping
# ---------------------------------------------------------------------------
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr,
)
log = logging.getLogger("docx_gen")
def load_variables(path: Path) -> dict[str, Any]:
"""Load a JSON file into a flat dict of template variables."""
if not path.is_file():
raise FileNotFoundError(f"Variables file not found: {path}")
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"Variables file must contain a JSON object, got {type(data).__name__}")
# Flatten nested dicts using dot notation: {"a": {"b": 1}} -> {"a.b": 1}
return _flatten(data)
def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
"""Recursively flatten nested dicts so templates can use '{{user.name}}'."""
flat: dict[str, Any] = {}
for key, value in data.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
flat.update(_flatten(value, full_key))
else:
flat[full_key] = value
return flat
def _stringify(value: Any) -> str:
"""Convert a JSON value to a string suitable for insertion into a docx run."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float, str)):
return str(value)
# Lists/dicts that survived flattening: render as compact JSON
return json.dumps(value, ensure_ascii=False)
def replace_in_paragraph(paragraph: Paragraph, variables: dict[str, Any], pattern: re.Pattern[str]) -> int:
"""
Replace tokens in a paragraph. Returns count of replacements made.
Strategy:
1. Try run-level replacement first (preserves inline formatting).
2. If a token spans multiple runs, fall back to paragraph-level rebuild.
"""
replacements = 0
# --- Pass 1: run-level replacement (formatting-preserving) ---
for run in paragraph.runs:
original = run.text
if not original:
continue
def _sub(match: re.Match[str]) -> str:
nonlocal replacements
key = match.group(1)
if key not in variables:
log.warning("Missing variable in template: %s", key)
if STRICT_MODE:
raise KeyError(f"Missing variable: {key}")
return match.group(0) # leave token untouched
replacements += 1
return _stringify(variables[key])
new_text = pattern.sub(_sub, original)
if new_text != original:
run.text = new_text
# --- Pass 2: paragraph-level fallback for tokens split across runs ---
# Only do this if the paragraph still contains an unresolved token.
full_text = paragraph.text
if pattern.search(full_text):
def _sub_para(match: re.Match[str]) -> str:
nonlocal replacements
key = match.group(1)
if key not in variables:
log.warning("Missing variable (multi-run) in template: %s", key)
if STRICT_MODE:
raise KeyError(f"Missing variable: {key}")
return match.group(0)
replacements += 1
return _stringify(variables[key])
rendered = pattern.sub(_sub_para, full_text)
# Collapse everything into the first run to keep formatting coherent.
if paragraph.runs:
paragraph.runs[0].text = rendered
for run in paragraph.runs[1:]:
run.text = ""
return replacements
def _iter_paragraphs(document: DocumentType):
"""Yield every paragraph in the document, including inside tables."""
for para in document.paragraphs:
yield para
for table in document.tables:
yield from _iter_table_paragraphs(table)
def _iter_table_paragraphs(table: Table):
"""Recursively yield paragraphs from a table (handles nested tables)."""
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
yield para
for nested in cell.tables:
yield from _iter_table_paragraphs(nested)
def render_template(template_path: Path, variables: dict[str, Any], output_path: Path) -> int:
"""Load template, apply substitutions, save to output. Returns replacement count."""
if not template_path.is_file():
raise FileNotFoundError(f"Template not found: {template_path}")
if template_path.suffix.lower() != ".docx":
raise ValueError(f"Template must be a .docx file, got: {template_path.suffix}")
try:
document = Document(str(template_path))
except Exception as exc: # python-docx raises various errors on corrupt files
raise RuntimeError(f"Failed to open template {template_path}: {exc}") from exc
pattern = re.compile(TOKEN_PATTERN)
total = 0
for paragraph in _iter_paragraphs(document):
total += replace_in_paragraph(paragraph, variables, pattern)
# Also substitute in headers and footers of every section.
for section in document.sections:
for hf in (section.header, section.footer):
for paragraph in hf.paragraphs:
total += replace_in_paragraph(paragraph, variables, pattern)
for table in hf.tables:
for paragraph in _iter_table_paragraphs(table):
total += replace_in_paragraph(paragraph, variables, pattern)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
document.save(str(output_path))
except OSError as exc:
raise RuntimeError(f"Failed to write {output_path}: {exc}") from exc
return total
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render a .docx template with JSON variables.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--template", required=True, type=Path, help="Path to .docx template")
parser.add_argument("--vars", required=True, type=Path, help="Path to JSON variables file")
parser.add_argument("--output", required=True, type=Path, help="Path to write rendered .docx")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
log.info("Loading variables from %s", args.vars)
variables = load_variables(args.vars)
log.debug("Loaded %d variables: %s", len(variables), sorted(variables.keys()))
log.info("Rendering template %s -> %s", args.template, args.output)
count = render_template(args.template, variables, args.output)
log.info("Done. %d substitution(s) applied.", count)
return 0
except FileNotFoundError as exc:
log.error("File error: %s", exc)
return 2
except (ValueError, KeyError) as exc:
log.error("Data error: %s", exc)
return 3
except RuntimeError as exc:
log.error("Processing error: %s", exc)
return 4
except KeyboardInterrupt:
log.error("Interrupted by user.")
return 130
if __name__ == "__main__":
sys.exit(main())
| Variable | Default | Purpose |
|---|---|---|
DOCX_LOG_LEVEL |
INFO |
Logging verbosity: DEBUG, INFO, WARNING, ERROR. |
DOCX_TOKEN_PATTERN |
\{\{\s*([\w\.\-]+)\s*\}\} |
Regex for template tokens. Capture group 1 is the variable name. Change this if your templates use a different syntax (e.g. ${name}). |
DOCX_STRICT |
false |
When true, missing variables raise KeyError and abort. When false, missing tokens are left in place and a warning is logged. |
CLI arguments (all required):
--template — path to the source .docx--vars — path to a JSON file with a top-level object--output — path to write the rendered .docx (parent dirs are created)1. Basic render
$ cat vars.json
{"customer_name": "Acme Corp", "invoice_total": 1249.50, "due_date": "2024-06-15"}
$ python3 docx_gen.py --template invoice.docx --vars vars.json --output out/invoice_acme.docx
2024-05-01 10:22:31 [INFO] Loading variables from vars.json
2024-05-01 10:22:31 [INFO] Rendering template invoice.docx -> out/invoice_acme.docx
2024-05-01 10:22:31 [INFO] Done. 3 substitution(s) applied.
2. Nested variables with dot notation
$ cat vars.json
{"user": {"name": "Ada Lovelace", "email": "ada@example.com"}, "plan": "Enterprise"}
$ DOCX_LOG_LEVEL=DEBUG python3 docx_gen.py --template welcome.docx --vars vars.json --output welcome_ada.docx
2024-05-01 10:23:02 [DEBUG] Loaded 3 variables: ['plan', 'user.email', 'user.name']
2024-05-01 10:23:02 [INFO] Done. 3 substitution(s) applied.
Template uses Hello {{user.name}}, welcome to the {{plan}} plan. — the flattening step turns user.name into a lookup key.
3. Strict mode for CI pipelines
$ DOCX_STRICT=true python3 docx_gen.py --template contract.docx --vars partial.json --output c.docx
2024-05-01 10:24:11 [ERROR] Data error: Missing variable: signatory_name
$ echo $?
3
Non-zero exit codes let your CI job fail loudly when a template references a variable that wasn't provided.
| Situation | Behavior | Exit code |
|---|---|---|
| Template or vars file missing | FileNotFoundError caught, logged, clean exit |
2 |
| Invalid JSON in vars file | ValueError with parse location |
3 |
| Vars JSON is not an object | ValueError |
3 |
| Missing variable (strict mode) | KeyError aborts mid-render |
3 |
| Missing variable (default) | Warning logged, token left untouched | 0 |
Corrupt/unreadable .docx |
RuntimeError from python-docx wrapper |
4 |
| Output path not writable | RuntimeError from OSError |
4 |
| Ctrl+C | Clean shutdown message | 130 |
The script never silently swallows errors — every failure path either raises a specific exception or exits with a non-zero code. Warnings about missing variables are logged to stderr so they don't pollute stdout.
.docx per file, using the JSON filename as the output basename. Useful for per-customer invoice runs from a queue.{{image:path/to/logo.png}} token handler that uses paragraph.add_run().add_picture() to embed images dynamically — great for branded reports.{% if x %}...{% endif %} blocks by parsing paragraphs for control tokens and deleting paragraphs that don't match. Turns the template into a mini template engine.libreoffice --headless --convert-to pdf or docx2pdf in a post-step to produce a PDF alongside the .docx — common requirement for client deliverables.load_variables() for a function that reads from Postgres/SQLite and yields one dict per row, feeding render_template() in a loop.