Skip to content

Generate the IG source

This is the step that reads your instance. One command pulls the data sets, the event and tracker programs, the tracked entity types, the option sets, the category combinations, and the organisation-unit hierarchy, and writes them out as the source of a publishable package - one form per data set, one per event program, one per tracker stage, a code list per option set, and two files per organisation unit. Nothing is written to DHIS2 and nothing is published yet; this writes files into your project directory.

Who this is for: the operator regenerating after a metadata change, and the implementer running generation for the first time.

Before you start: a scaffolded project pointing at your instance (Set up an IG project), ideally after a clean validate - generate refuses the codes and names validate marks as build-aborting.

You will be able to:

  • run the whole pipeline, or one target of it, and read the summary
  • say which directory each target owns and what a re-run does to it
  • narrow the selection so a national instance stays reviewable
  • find the notes a run raised and know which ones repeat validate

Run the whole pipeline

$ d2w fhir generate
running 8 step(s)
[1/8] instance metadata: 14 questionnaire target(s), 13 option set(s), 5 categories,
1,332 organisation unit(s)
[2/8] foundation: 23 files written, 0 files unchanged
[3/8] option sets: 13 option sets, 39 files written, 0 files unchanged
[4/8] categories: 5 categories, 15 files written, 0 files unchanged
[5/8] questionnaires: 14 questionnaires, 28 files written, 0 files unchanged, 1 note
[6/8] examples: 14 examples, 14 files written, 0 files unchanged, 2 notes
[7/8] organisation units: 1,332 organisation units, 2,667 files written, 0 files unchanged
[8/8] pages: 6 pages, 20 files written, 0 files unchanged, 1 note
full pipeline: 2,806 file(s) written across 7 target(s)
info: local_basic (fhir.toml) -> /home/you/demo-ig
                                                 fhir generate (7)
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━┓
┃Target         ┃ Subject            ┃ Directory          ┃ Files written ┃ Files unchanged ┃ Files deleted ┃ Notes┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━┩
│foundation     │ -                  │ ig/input/fsh/found │ 23            │ 0               │ 0             │ 0    │
│               │                    │ ation              │               │                 │               │      │
│option-sets    │ 13 option sets     │ ig/input/resources │ 39            │ 0               │ 0             │ 0    │
│               │                    │ /terminology,      │               │                 │               │      │
│               │                    │ resources/concept- │               │                 │               │      │
│               │                    │ maps               │               │                 │               │      │
│categories     │ 5 categories       │ ig/input/resources │ 15            │ 0               │ 0             │ 0    │
│               │                    │ /categories,       │               │                 │               │      │
│               │                    │ resources/concept- │               │                 │               │      │
│               │                    │ maps               │               │                 │               │      │
│questionnaires │ 14 questionnaires  │ ig/input/fsh/data- │ 28            │ 0               │ 0             │ 1    │
│               │                    │ sets,              │               │                 │               │      │
│               │                    │ fsh/event-programs │               │                 │               │      │
│               │                    │ ,                  │               │                 │               │      │
│               │                    │ fsh/tracker-progra │               │                 │               │      │
│               │                    │ ms,                │               │                 │               │      │
│               │                    │ fsh/tracked-entity │               │                 │               │      │
│               │                    │ -types,            │               │                 │               │      │
│               │                    │ fsh/data-dictionar │               │                 │               │      │
│               │                    │ y,                 │               │                 │               │      │
│               │                    │ resources/assignme │               │                 │               │      │
│               │                    │ nts,               │               │                 │               │      │
│               │                    │ resources/attribut │               │                 │               │      │
│               │                    │ e-option-combos,   │               │                 │               │      │
│               │                    │ resources/concept- │               │                 │               │      │
│               │                    │ maps               │               │                 │               │      │
│examples       │ 14 examples        │ ig/input/fsh/examp │ 14            │ 0               │ 0             │ 1    │
│               │                    │ les                │               │                 │               │      │
│org-units      │ 1,332 organisation │ ig/input/fsh/organ │ 2,667         │ 0               │ 0             │ 0    │
│               │ units              │ ization,           │               │                 │               │      │
│               │                    │ resources/registry │               │                 │               │      │
│pages          │ 6 pages            │ ig/input/pageconte │ 20            │ 0               │ 0             │ 0    │
│               │                    │ nt                 │               │                 │               │      │
└───────────────┴────────────────────┴────────────────────┴───────────────┴─────────────────┴───────────────┴──────┘
note: 2 note(s) across 2 target(s); full list in
/home/you/demo-ig/reports/fhir-generate-notes.md (--details to print)

