PlantUML preprocessing deep-dive: Jinja/JSON/YAML injection and dynamic generation

puml.online

!include is basic preprocessing. But enterprise architecture diagrams often need to pull service lists, port numbers, AWS resource IDs from external data sources. This is the field guide for Jinja / JSON / YAML dynamic generation.

Why dynamic generation

The hard-coded .puml pain:

1
2
3
4
5
6
7
8
9
10
11
12
@startuml
component "user-service" as us
component "order-service" as os
component "payment-service" as ps
component "inventory-service" as is
component "notification-service" as ns

us --> os : HTTP 8080
os --> ps : HTTP 8081
os --> is : HTTP 8082
us --> ns : HTTP 8083
@enduml

The problem: adding a service means editing two places — the component list AND the connections. With 30+ services, editing the diagram becomes a nightmare.

Fix 1: use !define to extract constants

1
2
3
4
5
6
7
8
9
10
11
!define USER_SVC user-service
!define ORDER_SVC order-service
!define PAYMENT_SVC payment-service
!define INVENTORY_SVC inventory-service
!define NOTIFICATION_SVC notification-service

component USER_SVC as us
component ORDER_SVC as os
component PAYMENT_SVC as ps
component INVENTORY_SVC as is
component NOTIFICATION_SVC as ns

Marginally better — service names live in one place. But adding a service still touches multiple lines.

Fix 2: use !procedure to write loops

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
!procedure $component($name, $alias)
component "$name" as $alias
!endprocedure

!procedure $connect($a, $b, $port)
$a --> $b : HTTP $port
!endprocedure

$component("user-service", us)
$component("order-service", os)
$component("payment-service", ps)
$connect(us, os, 8080)
$connect(os, ps, 8081)
$connect(os, is, 8082)
$connect(us, ns, 8083)
@enduml

!procedure is PlantUML’s subroutine definition — extract to functions. But the service list is still hand-written.

Fix 3: read from JSON (with !include + !function)

