Turn a spreadsheet and a Word template into hundreds of documents β and catch the mistakes before you generate them, not after.
π¬π§ English Β· π·πΊ Π ΡΡΡΠΊΠΈΠΉ
Word Mail Merge cannot save one file per record, cannot name files from your data, and cannot
export one PDF per record. So people pay freelancers $60β$600 to write a throwaway VBA macro
instead. mergemill is that macro, done properly β and it refuses to run until your template and
your data actually agree.
pip install mergemill
mergemill init # writes a working example
mergemill check letter.docx clients.xlsx
mergemill run letter.docx clients.xlsx -o out --name "{{ client_name }}"Generating documents is easy. Generating 500 correct documents is the hard part, and every failure is silent β a placeholder that never matched a column leaves a blank in a contract; two rows that produce the same filename overwrite each other and you quietly end up with 480 files instead of 500.
mergemill check writes nothing. It just tells you what would go wrong.
Real output above: two typo'd placeholders with did-you-mean suggestions, a filename collision
naming the exact source rows, a client called CON that Windows would reject, unused columns, and
blank cells in a column the template prints. Nothing was generated. Exit code 2.
mergemill ui opens a local page in your browser. Same engine, no command line, and the files
never leave your machine β the "server" is your own computer.
When something is wrong, it says so before you can click Generate:
These are real people, quoted verbatim, describing the exact gap this fills.
"How can I get this document to save as 20 individual record files? I really want the entire 30 page document to save as a single .pdf record, by school name." β learn.microsoft.com/answers #5053938
"I found a macro that lets me split the merged document after it's made, but it names the documents 1, 2, 3 etc. I have hundreds of documents I need to make, and renaming them manually is not workable." β superuser.com/q/842306
"I just can not export each pages to a pdf file separated. Is there any way to print/export files using direct mail on microsoft word? Preferential using some Merge Field as file name?" β superuser.com/q/1220625
"I'd like to use my Excel data to merge with Word to produce a letter for each ID which summarises each individual exam activity in tabular form (for which there is a separate record entered in Excel)." β learn.microsoft.com/answers #4800820
That last one is the many-to-one merge β one document per customer containing all of their
line items. Word cannot do it at all. mergemill does it with one flag.
Type placeholders directly into the document. No MERGEFIELD, no field codes β just text.
Dear {{ contact_first_name }},
Thank you for instructing {{ firm_name }} in connection with {{ matter }}.
Our fee is {{ fee | num(2) }} {{ currency }}, payable within {{ payment_terms }} days.
{% if retainer %}A retainer of {{ retainer | num(2) }} is payable before work begins.{% endif %}
A column headed Contact First Name in your spreadsheet resolves to {{ contact_first_name }}.
Case, spaces and punctuation are handled for you.
mergemill check letter.docx clients.xlsx --name "{{ client_name }}"mergemill run letter.docx clients.xlsx -o out --name "{{ client_name }}-{{ matter }}"# One PDF per row, no Word or LibreOffice required
mergemill run letter.docx clients.xlsx -o out --format pdf --name "{{ client_name }}"
# One invoice per customer, containing all of their line items
mergemill run invoice.docx lines.xlsx -o out \
--group-by invoice_no --name "{{ group_value }}"
# Only German clients over 1000, and show me what would happen first
mergemill run letter.docx clients.xlsx -o out \
--filter "country == 'DE' and fee > 1000" --dry-run
# Re-run a batch without touching what already exists
mergemill run letter.docx clients.xlsx -o out --skip-existing
# Use it as a CI gate: fail the build if a template drifts from its data
mergemill check letter.docx clients.xlsx --strict --jsonStandard Jinja2 via docxtpl, plus a few filters for spreadsheet data.
| You write | You get |
|---|---|
{{ client_name }} |
the cell value; a blank cell renders as nothing, never None |
{{ start_date }} |
2026-06-01 β never 2026-06-01 00:00:00 |
{{ fee | num(2) }} |
1234.50 |
{{ fee | num(2, ' ') }} |
1 234.50 |
{{ start_date | date('%d %B %Y') }} |
01 June 2026 |
{{ middle_name | blank('N/A') }} |
N/A when the cell is empty |
{% if vip %}β¦{% endif %} |
conditional sections |
Always available, whatever is in your spreadsheet: {{ row_number }} (the actual spreadsheet
line), {{ row_index }}, {{ total_rows }}, {{ today }}, {{ source_file }}. With
--group-by: {{ group_value }}, {{ group_count }} and {{ rows }}.
Put {%tr for line in rows %} in its own table row and {%tr endfor %} in another β the rows
between them repeat.
{%tr for line in rows %} |
{{ line.description }} β {{ line.quantity }} |
{%tr endfor %} |
| Per-record files | Filename from data | PDF per record | Many-to-one | Runs offline | Price | |
|---|---|---|---|---|---|---|
| mergemill | β | β | β | β | β | Free, MIT |
| Word Mail Merge | β | β | β | β | β | Bundled with Word |
| Merge Tools add-in (VBA) | β | β | β | partial | β | Free, Windows + Word only |
| docxtpl | β library | β | β | β | β | Free β you write the batch loop |
| Carbone | β | β | β | β | on-prem tier | $29β$159/mo, on-prem $1,500/yr |
| docxtemplater | β | β | paid module | β | β | Free core; modules β¬500ββ¬3,000/yr |
| A Fiverr freelancer | β | β | β | β | n/a | $60β$600, once, unmaintainable |
Prices were taken from each vendor's public pricing page. None of them preflight.
docxtpl deserves a specific note: it is excellent, and mergemill is built on it. But it is a
library that renders one document from one dictionary. Spreadsheet input, the batch loop,
filename templating, PDF output, grouping and validation are all left to you. That is the gap.
Documents contain salaries, contracts and client names, so:
- No network calls, ever. Not for telemetry, not for updates, not for fonts.
- The web UI binds to
127.0.0.1only and refuses to bind to a public interface. - Uploads in the UI land in a temporary directory that is deleted when the request ends.
- PDF conversion is local β no Word, no LibreOffice, no cloud converter.
pip install mergemill # core: .docx output
pip install 'mergemill[pdf]' # adds PDF outputPython 3.9+. Runtime dependencies are just docxtpl and openpyxl; the web UI uses only the
standard library.
| Command | Does |
|---|---|
mergemill init [dir] |
Write a runnable example: two templates, two spreadsheets |
mergemill inspect T.docx |
List every placeholder the template uses |
mergemill check T.docx D.xlsx |
Preflight only β never writes documents |
mergemill run T.docx D.xlsx -o out |
Preflight, then generate |
mergemill ui |
Local browser interface |
Useful flags: --name (filename pattern) Β· --group-by Β· --format docx\|pdf\|both Β·
--filter Β· --limit Β· --dry-run Β· --skip-existing Β· --strict Β· --force Β· --json Β·
--sheet Β· --date-format
Exit codes: 0 clean Β· 1 some documents failed Β· 2 preflight errors.
from mergemill import data, template, preflight, render
dataset = data.load("clients.xlsx")
info = template.inspect("letter.docx")
result = preflight.run(dataset, info, "{{ client_name }}", output_dir="out")
for finding in result.findings:
print(finding.code, finding.severity, finding.title)
if result.ok:
plan = render.build_plan(dataset, info.path, "{{ client_name }}", "out")
outcome = render.execute(plan)
render.write_manifest(plan, outcome, "out/manifest.json")Every run can write a JSON manifest mapping each output file back to the spreadsheet rows that produced it β useful when someone asks six months later where a document came from.
| Code | Level | Meaning |
|---|---|---|
| E001 | error | Template has a Jinja syntax error |
| E002 | error | Placeholder has no matching column (with did-you-mean) |
| E003 | error | Filename pattern references an unknown field |
| E004 | error | Two or more rows produce the same filename |
| E005 | error | Filename is illegal on Windows or macOS |
| E006 | error | Data source has no rows |
| E007 | error | Two columns normalise to the same identifier |
| E008 | error | --group-by column does not exist |
| W101 | warning | Column present in the data but never used |
| W102 | warning | Blank cells in a column the template prints |
| W103 | warning | Template contains no placeholders |
| W104 | warning | Output folder already holds files that would be overwritten |
| W105 | warning | Grouping has no effect β every group has one row |
| W106 | warning | A filename needed automatic fixing |
| W107 | warning | A column is shadowed by a built-in name and is unreachable |
-
.pptxand.xlsxtemplates - Images per record (
{{ logo }}from a file-path column) - A
mergemill.tomlso a project's flags live in the repo - Watch mode: regenerate when the spreadsheet changes
- Google Sheets input via an explicit, opt-in fetch step
- Pre-built binaries so non-Python users can skip
pip
Have a use case that does not fit? Open an issue β real workflows are more useful than feature requests in the abstract.
See CONTRIBUTING.md. The short version: pip install -e '.[dev]',
pytest, and new preflight rules are the most welcome contribution of all.
MIT β see LICENSE.