Every line carries two different numbers. The subject is what the target covers on the instance - 1,332 organisation units, 14 questionnaires - and the file counts say how many files publishing that took, which is larger wherever one covered object ships as several resources: an organisation unit becomes both an Organization and a Location, so 1,332 units are 2,667 files. Foundation covers no instance object at all, so its line and its Subject cell are files alone.

The step lines and the table disagree about the examples on purpose: the step line counts what that target raised on its own, and the table counts each note once, on the first target that raised it - the greys-out note the examples and the pages both repeat is filed under the questionnaires.

The bare run is the one to reach for: it reads the instance once and every target builds off that single result, where seven separate commands each open a client of their own. It is also the cheap half of the chain - against a local instance a run like the one above is quick, and the real wall clock is all in the compile and publish that follows. uv run d2w fhir generate is the same command through the project's pinned toolchain, and the closing info: line names the profile it read and the project it wrote.

Re-running against an unchanged instance converges to zero:

$ d2w fhir generate
running 8 step(s)
[1/8] instance metadata: 14 questionnaire target(s), 13 option set(s), 5 categories,
1,332 organisation unit(s)
[2/8] foundation: 0 files written, 23 files unchanged
[3/8] option sets: 13 option sets, 0 files written, 39 files unchanged
[4/8] categories: 5 categories, 0 files written, 15 files unchanged
[5/8] questionnaires: 14 questionnaires, 0 files written, 28 files unchanged, 1 note
[6/8] examples: 14 examples, 0 files written, 14 files unchanged, 2 notes
[7/8] organisation units: 1,332 organisation units, 0 files written, 2,667 files unchanged
[8/8] pages: 6 pages, 0 files written, 20 files unchanged, 1 note
full pipeline: 0 file(s) written across 7 target(s)

Know the eight targets

d2w fhir generate                All seven, in that order, off one pass over the instance
d2w fhir generate foundation     Identifier aliases + the extensions + the capture contract
d2w fhir generate option-sets    Option sets -> CodeSystem/ValueSet pairs
d2w fhir generate categories     Categories -> CodeSystem/ValueSet pairs
d2w fhir generate questionnaires Data sets + event programs + tracker programs -> Questionnaires
d2w fhir generate examples       Example QuestionnaireResponses answering those Questionnaires
d2w fhir generate org-units      Organisation units -> Organization/Location instances
d2w fhir generate pages          Narrative site pages + per-artifact intros
d2w fhir generate load-set       Synthetic QuestionnaireResponse corpus into load/ (not IG source)

Name a target when you want that target alone - a tight edit loop on one directory. A solo run prints its own detail table and all of its notes:

$ d2w fhir generate pages
running 2 step(s)
[1/2] instance metadata: 14 questionnaire target(s), 1,332 organisation unit(s)
[2/2] pages: 0 written, 20 unchanged, 1 note
                              fhir generate pages
┌──────────────┬──────────────────────────┐
│profile       │ local_basic (fhir.toml)  │
│project       │ /home/you/demo-ig        │
│target        │ ig/input/pagecontent     │
│files written │ 0                        │
│unchanged     │ 20                       │
│files deleted │ 0                        │
│pages         │ 6                        │
│intros        │ 14                       │
└──────────────┴──────────────────────────┘
note: data set 'Child Health' (BfMAe6Itzgt) greys out 8 disaggregated cells, which
are not published; a response answering one would not be of the form:
DUSpd8Jq3M7.hEFKSsPV5et, DUSpd8Jq3M7.psbwp3CQEhs, ca8lfO062zg.Prlt0C1RF0s,
ca8lfO062zg.V6L425pT3A0, d5xTg3WR3DP.Prlt0C1RF0s and 3 more