1
2
3
4
5
6
7
8
9
10
// services.json
{
"services": [
{"name": "user-service", "alias": "us", "port": 8080, "deps": ["order", "notification"]},
{"name": "order-service", "alias": "os", "port": 8081, "deps": ["payment", "inventory"]},
{"name": "payment-service", "alias": "ps", "port": 8082, "deps": []},
{"name": "inventory-service", "alias": "is", "port": 8083, "deps": []},
{"name": "notification-service", "alias": "ns", "port": 8084, "deps": []}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@startuml
!include services.json

!procedure $render_component($svc)
component $svc.name as $svc.alias
!endprocedure

!procedure $render_connections($svc)
!foreach $dep in $svc.deps
!$depAlias = $dep + "-svc"
$svc.alias --> $depAlias : HTTP
!endforeach
!endprocedure

!foreach $svc in %json()
$render_component($svc)
!endforeach

!foreach $svc in %json()
$render_connections($svc)
!endforeach
@enduml

%json() magic variable — parses the included JSON file into a dictionary list, !foreach iterates over it.

Note: include the JSON file by full path. !include does not recurse — single include only.

Fix 4: read from YAML (with !include + %yaml())

1
2
3
4
5
6
7
8
9
10
# services.yaml
services:
- name: user-service
alias: us
port: 8080
deps: [order, notification]
- name: order-service
alias: os
port: 8081
deps: [payment, inventory]
1
2
3
4
5
6
7
8
9
10
11
12
13
@startuml
!include services.yaml

!foreach $svc in %yaml()
component $svc.name as $svc.alias
!endforeach

!foreach $svc in %yaml()
!foreach $dep in $svc.deps
$svc.alias --> $dep : HTTP $svc.port
!endforeach
!endforeach
@enduml

%yaml() and %json() share the same origin — both go through PlantUML’s !foreach context under the hood.

Fix 5: pre-generate .puml with Python

When PlantUML preprocessing isn’t enough (e.g. generate ER diagrams from a database schema), use a script to generate .puml first, then run plantuml to compile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# gen_diagram.py
import yaml, pathlib

config = yaml.safe_load(pathlib.Path("services.yaml").read_text())

with open("architecture.puml", "w") as f:
f.write("@startuml\n\n")
for svc in config["services"]:
f.write(f'component "{svc["name"]}" as {svc["alias"]}\n')
f.write("\n")
for svc in config["services"]:
for dep in svc["deps"]:
f.write(f'{svc["alias"]} --> {dep} : HTTP\n')
f.write("\n@enduml\n")
1
2
python gen_diagram.py
plantuml architecture.puml

Pros:

  • Fully programmable — any Python library works (SQLAlchemy for schema, boto3 for AWS, requests for HTTP APIs)
  • Easy to test — the script is plain Python, pytest covers it
  • PlantUML output stays compact

Cons:

  • An extra step — CI runs Python, then plantuml
  • Architecture changes go data-source (YAML) → gen_diagram.py → diagram, two steps instead of PlantUML !include‘s single step

Field example: auto-generate topology from a K8s cluster

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# gen_k8s_diagram.py
import yaml, subprocess
from kubernetes import client, config

config.load_kube_config()
v1 = client.CoreV1Api()

puml_lines = ["@startuml", "left to right direction", ""]

services = {}
for svc in v1.list_service_for_all_namespaces().items:
name = svc.metadata.name
namespace = svc.metadata.namespace
label = f"{namespace}/{name}".replace("-", "_")
services[name] = {"label": label, "namespace": namespace}

puml_lines.extend([
f'component "{svc.metadata.name}" as {services[svc.metadata.name]["label"]}'
for svc in v1.list_service_for_all_namespaces().items
])

puml_lines.append("")
endpoints = v1.list_endpoints_for_all_namespaces()
for ep in endpoints.items:
for subset in (ep.subsets or []):
if not subset.ports:
continue
for port in subset.ports:
target_name = ep.metadata.name
target_label = services[target_name]["label"]
for ref in (subset.addresses or []):
if ref.target_ref and ref.target_ref.kind == "Pod":
pod_name = ref.target_ref.name
puml_lines.append(f'{target_label} --> [{pod_name}] : {port.port}')

puml_lines.append("@enduml")

with open("k8s-topology.puml", "w") as f:
f.write("\n".join(puml_lines))

subprocess.run(["plantuml", "k8s-topology.puml"], check=True)

The generated diagram reflects the current cluster state — add a service, run CI once, the diagram updates itself.

Field example: generate sequence diagrams from OpenAPI/Swagger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# gen_sequence_from_openapi.py
import yaml, pathlib

spec = yaml.safe_load(pathlib.Path("openapi.yaml").read_text())
base_url = spec.get("servers", [{}])[0].get("url", "https://api.example.com")

puml = ["@startuml", "participant Client", "participant API", ""]
for path, methods in spec["paths"].items():
for method, op in methods.items():
if method not in ("get", "post", "put", "delete"):
continue
endpoint = f"{method.upper()} {path}"
puml.append(f"Client -> API: {endpoint}")
for code, resp in op.get("responses", {}).items():
puml.append(f"API --> Client: {code}")
puml.append("")

puml.append("@enduml")
pathlib.Path("api-sequence.puml").write_text("\n".join(puml))

The generated sequence covers every API endpoint — docs and code stay synchronized forever.

Template inheritance (!include + !define)

1
2
3
4
5
# _base.puml
!define $service_color #LightBlue
!procedure $svc($name, $alias)
component "$name" <<$service_color>> as $alias
!endprocedure
1
2
3
4
5
6
7
# diagram.puml
!include _base.puml

$svc("user-service", us)
$svc("order-service", os)
us --> os
@enduml

_base.puml is the template base — multiple diagrams share the same component-style definition. Change the color palette by editing _base.puml.

String / numeric / date functions

1
2
3
4
5
6
7
8
9
10
11
12
!function $today()
%date()
!endfunction

!function $build_id()
%substr($GITHUB_SHA, 0, 7)
!endfunction

note as N1
Generated at $today()
Build: $build_id()
end note

%date() is the current date, %substr(s, start, end) is a string slice, %str() converts to string. All built-ins are listed in PlantUML Preprocessing.

Field foot-guns

  • !include does not support HTTP — local files only. To include remote files, curl first or use Python pre-generation.
  • %json() silently swallows include failures — wrong path won’t error, the !foreach just iterates over nothing. Add a CI lint phase that validates every !include path exists.
  • PlantUML string concat does not auto-coerce numbers: "service-" + 1 does not become "service-1" — you need %str($i).
  • !foreach nested > 3 levels errors out — PlantUML’s internal stack depth limit. For deep nesting use Python pre-generation.
  • Chinese service names with hyphens: component "user-service" as us is fine, but component "用户服务" as user_svc — alias must be pinyin / English; PlantUML aliases do not support Chinese + hyphen mixing.

Decision tree

1
2
3
4
5
6
Need dynamic generation?
├─ No → hard-code the .puml, done
├─ 5-10 services → !procedure for functions
├─ 10-50 services → JSON/YAML + !foreach
├─ External API/K8s/DB → Python pre-generates the .puml
└─ Every API endpoint generates a sequence → OpenAPI parser + Python

The more complex the scenario, the more you should treat .puml as “the output of a template” — maintain the generator code separately, don’t pile !function calls into .puml.

  • Title: PlantUML preprocessing deep-dive: Jinja/JSON/YAML injection and dynamic generation
  • Author: puml.online
  • Created at : 2026-07-30 17:00:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-preprocessing-jinja-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.