Lesson 5 of 8
Unit · One template, many outputs
One template, twelve districts, no copy anywhere
Twelve district reports produced by copy-paste are twelve documents that will disagree by March. One parameterised template rendered twelve times is one document that cannot, and the mechanism is a single line in the frontmatter.
The report that becomes twelve reports
A cluster wants a report per district. The obvious approach is to write one, copy it eleven times and change the filter at the top of each.
By March those twelve files disagree. Someone fixes the commune-name normalisation in one of them. Someone adds a caveat to three. The indicator definition changes and eight get updated. Nothing in any build can detect that they have drifted, because they are twelve unrelated documents.
A parameterised template is one document. Fixing it fixes twelve reports, and there is nowhere for a difference to hide.
Quarto, which this platform already uses
---
title: "Nutrition surveillance: `r params$district`"
params:
district: "Artibonite"
as_of: "2025-03-31"
format: pdf
---
quarto render report.qmd -P district:Nord-Ouest -P as_of:2025-03-31
The document declares its parameters and the renderer supplies them. The body
refers to params$district — in R — or to district from the injected parameters
cell in Python, and nothing else in the file mentions a specific district.
# | tags: [parameters]
district = "Artibonite"
as_of = "2025-03-31"
params$district
The default value in the frontmatter is what makes the template previewable. You open it, it renders for Artibonite, and you are editing something you can see.
Rendering all twelve
import subprocess, pathlib
DISTRICTS = ["Artibonite", "Centre", "Grande-Anse", "Nippes", "Nord",
"Nord-Est", "Nord-Ouest", "Ouest", "Sud", "Sud-Est"]
for district in sorted(DISTRICTS):
slug = district.lower().replace(" ", "-")
subprocess.run([
"quarto", "render", "report.qmd",
"-P", f"district:{district}",
"-P", f"as_of:{AS_OF}",
"--output", f"outputs/reports/{slug}.pdf",
], check=True)
for (d in sort(districts)) {
quarto::quarto_render(
"report.qmd",
execute_params = list(district = d, as_of = as_of),
output_file = paste0(tolower(gsub(" ", "-", d)), ".pdf")
)
}
check=True is the important argument. Without it a failed render is a missing
file that the loop steps over silently, and you discover it when someone asks for the
Nippes report.
Sort the district list, for the reason the last lesson gave: the loop’s output order should not depend on how the list was assembled.
Writing a template that survives its own parameters
Four things a copied report gets away with and a template does not.
No hard-coded numbers in the prose. Every figure in the text comes from the data.
Coverage in `r params$district` is
`r scales::percent(coverage, accuracy = 0.1)`, against a national figure of
`r scales::percent(national, accuracy = 0.1)`.
Handle the district with no data. One of the twelve will have an empty table, and
a template that assumes rows produces a stack trace or, worse, a page of NaN.
if summary.empty:
print(f"No screening was conducted in {district} during this period.")
else:
...
if (nrow(summary) == 0) cat("No screening was conducted in this period.")
Handle the singular. “1 communes” in eleven reports is the sign of a template nobody proofread.
Handle the small denominator. A district with 40 screenings gets an interval three times wider than one with 400, and the template has to say so rather than printing both to one decimal place as though they were comparable.
What belongs in the template and what belongs in the pipeline
| In the template | In the pipeline |
|---|---|
| Layout, prose, the shape of the argument | Reading raw data |
| A summary table computed from prepared data | Cleaning and normalisation |
| Figures, drawn from prepared data | Any transformation more than a line or two |
| Caveats, thresholds and definitions | Anything two reports would share |
The template should read a prepared file, not the raw export. If twelve renders each redo the cleaning, the cleaning runs twelve times, takes twelve times as long, and — worse — can be edited in the template for one district only.
# run.py
clean() # once
build_figures() # once
for district in DISTRICTS: # twelve times, from prepared data
render(district)
# run.R, same shape
The parameter you should always have
params:
district: "Artibonite"
as_of: "2025-03-31"
data_version: "v1"
A data version parameter makes the report say which file it read, which is the provenance question a reader asks six months later. Print it in the colophon.
And an as_of parameter rather than the clock, for the reason lesson 4 gave: a
report that says “generated 14 March” changes every time it is rebuilt, and a report
that says “data as of 31 March” does not.
Where this platform does the same thing
Every lesson on this site produces a slide deck in two languages and three formats — a Beamer PDF, a PowerPoint file and the LaTeX source, six files per lesson — and none of them was authored.
slide-plan.mjs plans the deck once from the lesson body, and the Beamer PDF, the
PowerPoint file and the in-browser slide reader are three renderings of that one plan.
The reasoning is the same as the twelve districts’, at a different scale. A
slides: array in the frontmatter would produce better slides in principle and worse
ones in practice: it means every new lesson needs a second authoring pass in two
languages, and that is the pass that gets skipped.
The consequence for authors is real and worth naming. Because the deck is derived, lessons are written with lists, bold lead-ins, tables and callouts — because that is what a deck is made of. A parameterised system shapes its inputs, and pretending otherwise produces templates that fight their own content.
Report it whole
District reports
Twelve district reports are rendered from one template, report.qmd, by
run.py. No district-specific text exists outside the data.
Parameters: district, as_of (2025-03-31), data_version (v1). Each report
prints all three in its colophon.
Cleaning and figure generation run once, before rendering; the template
reads prepared data and performs no transformation.
Districts with no screening in the period render a stated "no data"
section rather than an empty table.
Rendering is verified by `--check`: a failed render stops the run rather
than leaving a missing file.
The last line is what turns twelve rendered files into twelve reports you can send. A loop that swallows failures produces eleven reports and a question nobody asks until the wrong person notices.
What comes next
A pipeline that renders twelve reports from broken upstream data renders twelve broken reports. The next lesson makes it stop instead — loudly, at the point of failure, rather than producing a plausible number nobody can check.