What each target writes, and where:

Target Writes
foundation ig/input/fsh/foundation/ - the identifier aliases and NamingSystems, the D2Period / D2FormType / D2AttributeValue and tracker extensions, the response profiles, $generate, and the capture CapabilityStatement. Depends on fhir.toml alone; never touches DHIS2. Prerequisite for a compiling IG.
option-sets Pre-built CodeSystem/ValueSet JSON into ig/input/resources/terminology/, ConceptMaps into resources/concept-maps/.
categories Its own CodeSystem/ValueSet JSON into ig/input/resources/categories/, ConceptMaps into resources/concept-maps/.
questionnaires One Questionnaire per data set (fsh/data-sets/), per event program (fsh/event-programs/), per tracker stage plus the program's registration form (fsh/tracker-programs/<program stem>/), per tracked entity type's person-only registration form (fsh/tracked-entity-types/), the shared data dictionary (fsh/data-dictionary/), assignment Lists (resources/assignments/), attribute-option-combo terminology (resources/attribute-option-combos/), and the two ConceptMaps that map an instance's own statements outward - D2Sex_CM and D2Section_CM - into resources/concept-maps/.
examples One Usage: #example response per target into fsh/examples/<target stem>-<n>.fsh.
org-units Profiles and level terminology into fsh/organization/; the registry as pre-built JSON - Organization-<stem>.json + Location-<stem>.json per unit - into resources/registry/.
pages Six site pages plus per-artifact intros into ig/input/pagecontent/ - markdown, not FSH.
load-set Test data into load/ beside ig/, for posting at a running facade (Serve the IG). Gitignored; the bare run never writes it.

What the artifacts contain - the extensions, the identifier slices, the value-type mapping, the stems in those file names - is the reference material in Identifiers and the D2 extensions and Terminology and ConceptMaps.

Each target owns its subdirectories and syncs each one: writes what changed, leaves what did not, deletes generated files that no longer belong. A JSON sync owns its directory outright - it deletes every *.json the run did not produce - which is why each terminology pair gets a directory of its own. concept-maps/ is the one shared directory; ownership there is stated by file-name prefix. The tracker-programs/ sweep prunes a per-program subdirectory it emptied. Hand-authored files are safe by the same rule the whole tree follows: the sweep only deletes files carrying the generated header, so ig/input/pagecontent/index.md and any markdown you drop beside it survive every regenerate.

A run that rewrites FSH removes the compile. ig/fsh-generated/ is SUSHI's output and nobody else's, and the moment a target writes a FSH source it holds resources compiled from sources that no longer exist. So the run removes it and says which tree went and why:

note: removed ig/fsh-generated: it held SUSHI's compile of FSH sources this run
rewrote, and check-artifacts, serve, forward and `make build` all read that
tree. Run `make sushi` in the project to compile the sources this run wrote.

make sushi writes it again from the sources on disk, and make build runs the publisher's own SUSHI before it publishes, so a build needs nothing extra. Serving and forwarding read the compiled tree too, and each already refuses a project holding none by naming those same two steps - generate, then make sushi - so a serve right after a generate is the flow those pages already teach. d2w fhir serve --live reads the instance instead and needs no compile at all.

A regenerate that writes nothing leaves the compile where it is: the sources it was compiled from are still the sources on disk. Nothing else is removed - ig/temp/ is the IG publisher's own scratch, which no command reads between builds, and ig/output/ is a published site, which is a thing to replace deliberately rather than to discard on a source edit.

The pre-built resources are not committed. The scaffolded .gitignore covers ig/input/resources/ - thousands of regenerable files - while ig/input/fsh/ is committed, so the FSH diff after a metadata change is still there to review.

Narrow the selection

Absent or empty selection tables mean all of that kind on the instance. A national instance carries hundreds of forms, so an IG meant for review names the handful of UIDs it is about:

[generate.data_sets]
include_ids = ["TuL8IOPzpHh"]       # EPI Stock

