Render one document per row from a CSV / Excel / JSON data file and a Jinja2 template.
docforge is a small, zero-config CLI for mail-merge–style batch document
generation. Point it at a data file and a template and it writes one rendered
document per row — letters, invoices, contracts, reports, READMEs, anything
text-shaped.
git clone https://github.com/document-data-automation/docforge.git
cd docforge
docforge --template examples/invoice.md --data examples/customers.csv --out ./out/
# ...or without installing anything: python -m docforge --template examples/invoice.md ...What's new in 0.2.0 — document-oriented Jinja filters (
currency,date,default_if_empty,slugify,wrap), global variables via--var, row filtering with--where/--limit, STDIN input (--data -+--format),{% include %}partials, and an audit--manifest. All 0.1.0 commands and the library API are unchanged. See the CHANGELOG.
For the broader pattern behind this tool, see Word document templating & batch processing.
Mail merge is a surprisingly common need (per-customer letters, per-store
reports, per-employee onboarding packets), and the usual answers are either
heavyweight (Word + VBA) or scattered across several Python libraries.
docforge is one command, one dependency (Jinja2), and a sensible default
filename scheme.
It pairs well with the patterns covered at document-data-automation.com — Python workflows for office documents and tabular data.
docforge isn't published to any package index — clone the repo and use it
directly:
git clone https://github.com/document-data-automation/docforge.git
cd docforge
# option A: run straight from the clone, no install (needs Jinja2 importable)
python -m docforge --template examples/invoice.md --data examples/customers.csv --out ./out/
# option B: install into your environment to get a `docforge` command
pip install . # core (CSV/JSON/JSONL); or: pip install ".[xlsx]" for Excel inputPython 3.9+. The only runtime dependency is Jinja2; openpyxl is an optional
extra, needed only to read .xlsx data files.
A template (invoice.md):
# Invoice {{ customer_id }}
**Customer:** {{ name }}
**Plan:** {{ plan }}
**Amount due:** {{ amount_due | currency }}
**Due date:** {{ due_date | date('%B %d, %Y') }}A data file (customers.csv):
customer_id,name,plan,amount_due,due_date
C-001,Ada Lovelace,Pro,1290.5,2026-06-15
C-002,Grace Hopper,Enterprise,4999,2026-06-20Run:
docforge \
--template invoice.md \
--data customers.csv \
--out ./invoices/ \
--name "{{ customer_id }}{{ _ext }}"You get ./invoices/C-001.md and ./invoices/C-002.md, with amounts rendered
as $1,290.50 and dates as June 15, 2026.
| Format | Extensions | Notes |
|---|---|---|
| CSV | .csv |
Auto-detects ,, ;, \t, |; BOM-safe |
| JSON | .json |
Top-level array of objects, or a single object |
| JSON Lines | .jsonl, .ndjson |
One JSON object per line |
| Excel | .xlsx, .xlsm |
First sheet, first row = header (needs [xlsx] extra) |
Templates use Jinja2, so you get conditionals, filters, and loops:
Hi {{ name | title }},
{% if plan == "Enterprise" %}
Thanks for being on our top tier.
{% else %}
Curious about Enterprise? Reply to this email.
{% endif %}Any column in your data file becomes a variable. Two extras are injected automatically:
_index— 1-based row number_ext— the template file's extension (e.g..md,.txt,.html)
By default, referencing a variable that isn't in the row is an error
(catches typos early). Pass --lenient to render missing variables as empty
strings instead.
docforge registers a handful of document-oriented filters on top of the
Jinja built-ins. They work in both the body and --name templates.
| Filter | Example | Result |
|---|---|---|
currency |
{{ 1290.5 | currency }} |
$1,290.50 |
{{ 1290.5 | currency('€', 0) }} |
€1,291 |
|
date |
{{ due_date | date('%B %d, %Y') }} |
June 15, 2026 |
default_if_empty |
{{ note | default_if_empty('—') }} |
— (if blank) |
slugify |
{{ name | slugify }} |
ada-lovelace |
wrap |
{{ bio | wrap(60) }} |
text wrapped to 60 cols |
currency(symbol='$', decimals=2)— parses numbers even when they carry symbols or thousands separators ("$1,234.50"works), then reformats.date(fmt='%Y-%m-%d')— acceptsdatetime/dateobjects and strings in many common shapes (ISO 8601,M/D/Y,D-M-Y,Month D, Y, …) and reformats viastrftime.default_if_empty(default='')— falls back when the value isNoneor blank/whitespace (but not for0).slugify— lowercases and turns runs of non-word characters into single dashes — handy for filenames.wrap(width=70)— hard-wraps text withtextwrap.
Inject values available to every row and to the filename template — a run date, a company name, a batch id:
docforge ... --var company="Acme Co" --var run_date=2026-07-18--var is repeatable. On a key collision, the row's own field wins, so
globals act as defaults.
The render environment is rooted at the template's directory, so a template can pull in shared headers/footers:
{% include "partials/footer.md" %}Paths resolve relative to the template file's folder. (Using docforge as a
library, pass template_dir= to render_template to enable this.)
--name is itself a Jinja template. The default is {{ _index }}{{ _ext }}
(1.md, 2.md, …). For stable, human-readable names, base it on an ID:
docforge --name "{{ customer_id }}-{{ due_date }}{{ _ext }}" ...Characters that would break on Windows or POSIX (<>:"/\|?* and control
chars) are replaced with _. Filename collisions across rows abort the run
so you don't silently overwrite output.
docforge ... --zip invoices.zipWrites individual files into --out and a single zip archive for easy
email/upload.
Render only the rows you want. --where is repeatable with AND semantics
and supports = and !=:
docforge ... --where "plan=Pro" --where "region!=EU" --limit 50--limit N caps the number of rows rendered (applied after filtering) — great
for a quick sample run.
Pipe data straight in. Because there's no extension to sniff, pass --format:
psql -c "COPY (…) TO STDOUT WITH CSV HEADER" | \
docforge -t invoice.md -d - --format csv -o ./out/--format csv|json|jsonl|xlsx also overrides the inferred format for a
mis-extensioned file (e.g. a .txt that's really JSON).
Write a record of exactly what was produced — one entry per rendered row with
its _index, output filename, and the first couple of data columns:
docforge ... --manifest run-manifest.csv # or .jsonCSV and JSON are both supported (chosen by the file extension).
--dry-run— print the filenames that would be written (to stdout); write nothing.--lenient— missing template variables become empty strings instead of errors.--version— print the version.
Progress and summary messages go to stderr, so stdout stays clean for piping.
from docforge import load_rows, render_template, render_filename
rows = load_rows("customers.csv") # or load_rows("-", fmt="csv") for STDIN
template = open("invoice.md").read()
for i, row in enumerate(rows, start=1):
ctx = {**row, "_index": i, "_ext": ".md"}
name = render_filename("{{ customer_id }}{{ _ext }}", ctx)
body = render_template(template, ctx, template_dir="./") # enables {% include %}
open(f"out/{name}", "w").write(body)The custom filters are exported too, as docforge.DOCUMENT_FILTERS, if you want
to register them on your own Jinja environment. load_rows takes an optional
fmt= and render_template an optional template_dir=; both keep their
original positional signatures.
pip install -e ".[dev]"
pytestMIT. See LICENSE.
Built and maintained by document-data-automation — Python workflows for turning repetitive office document and data tasks into reliable automations.