[generate.event_programs]
include_ids = ["EVTsupVis01"]       # Supervision visit

[generate.tracker_programs]
include_ids = ["IpHINAT79UW"]       # Child Programme - registration + one per stage

[generate.tracked_entity_forms]
include_ids = ["nEenWmSyUEp"]       # Person - register a person, no program

[generate.option_sets]
include_ids = ["OsVaccType1"]       # Vaccine type

[generate.categories]
include_ids = ["yY2bQYqNt0o", "Qzh0MSUx4RM"]

[generate.organisation_units]
max_level = 4                       # or root = "<uid>" for one sub-hierarchy

Name the terminology tables too, and not only the form tables. A table left absent selects everything of its kind, and one DHIS2 name carrying a raw < anywhere in that everything refuses the run - which is why every guide in examples/fhir/igs/ names its option sets and its categories explicitly.

Or seed the same lists while scaffolding: d2w fhir init --data-set ... --event-program ... --tracker-program ... --max-level 4 (offline; written as given). Points worth knowing:

  • The two program tables select opposite programTypes, and a UID listed under the wrong one fails the run by name rather than being quietly reshaped. With empty lists the sweep routes each program by its live type.
  • [generate.tracked_entity_forms] is the person-only shelf. Left empty it publishes a registration form for each tracked entity type the selected tracker programs register - a form that registers somebody in DHIS2 without enrolling them in any program. Name UIDs to publish a different set.
  • The option-set closure: a selected form binding an option set outside [generate.option_sets] include_ids pulls the set in anyway, with a note. Validate resolves scope the same way, so the two never disagree.
  • Registry scale: every organisation unit emits two instances, and the IG publisher validates and renders every resource, so the registry sets the wall clock of the publisher run. org-units warns once a registry passes 2,000 instances, naming both dials (max_level, root). See Build and publish the guide.
  • Examples: [generate.examples] per_target sets responses per form (0 disables the target); source = "synthetic" (default, deterministic, no data endpoint called) or "instance" (answers from the values the server holds - deliberate on production, since real captured values land in a document you are about to publish).
  • Locales: [generate] locales = [] means every locale found on the instance; list BCP-47 or DHIS2-style tags (pt_BR and pt-BR both work) to narrow. NAME translations become concept designations and title/name translation extensions; a question DHIS2 gives a form name to takes its label translation from FORM_NAME. Nothing else is emitted.

Know which spelling a surface speaks

A DHIS2 data element carries two spellings, and they are written for two audiences. name is written for analytics and reference - it has to be unique and it reads like a report column. formName is written for input: not always to shorten, but to clarify what a person is being asked. So each surface speaks the one it is for.

Input surfaces speak formName, reference surfaces speak name. A Questionnaire.item.text - a data set question, an event programme question, a tracker stage question, a registration form's tracked entity attribute, and the group heading of a disaggregated element - is the form name wherever DHIS2 states one and the name wherever it does not. The data dictionary is the reference surface, so a D2DE_CS or D2TEA_CS concept keeps displaying the name and states the form name beside it as a form-name property; a consumer holding one concept reaches both spellings. The cells of a disaggregated question are labelled by their category option combo, which has no form name of its own.

Both spellings are person-written, so both pass the same gate: a < in a form name refuses a run and is graded a template-hostile-name error by d2w fhir validate exactly as a < in a name is.

Know what a run refuses to write

A DHIS2 name is kept byte-true on the resource it becomes, and a DHIS2 form name is kept byte-true on the question it labels; the IG publisher writes both into pages it strict-parses after writing. A < opens a tag, so the parse of the page it just wrote fails - hours in, once every resource has already been rendered. A run that would write such a name is refused whole before a single file is written, naming the object and which of its two spellings carries the character, and exiting 1:

$ d2w fhir generate
error: dataElements 'Vitamin A given to < 5y' (tU7GixyHhsv), asked by dataSets
'Child Health' (BfMAe6Itzgt), has a name carrying '<'. ...
$ echo $?
1

The whole run is refused rather than the one object skipped, because a quietly skipped object leaves a guide that disagrees with its own selection - a Questionnaire referencing terminology nobody wrote.

This is the same statement d2w fhir validate makes, in both directions. Validate reads the same hostile_names posture this run does, so under "refuse" - and unset, which refuses - every name graded a selection-scoped template-hostile-name error there refuses a run here, and every name refused here is graded that error there. Under "substitute" neither command stops, and validate grades those names informational naming the wording published in their place. It holds over every kind of object a selection publishes: option sets and their options, categories and their category options, organisation units, data sets, event programs, tracker programs and their stages, tracked entity types, and the data elements and tracked entity attributes those forms ask as questions. Codes are gated too, on the six collections whose codes become identifier values - optionSets, categories, organisationUnits, dataSets, programs, programStages.

Run d2w fhir validate first anyway: generate stops at the first object it cannot write, so on an instance with several offenders only validate lists them all. The fix is in DHIS2 - rename the object - or leave it out of the selection. examples/fhir/igs/refused-names/ is a working exhibit of both commands on one poisoned selection.

A build reads neither of them. make build publishes whatever ig/fsh-generated/ and ig/input/ hold, so artifacts written before this gate existed, artifacts from a generate an older lock pinned, and hand-authored FSH all reach the publisher without ever passing it. d2w fhir check-artifacts is the same refusal applied to those files, through the same two predicates, and it is what make build runs first - see The build refuses before it begins.

Answer the hostile-name question

Refusing is one answer, and for many instances it is the wrong one. DHIS2 names carry < legitimately: an age band is called 5 to < 15 years, Female and a disaggregation cell is called Male, <15y. Renaming several hundred of those in a production instance to publish a guide is not a fix - it is the guide's problem pushed onto the people who run the instance.

So a run has three outcomes, and you choose which:

Outcome How you get it What happens
Refuse --refuse-hostile-names, or hostile_names = "refuse" The run writes nothing and names the object, exactly as above. Every code is published byte-true.
Ask neither flag, no dial, and a terminal The names and codes are shown with their rewrites and you answer yes or no.
Substitute --substitute-hostile-names, or hostile_names = "substitute" The guide publishes the rewritten wording and the hyphenated codes, and every rewrite is noted.

A flag beats the fhir.toml dial, and the dial beats the question. With no terminal to ask on - a script, a CI job - the run never hangs on a prompt: it prints the same block, names the two flags, and leaves every name and code as DHIS2 states it.

The name rewrite is wording, not escaping. < becomes the word it stands for, so a reader of the guide reads a sentence:

DHIS2 name Published name
5 to < 15 years, Female 5 to under 15 years, Female
Male, <15y Male, under 15y
Age <= 5 Age at most 5

Codes carrying a space are rewritten too, and only under substitute. A space is legal in an R4 code, so nothing refuses Pre eclampsia - and the IG publisher's anchor slug strips whitespace, so it and a literal Preeclampsia render one anchor id (BUGS.md 107), while every URL, CQL quotation, and terminology server below the guide escapes or quotes the space at its own discretion. Each space becomes a hyphen:

DHIS2 code Published code
Pre eclampsia Pre-eclampsia
IPT 1 IPT-1
Pre eclampsia, where the instance also holds Pre-eclampsia Pre-eclampsia-2

The ordinal is assigned in sorted order, so the same selection publishes the same codes on every run.

DHIS2 is never written to, and nothing is lost. No UID is touched. Every rewritten concept states the DHIS2 code byte-true as a dhis2-code property - in the option-set, category, and category-option-combo vocabularies and in the .../id/*-code identifier CodeSystems alike - and the ConceptMaps keep taking a published concept back to its DHIS2 UID. The capture path reads that property, so a QuestionnaireResponse answering with a published code still writes the DHIS2 code to DHIS2. A DHIS2 code carrying < still refuses the run whichever answer you give: < opens a tag in the table cell an identifier value lands in, and no rewrite of it would be an identifier anybody could join on.

Every rewrite lands in the notes - a name-substitution note per distinct DHIS2 name and a code-substitution note per distinct DHIS2 code, however many resources carry them - so the notes report is the record of where the guide and the instance say different things:

note: the DHIS2 name '5 to < 15 years, Female' carries a comparison the IG
publisher's pages cannot carry as it stands; the guide publishes '5 to under
15 years, Female', states '5 to < 15 years, Female' as a `dhis2-name`
property, and DHIS2 keeps the name it holds

note: the DHIS2 code 'IPT 1' carries a space, which the IG publisher's anchors
and every URL downstream of them handle at their own discretion; the guide
publishes 'IPT-1' and states the DHIS2 code beside it as a `dhis2-code` concept
property

Substitution reaches further than the refusal does. The refusal reads the names and form names of the objects a selection publishes and the questions they ask; the rewrite reads every name the emission publishes, which also covers the cell names of a disaggregation. Those reach a Questionnaire's cell labels and the data dictionary's category option combo displays without passing any refusal - one national selection generated cleanly and then handed the publisher 738 of them. Under refuse that class is caught by d2w fhir check-artifacts, before you spend a build on it; under substitute it never reaches the disk.

Read the notes

Each target raises aggregate notes - a selection entry that matched nothing, an option set the closure pulled in, a form skipped for a linkId collision. A note carries a kind, and three kinds (code-fallback, code-collision, stem-fallback) are restatements of what validate already reports. A bare run counts them - the echoes separately - and writes them all to reports/fhir-generate-notes.md, each note once, under the first target that raised it:

note: 3 note(s) across 2 target(s) (+8 validate echoes); full list in
reports/fhir-generate-notes.md (--details to print)

Read that as: generation found three things worth your attention, and eight more it would only be repeating d2w fhir validate to tell you about. Nothing is hidden - the file carries every note, echoes under a trailing ### Restatements of validate findings heading. --details prints every note inline instead; a solo target always prints all of its notes. From the demo run above:

$ cat reports/fhir-generate-notes.md
# fhir generate notes

- Profile: local_basic (http://localhost:8080)
- Generated: 2026-08-15T18:02:36+00:00

## questionnaires

- data set 'Child Health' (BfMAe6Itzgt) greys out 8 disaggregated cells, which are not published; a response answering one would not be of the form: DUSpd8Jq3M7.hEFKSsPV5et, DUSpd8Jq3M7.psbwp3CQEhs, ca8lfO062zg.Prlt0C1RF0s, ca8lfO062zg.V6L425pT3A0, d5xTg3WR3DP.Prlt0C1RF0s and 3 more

## examples

- 1 question takes an attachment, a geometry document, or a reference to a DHIS2 object the IG does not publish; left unanswered in the synthetic examples: Birth certificate (uf3svrmp8Oj)

The greys-out note is a fact about the form, so the questionnaires, the examples, and the pages targets all raise it - and the file states it once, on the questionnaires, because they are the first target that did.

That first note is the one worth reading twice, because it is a DHIS2 fact about your form rather than a defect in the run. A data set section can grey out individual cells of a disaggregated group - DHIS2's own entry screen renders the cell and refuses to let anyone type in it. Those cells are not published, so no consumer of this package can answer one either; a response that did would not be a response to this form. The note names each one as <data element>.<category option combo> and counts the rest, and the same note is raised by every target that reads the form.

Every command with an instance behind it narrates its steps on stderr - a spinner on a terminal, one plain [k/N] label: summary line per step when redirected. --no-progress turns it off; d2w --json fhir generate implies it and puts the per-target file lists and counts on stdout as JSON.

Check the site pages landed

pages is the last target and the only one writing markdown: forms.md, registry.md, terminology.md, identifiers.md, periods.md, and capture.md - the scaffolded site menu - plus per-artifact intros the publisher injects into matching artifact pages (Questionnaire-<stem>-intro.md always; CodeSystem and Organization intros only when the DHIS2 object carries a description). index.md is scaffolded once and is yours. Every DHIS2 name and description on these pages is escaped on the way in, so a data set called "Mortality < 5 years by gender" renders as text rather than aborting the publisher's HTML parse.

Next: Build and publish the guide