Format comparison
A side-by-side comparison of DMS against every config / data format the DMS docs reference. Honest on both sides — every other format has real strengths DMS intentionally gives up.
Based on DMS spec v0.14.
Formats compared:
- DMS — this format.
- JSON — RFC 8259, the wire-format baseline.
- JSON5 — JSON5.org, the "JSON for humans" dialect.
- HJSON — hjson.github.io, "human JSON".
- YAML — yaml.org, version 1.2 unless noted.
- TOML — toml.io, version 1.0.
- INI — no standard; behavior described is the consensus across
configparser(Python), Windows.iniAPI, etc. - XML — W3C XML 1.1, the data-as-markup case.
- BSON — bsonspec.org, MongoDB's binary JSON.
- StrictYAML — hitchdev.com/strictyaml, the cleaned-up YAML subset.
- RON — github.com/ron-rs/ron, Rusty Object Notation.
- HCL — github.com/hashicorp/hcl, HashiCorp Configuration Language v2.
- KDL — kdl.dev, KDL Document Language v2.
Cell legend: 🟢 supported (good) · 🟡 partial / caveats · 🔴 not supported · — not applicable to this format.
Tier 1 note: DMS tier 1 (decorators + dialect imports) layers structural metadata on top of the tier-0 grammar. Five dialects currently published —
dms+html(HTML/XML),dms+hcl(HCL/ Terraform),dms+kdl(KDL),dms+ron(RON),dms+k8s(Kubernetes manifests). Where a section below mentions DMS lacking element- shaped or block-shaped expressivity, the matching dialect addresses it. See TIER1.md for the spec, dialects/ for the dialect index.
Each format name in the tables below links to that format's write-up further down the page.
Comments
This is DMS's load-bearing differentiator. Every other format with comment syntax drops them on parse in the standard library — DMS is the only one that requires preservation by spec.
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Has comment syntax | 🟢 #,//,/* */,### LABEL ### |
🔴 | 🟢 //,/* */ |
🟢 #,//,/* */ |
🟢 # |
🟢 # |
🟡 ; or # parser-dependent |
🟢 <!-- --> |
🔴 binary | 🟢 # |
🟢 //,/* */ |
🟢 #,//,/* */ |
🟢 //,/* */,/- slashdash |
| Block / multi-line comments | 🟢 (nested /* */ and labeled ###) |
🔴 | 🟢 | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | — | 🔴 | 🟢 | 🟢 | 🟢 nestable /* */ |
| First-class AST attachment (leading/trailing/floating) | 🟢 by spec | — | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🟡 DOM only | — | 🔴 | 🔴 | 🔴 | 🟡 some impls (kdl-rs) |
| Preserved through decode → modify → re-encode | 🟢 every reference decoder, every language, by spec | — none | 🔴 libs drop them | 🔴 libs drop them | 🟡 ruamel.yaml only (Python) |
🟡 toml-edit only (Rust); separate value type |
🟡 varies wildly | 🟡 DOM only; SAX/streaming drops them | — | 🔴 | 🔴 | 🟡 HCL2 only via hclwrite (Go) |
🟡 kdl-rs only; not in spec |
Structure & syntax
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Nesting mechanism | indent (1-rule) | nested objects | nested objects | nested objects | indent | [section.path] headers; inline tables/arrays for nested values |
flat with sections | nested elements | nested binary doc | indent (YAML rules) | nested objects | nested blocks/objects | child blocks { … } |
| Indentation rule | one rule: siblings match, any width | — | — | optional | complex | — | — | — | — | YAML's | — | — | — (not indent-based) |
| Tabs in structural indent | 🟢 banned | — | — | allowed | 🟡 allowed (with pitfalls) | — | — | — | — | 🟡 allowed | — | — | — |
| Key/value separator | : |
: |
: |
: |
: |
= |
= |
— | — | : |
: |
= |
= for props; positional args use whitespace |
| List item marker | + item or […,…] |
[…,…] only |
[…,…] only |
[…] or one-per-line |
- item or flow |
[…,…] inline; [[array]] headers for top-level arrays of tables |
— no nested lists | repeated elements | binary array | - item |
[…,…] |
[…,…] |
sibling nodes (no array primitive) |
| Document root | 🟢 scalar/list/table (polymorphic) | object/array/scalar | object/array/scalar | object/array/scalar | scalar/seq/mapping | mapping only | implicit root | single element | document object | mapping only | any | block list | list of nodes only |
| Multi-document stream | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 --- |
🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
Keys
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Bare keys (unquoted) | 🟢 ASCII + Unicode XID_Continue | 🔴 | 🟢 ECMAScript ident | 🟢 | 🟢 very permissive | 🟢 [A-Za-z0-9_-] |
🟢 varies | 🟡 XML name rules | — | 🟢 | 🟢 | 🟢 | 🟢 broad identifier rules |
| Quoted keys | 🟢 basic / literal | 🟢 "…" mandatory |
🟢 either | 🟢 optional | 🟢 either | 🟢 basic / literal | 🔴 usually | — | — | 🟢 basic | 🟢 | 🟢 | 🟢 "…" |
| Numeric-looking bare keys | 🟢 as string | — | 🔴 | 🔴 | 🟡 may parse as number | 🟢 as string | — | — | — | 🟡 schema-coerced | 🟡 | 🟡 | 🟢 node names ≠ value literals |
| Duplicate keys | 🟢 error (post-decode string compare) | 🟡 undefined | 🟡 undefined | 🟡 undefined | 🟡 1.2: error; 1.1: varies | 🟢 error | 🔴 last-wins | siblings allowed | 🟢 error | 🟢 error | 🟢 error | 🔴 last-wins | 🟡 sibling nodes allowed; props rightmost-wins |
| Key-order preservation | 🟢 required by spec | 🔴 undefined | 🟡 impl-defined | 🟡 impl-defined | 🟡 impl-defined | 🟡 spec silent; preserved by all major impls | 🟡 impl-defined | 🟢 preserved (DOM) | 🟢 preserved | 🟢 preserved | 🟡 impl-defined | 🟢 preserved | 🟢 sequence preserved by spec |
| Dotted flat-shorthand keys | 🔴 (intentionally) | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 a.b.c = 1 |
🔴 flat sections | 🟢 path expressions | — | 🔴 | 🔴 | 🔴 | 🔴 |
Strings
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Double-quoted (with escapes) | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟡 varies | 🟢 attrs/CDATA | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| Single-quoted (literal, no escape) | 🟢 | 🔴 | 🟢 | 🟢 | 🟢 | 🟢 | 🟡 varies | — | — | 🟢 | 🔴 | 🔴 | 🔴 (raw uses #"…"#) |
| Plain/unquoted strings | 🔴 | 🔴 | 🔴 | 🟢 | 🟡 tons of edge cases | 🔴 | 🟢 everything is a string | — | — | 🟢 scalar | 🔴 | 🔴 | 🔴 (bare idents are names, not values) |
| Multi-line literal | 🟢 '''…''' |
🔴 | 🟡 via \ line splice |
🟢 '''…''' |
🟢 \| block |
🟢 '''…''' |
🔴 varies | 🟢 CDATA | — | 🟢 block | 🔴 | 🟢 heredocs | 🟢 raw #"…"# |
| Multi-line with escapes | 🟢 """…""" |
🔴 | 🔴 | 🔴 | 🟢 \| (no escape) / > |
🟢 """…""" |
🔴 | 🔴 | — | 🟡 folded | 🔴 | 🟢 <<EOF |
🟡 "…" can span lines |
| Optional label / terminator | 🟢 """EOF…EOF |
— | — | — | 🔴 | 🔴 | 🔴 | 🔴 | — | 🔴 | 🔴 | 🟢 heredoc tag | 🔴 |
| Indent-strip on multi-line | 🟢 always on, terminator-driven | — | — | — | 🟡 indent indicator | 🔴 | — | 🔴 | — | 🟡 | — | 🟢 <<-EOF |
🟡 v2 whitespace rules |
| Heredoc modifiers / pipeline | 🟢 _trim(), _fold_paragraphs(), chainable |
— | — | — | 🔴 (9 fixed flag combos) | 🔴 | — | 🔴 | — | 🔴 | — | 🟡 template directives | 🔴 |
Line-ending \ splice |
🟢 in """ |
🔴 | 🟢 | 🔴 | 🔴 | 🟢 in """ |
🔴 | 🔴 | — | 🔴 | 🔴 | 🔴 | 🟡 v2 line-continuation |
| String concatenation | 🔴 (by design) | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
Numbers
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Integer: decimal | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | as string | as string | 🟢 | 🟡 schema-coerced | 🟢 | 🟢 | 🟢 |
Integer: hex 0x |
🟢 | 🔴 | 🟢 | 🔴 | 🟡 1.2 | 🟢 | — | — | — | 🔴 | 🟢 | 🟢 | 🟢 |
| Integer: octal | 🟢 0o |
🔴 | 🔴 | 🔴 | 0o 1.2 / 0 1.1 |
0o |
— | — | — | 🔴 | 🟢 0o |
🟢 0o |
🟢 0o |
Integer: binary 0b |
🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | — | — | — | 🔴 | 🟢 | 🟢 | 🟢 |
| Underscore digit separators | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 1_000 |
— | — | — | 🔴 | 🟢 | 🔴 | 🟢 1_000 |
| Integer size | 🟢 64-bit signed, overflow = error | 🟡 float-as-number | 🟡 float-as-number | 🟡 varies | 🟡 impl-defined | 🟢 64-bit signed | 🟡 as text | 🟡 as text | 🟢 int32/int64/Decimal128 | 🟡 as text | 🟡 impl-defined | 🟡 impl-defined | 🟡 impl-defined |
| Leading-zero decimals | 🟢 error | 🟢 error | 🟢 error | 🟢 error | 🟡 varies | 🟢 error | — | — | — | 🟡 varies | 🟢 error | 🟢 error | 🟡 spec silent |
| Float: decimal | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | — | — | 🟢 double | 🟡 | 🟢 | 🟢 | 🟢 |
Float: hex/oct/bin (IEEE p) |
🟢 0x1.8p3, etc. |
🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | — | 🔴 | 🔴 | 🔴 | 🔴 |
Digit required on both sides . |
🟢 | 🟢 | 🟢 | 🟢 | 🔴 loose | 🟢 | — | — | — | 🟡 | 🟢 | 🟢 | 🟢 v2 |
inf / nan |
🟢 bare keywords | 🔴 | 🟢 Infinity,NaN |
🔴 | 🟢 .inf/.nan |
🟢 inf/nan |
🔴 | 🔴 | 🟢 as double | 🔴 | 🟢 inf/NaN |
🔴 | 🟢 #inf/#nan (v2) |
| Inf/NaN round-trip | required | — | required | — | not required | required | — | — | required | — | required | — | required (v2) |
Unit suffixes (5MB, 30s) |
🔴 (by design) | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
Dates & times
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Offset datetime (RFC 3339) | 🟢 | 🔴 as string | 🔴 as string | 🔴 as string | 🟡 1.1 ext | 🟢 | 🔴 as string | 🟢 xs:dateTime in schema | 🟢 UTC ms | 🟡 schema-coerced | 🔴 | 🔴 as string | 🔴 string + (date-time) annotation |
| Local datetime | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🟢 xs:dateTime variant | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 string + annotation |
| Local date | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🟢 xs:date | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 string + (date) annotation |
| Local time | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🟢 xs:time | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 string + (time) annotation |
| Fractional seconds | 🟢 error on > 9 digits | — | — | — | 🟡 varies | 🟡 "truncation OK" | — | 🟢 xs:dateTime | 🟡 ms-resolution | — | — | — | — |
Collections
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Block arrays (multi-line list) | 🟢 + item |
🔴 | 🔴 | 🟢 one-per-line | 🟢 - item |
🟡 [[array]] headers for arrays of tables; primitive arrays inline only |
🔴 | 🟢 repeated elements | 🔴 | 🟢 | 🔴 | 🟡 via blocks | 🟡 sibling nodes (no array primitive) |
| Flow arrays | 🟢 trailing ,, newlines OK |
🟢 | 🟢 trailing , |
🟢 optional , |
🟢 newlines OK | 🟢 newlines + trailing , (1.0+) |
🔴 | 🔴 | 🟢 array type | 🔴 no flow | 🟢 trailing , |
🟢 trailing , |
🔴 |
| Block tables | 🟢 via indent | 🔴 | 🔴 | 🟢 | 🟢 | [section] |
🟡 flat sections only | 🟢 nested elements | — | 🟢 | 🔴 | 🟢 blocks | 🟢 children blocks (each entry is a node) |
| Flow tables | 🟢 trailing ,, newlines OK |
🟢 {a:1} |
🟢 {a:1} |
🟢 | 🟢 | 🟡 one-line, no trailing , |
🔴 | 🔴 | — | 🔴 | 🟢 | 🟢 | 🔴 |
| Arrays of tables (inline) | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 [{…},{…}] or [[name]] headers |
🔴 | 🟢 | 🟢 | 🔴 | 🟢 | 🟡 via blocks | 🔴 |
| Tables of arrays (inline) | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟡 inline limited | 🔴 | 🟢 | 🟢 | 🔴 | 🟢 | 🟢 | 🔴 |
| Heterogeneous arrays | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 1.0+ | — | 🟢 via type attrs | 🟢 | 🟡 schema | 🟢 | 🟢 | 🟡 siblings carry any value |
Meta / advanced
| Feature | DMS | JSON | JSON5 | HJSON | YAML | TOML | INI | XML | BSON | StrictYAML | RON | HCL | KDL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Anchors / aliases (&foo / *foo) |
🔴 | 🔴 | 🔴 | 🔴 | 🟡 yes — DRY but edge cases | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
Merge keys (<<) |
🔴 | 🔴 | 🔴 | 🔴 | 🟡 1.1 ext | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
Type tags (!!str, !!int) |
🔴 | 🔴 | 🔴 | 🔴 | 🟡 yes | 🔴 | 🔴 | 🟢 via xsi:type / DTD |
🟢 explicit binary types | 🔴 | 🔴 | 🔴 | 🟢 (u8),(date),etc. — advisory |
| References / interpolation | 🔴 (rejected) | 🔴 | 🔴 | 🔴 | 🟡 via anchors | 🔴 | 🔴 | 🟢 XInclude / entities | 🔴 | 🔴 | 🔴 | 🟢 first-class (${var}, merge(), expressions) |
🔴 |
| Schemas in-format | 🔴 in-language | 🔴 | 🔴 | 🔴 | 🔴 external (JSON Schema) | 🔴 external | 🔴 | 🟢 DTD / XSD / RNG / Schematron | 🔴 | 🟢 schema is the format | 🔴 | 🔴 | 🔴 external (KDL Schema Language separate) |
| Null / none | 🔴 (key absence) | 🟢 null |
🟢 null |
🟢 null |
🟢 ~,null,empty |
🔴 | 🔴 | 🟡 via xsi:nil |
🟢 null type | 🔴 schema-coerced | 🟢 () unit |
🟢 null |
🟢 #null (v2) |
| No executable-code type | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 most loaders | 🟢 | 🟢 | 🔴 entities + processing instructions | 🔴 JS in field names | 🟢 | 🟢 | 🔴 whole language is expressions | 🟢 |
| Spec defines parse output as a dictionary / map | 🟢 by spec | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟡 flat sections | 🟡 DOM nodes | 🟢 | 🟢 | 🟢 | 🟢 | 🔴 spec defines Vec<Node>; mapping is consumer's job |
| Not coupled to one runtime | 🟢 13 reference decoders | 🟢 | 🟡 niche | 🔴 one library | 🟢 | 🟢 | 🟡 each parser is its own | 🟢 | 🔴 MongoDB-shaped | 🔴 Python-shaped | 🔴 Rust/serde-shaped | 🔴 Terraform-shaped | 🟢 multiple ports (Rust, JS, Python, Java, Go, Ruby, Swift) |
Format-by-format
What each format does well, where it stops working, and why DMS made the trade it did. The headings below are linked from every format-name cell in the tables above — click any column to land on that format's write-up.
DMS
The only format on this list where comments survive decode →
modify → re-encode by spec, in every reference decoder. Plus:
heredocs with a chainable modifier pipeline (_trim,
_fold_paragraphs, etc.), three forms of multi-line comments,
polymorphic root, Unicode bare keys, non-decimal floats with p
exponents, strict whitespace rules without YAML's edge cases,
explicit over-precision errors, no ambiguity between numbers and
strings.
Full grammar in SPEC.md.
Where DMS deliberately gives up ground
- No references / anchors. DRY comes from the consumer or from
external preprocessing (templating,
cat, etc.). YAML's anchors- and-aliases mechanism is responsible for a large slice of YAML's edge cases; DMS skips that whole class of behavior. - No schemas in-format. Tier-0 has no schemas. Tier-1 dialect specs declare per-family param signatures (
typed,required,wildcard_with_typed,strict,positional) that the decoder validates against — but document validation against arbitrary schemas (JSON Schema, etc.) remains a separate concern. - No null. Key absence is the only missing-value signal. Less expressive than JSON / YAML; eliminates a Norway-style ambiguity class.
- No unquoted plain strings. Every string must be quoted or be a
heredoc. The cost: shorter docs look more cluttered than YAML /
HJSON. The benefit: zero ambiguity between a string
NOand the booleanfalse. - No multi-document streams. One DMS file = one DMS document.
If you need multi-document, use multiple files. Tier-1 dialects that need multi-resource files use a body list of
|resource(...)calls — seedms+k8sfor the pattern. This is semantically multi-document but syntactically still one DMS document. - No first-class references / expressions. Tier-0 has no expressions. Tier-1 dialects can declare opaque-expression families (
dms+hcl's@expr("aws_vpc.main.id")is the canonical example) but the strings are uninterpreted by DMS itself — evaluation lives in the consumer / runtime layer.
JSON
Wire format. Universal, fastest, simplest — and that's exactly the job it was designed for. No human-edit story; no comments; every key quoted; trailing commas are syntax errors; no multi-line strings; one number type (IEEE 754 double) so 64-bit IDs lose precision through a strict parser; dates are strings by convention.
JSON is fine as a transport. Every "JSON for humans" dialect — JSON5, HJSON — is an acknowledgment that JSON itself isn't one.
Side-by-side:
{
"name": "api-server",
"version": "1.4.2",
"host": "0.0.0.0",
"port": 8080,
"features": ["http2", "tls"],
"db": {
"host": "primary.example.com",
"port": 5432,
"pool_size": 20
},
"started_at": "2026-04-12T08:30:00Z"
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
JSON loses both comments and the front-matter/body split; started_at
becomes a plain string with no type signal.
JSON5
A small step up from JSON for hand-edited files: comments back
(//, /* */), unquoted keys for ECMAScript identifiers, hex
literals, leading-dot floats, signed Infinity / NaN, line-
continuation strings, single quotes, trailing commas. Each
relaxation is independently sensible.
The catch: JSON5 looks like JSON but isn't parsed by JSON parsers, and most JSON5 parsers quietly accept things outside the spec. Popular enough to confuse, not popular enough to be portable. No round-trip story — every library drops comments on parse.
Side-by-side:
// Service config — bumped after LB change
{
name: "api-server",
version: "1.4.2",
host: "0.0.0.0",
port: 8080, // standard internal port
features: ["http2", "tls"],
db: {
host: "primary.example.com",
port: 5432,
pool_size: 20,
},
started_at: "2026-04-12T08:30:00Z",
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
JSON5 recovers comments and trailing commas but still loses them on
re-encode; started_at is a string; no front-matter concept.
HJSON
The most readable JSON dialect — drops the quotes, accepts
multi-line strings via ''', allows # and // comments. Single
library, single ecosystem. The same ambiguities YAML has with
unquoted strings come back (a port called true is a boolean now);
HJSON's answer is "quote it", but at that point you're back to
JSON.
Side-by-side:
# Service config — bumped after LB change
{
name: api-server
version: 1.4.2
host: 0.0.0.0
port: 8080 # standard internal port
features: [http2, tls]
db: {
host: primary.example.com
port: 5432
pool_size: 20
}
started_at: 2026-04-12T08:30:00Z
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
HJSON's unquoted strings (api-server, 0.0.0.0) look clean but
create ambiguity; comments still drop on parse. DMS requires quotes,
preserves comments, and lifts started_at to a typed datetime.
YAML
Anchors and aliases for DRY, native null, unquoted strings, huge ecosystem. Pays for it with eighty-five pages of spec and a backlog of edge cases that survive every release.
Side-by-side:
# Service config — bumped after LB change
name: api-server
version: 1.4.2
host: 0.0.0.0
port: 8080 # standard internal port
features:
- http2
- tls
db:
host: primary.example.com
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
YAML's api-server is a plain string only because there's no
boolean or number that looks like it — version: 1.4.2 is safe
too, but version: 1.0 becomes float 1.0, not string "1.0".
DMS requires quotes, eliminating all such ambiguities.
The headline ones: the Norway Problem (NO, no, off, plus
in 1.1 y/yes/on) silently parse as boolean false; numbers
that aren't numbers (1.0 → float, 011 → octal 9 in YAML 1.1,
02138 → who knows); nine |/> block-scalar modes with indent
indicators; anchors / aliases / merge keys (&foo, *foo, <<)
that put inheritance into your data; tabs that are sometimes
fine and sometimes a parse error.
The clean visual shape is real. The 85-page spec underneath it is also real.
DMS tier 1's dms+k8s dialect addresses the most-cited YAML pain (Kubernetes manifests) with typed labels/selectors and an indent-strict grammar; other YAML use cases stay tier-0 plus the consumer's choice of schema.
TOML
Smallest spec to read, unambiguous grammar, no significant whitespace, excellent datetime story. The trade is verbosity.
Side-by-side:
# Service config — bumped after LB change
name = "api-server"
version = "1.4.2"
host = "0.0.0.0"
port = 8080 # standard internal port
features = ["http2", "tls"]
started_at = 2026-04-12T08:30:00Z
[db]
host = "primary.example.com"
port = 5432
pool_size = 20
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
TOML's [db] section header is readable here but becomes verbose
when nesting grows deeper (see example below). DMS indentation
scales linearly. The trade:
[[servers]]
name = "web1"
[[servers.disks]]
mount = "/"
[[servers.disks]]
mount = "/var"
[[servers]]
name = "web2"
Every nested record repeats servers.disks. Every new server
repeats servers. The [[]] array-of-tables-header doubles
the same bracket because there was no other character available in
ASCII. No block comments. No heredocs beyond limited multi-line
basic / literal strings. No array root, no scalar root — every doc
must be a top-level table.
INI
INI predates "config file" as a category. Windows shipped it before anyone codified it, and there's still no standard — just thirty years of implementations that disagree.
Side-by-side:
; Service config — bumped after LB change
name = api-server
version = 1.4.2
host = 0.0.0.0
port = 8080
[db]
host = primary.example.com
port = 5432
pool_size = 20
; features and started_at omitted — no list type, no datetime type
; front-matter concept does not exist in INI
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
INI is already losing data: no list type (features), no datetime type (started_at), no standard comment character, every value is a string. The thirty-year-old disagreements:
[database]
host = db.internal
port = 5432
description = "a value with spaces"
debug = true
[servers.web1] ; or is it [servers] then [web1]?
ipv4 = 10.0.0.1
Comments: ; or #. Pick one. Or both. Depends.
Some parsers accept ;, some #, some both, some only at the
start of a line. Inline comments? Trailing-whitespace handling?
Comment a section header? Roll the dice — every parser has its
own answer and none of them has a spec to point at.
Types: every value is a string
port = 5432 is the string "5432". debug = true is the string
"true". Some parsers will helpfully convert enabled = no to a
boolean false, because they remembered being YAML when they grew
up. Others won't. Either way, the file itself doesn't say.
Nesting: not really
Want a nested structure? Some parsers split [a.b.c] into a tree.
Others leave the literal section name a.b.c. A few support
[a] then [a.b] parent/child semantics. Most don't. There is
no portable way to describe two levels of hierarchy in INI.
Multi-line values: a fistfight
Line continuation with \? Triple-quoted heredocs? Repeated keys
appended together? Indentation-as-continuation? Different parsers,
different answers, never a spec. Whatever you write, somebody else's
loader silently does something different with it.
INI is a folk format. Every project that uses it ships a private dialect and pretends it's standard. There is no winning here, only varying degrees of compatibility damage.
XML
XML is a markup language. Someone, somewhere, decided to use it as a config format. Then everyone copied them.
Side-by-side:
<!-- Service config — bumped after LB change -->
<service name="api-server" version="1.4.2">
<host>0.0.0.0</host>
<port>8080</port><!-- standard internal port -->
<features>
<feature>http2</feature>
<feature>tls</feature>
</features>
<db host="primary.example.com" port="5432" pool_size="20"/>
<started_at>2026-04-12T08:30:00Z</started_at>
</service>
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
XML forces an immediate design decision per field: attribute or
child element? db used attributes above; host and port used
child elements — both are legal, neither is canonical:
<config>
<database host="db.internal" port="5432">
<pool size="10"/>
</database>
<servers>
<server name="web1" ipv4="10.0.0.1"/>
<server name="web2" ipv4="10.0.0.2"/>
</servers>
</config>
That's a small XML doc and you've already made one decision per field: attribute or child element? Both work. Neither is canonical. Two authors, two schemas, two parsers that disagree on what your config even is.
Verbose past parody
Every value is wrapped in an open tag and a matching close tag. A ten-key config is twenty lines of brackets and slashes. The data is a minority population in its own file.
Schemas as a job description
XML has DTD. And XSD. And RELAX NG. And Schematron. Four ways to write a schema, each with its own ecosystem, none of which ships in your language's stdlib. Pick one and you've made a career choice.
Attacks built into the spec
<, >, &, plus user-defined entities. Define an
entity recursively and you have billion laughs — a 1KB file
that expands to a gigabyte and kills your parser. Reference an
external file and you have XXE — the parser will fetch
arbitrary URLs while reading your config. Two CVE classes baked
into the data format. Every modern XML library ships with these
features disabled by default, and somebody still gets bitten every
year.
Namespaces are a sub-language
<config xmlns:db="http://example.org/db"
xmlns:srv="http://example.org/server">
<db:host>db.internal</db:host>
<srv:name>web1</srv:name>
</config>
URLs in your config file. Prefix bindings that are scoped, inherited, and overridable mid-document. XPath queries that depend on which namespace you're standing in. A whole second axis of complexity, riding along on top of the markup.
XML works for documents — that's what it was designed for. As a config format it's overhead pretending to be structure.
DMS tier 1's dms+html covers element-shaped data — |tag(class:..., id:...) calls produce HTML/XML-equivalent shape with comment round-trip and typed attributes.
BSON
MongoDB invented BSON to make JSON faster to parse on the wire. Fine goal. Then somewhere along the way people started asking: "can I configure things with this?" No. You cannot.
Side-by-side:
// BSON is binary — shown here as its JSON projection (mongosh Extended JSON)
{
"name": "api-server",
"version": "1.4.2",
"host": "0.0.0.0",
"port": { "$numberInt": "8080" },
"features": ["http2", "tls"],
"db": {
"host": "primary.example.com",
"port": { "$numberInt": "5432" },
"pool_size": { "$numberInt": "20" }
},
"started_at": { "$date": { "$numberLong": "1744446600000" } }
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
The JSON projection above is what tooling shows you; the wire bytes look like this:
It's binary. You cannot read it.
\x1e\x00\x00\x00\x02name\x00\x05\x00\x00\x00web1\x00...
That's a tiny BSON document. A human being wrote this once. Nobody has
read it since. Your text editor can't even show you most of it without
throwing up ^@ everywhere. If you're considering BSON as a config
format you have already lost.
Bigger than the JSON it "improves"
BSON pays type-info overhead on every field — length prefixes, type bytes, null terminators — so for small documents it is larger than the JSON string it replaces. "Efficient for machines" that generate five-line configs. Right.
It has a JavaScript type. Your config can contain executable code.
BSON defines a Code type that encodes a JavaScript string plus an
optional scope. A configuration format with a code type. Ship a
config, run a function. What could go wrong.
A spec that changes under you
BSON's Symbol type: deprecated. Undefined: deprecated. DBPointer:
deprecated. Code_w_scope: deprecated. Four types in, four types out.
Whatever parser decides to keep reading them becomes a liability.
BSON is a wire format for one database. It has no business being near your configs.
StrictYAML
StrictYAML's pitch is honest: YAML is a mess, here's YAML with the worst parts removed. Gone: flow style, implicit typing, anchors and aliases, merge keys, duplicate keys, the Norway Problem. Good list. What's left is the problem.
Side-by-side:
# Service config — bumped after LB change
name: api-server
version: 1.4.2
host: 0.0.0.0
port: "8080" # quoted — without schema, this is the string "8080"
features:
- http2
- tls
db:
host: primary.example.com
port: "5432"
pool_size: "20"
started_at: "2026-04-12T08:30:00Z"
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
In StrictYAML, port: 8080 is the string "8080" — integer
only if you load against a Python schema that declares Int().
DMS integers are integers in the file; no external schema needed.
The types live in Python, not in the config
port: 5432
debug: true
In StrictYAML, port is the string "5432". debug is the string
"true". To get an int or a bool back, you must load the file against
a schema — a schema defined in Python code — that says "this key is an
Int(), that one is a Bool()".
Open a StrictYAML file in isolation and you cannot tell what anything is. The types are spec'd in another file, in another language, in another repo.
Python-only, by design
StrictYAML the library is Python. Any "StrictYAML" in Go, Rust, or Node is a best-effort approximation that may or may not disable the same features. "Strict" refers to one library's behavior, because there is only one library.
It's still YAML underneath
The features StrictYAML removes are real wins. Everything else is the rest of YAML's 85-page spec: block scalars with nine modifiers, indentation rules that change inside block context, tab-vs-space parse errors, trailing-newline gotchas. You still need a YAML parser to read a StrictYAML file — you just trust it not to use half its features.
A subset isn't a replacement
StrictYAML is an admission: "YAML shouldn't do most of what YAML does."
Fair. But the fix is a Python library that opts out of features the
spec still defines. Every other YAML parser in every other language
still ships the full behavior. Your StrictYAML file is one
yaml.safe_load() away from Norway again.
RON
RON ("Rusty Object Notation") is a config format that reads like Rust source code, because it was designed for Rust people serializing Rust types. If you are not a Rust person, you are going to have questions.
Side-by-side:
// Service config — bumped after LB change
Service(
name: "api-server",
version: "1.4.2",
host: "0.0.0.0",
port: 8080, // standard internal port
features: ["http2", "tls"],
db: Db(
host: "primary.example.com",
port: 5432,
pool_size: 20,
),
started_at: "2026-04-12T08:30:00Z",
)
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
RON requires Rust struct names (Service(...), Db(...)); rename a
Rust type and every config file breaks. started_at is a string —
RON has no native datetime type.
Why does my config have Some(...) in it?
Scene(
materials: {
"metal": Material(
reflectivity: 1.0,
albedo: Some((255, 255, 255)),
),
},
entities: [
Entity(
name: "sphere",
position: (0.0, 0.0, 0.0),
),
],
)
Some(...). Scene(...). Material(...). Entity(...). Your config
file is full of constructors. If you don't know what an
Option<T> is, RON is going to teach you — and then you're going to
wonder why a config format made you learn Rust's type system.
Parentheses everywhere
RON uses (...) for structs instead of {...}, and tuples are
(a, b) unnamed. So a single file has [...] for lists, {...} for
maps, (...) for structs, (...) for tuples. Same punctuation, four
meanings, context-dependent. Your brain works it out. Eventually.
Enum variants leak into the data
color: Rgb(255, 0, 0),
That's not "color equals some data." That's "color is an enum
variant named Rgb with three fields." RON forces you to spell the
variant tag in the config, because that's how Rust's #[derive(Deserialize)]
expects to see it. Change the Rust code, change every config file.
Fine for Rust. Awkward for anyone else.
If your whole team speaks Rust, RON's fine — it round-trips cleanly to
serde. The second a non-Rust tool or human touches a RON file,
they're typing parens they don't understand and deleting Some
wrappers on a whim. It's a serialization format for Rust projects
dressing up as a general-purpose data syntax.
DMS tier 1's dms+ron covers tagged ADT data — variants and named structs in DMS shape with comment preservation.
HCL
HashiCorp built HCL to configure Terraform. Fine goal — and at first glance it reads like TOML had a fling with JSON.
Side-by-side:
# Service config — bumped after LB change
service "api-server" {
version = "1.4.2"
host = "0.0.0.0"
port = 8080 # standard internal port
features = ["http2", "tls"]
started_at = "2026-04-12T08:30:00Z"
db {
host = "primary.example.com"
port = 5432
pool_size = 20
}
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
HCL's block label (service "api-server") mixes block type and
identity — idiomatic for Terraform resources but awkward for plain
configs. started_at is a string (no native datetime). And then
the rest of HCL falls out of the ceiling:
resource "aws_instance" "web" {
ami = "ami-abc123"
instance_type = "t2.micro"
}
Then you write anything non-trivial and the rest of HCL falls out of the ceiling.
Interpolation is a sub-language
ami = "ami-${var.region == "us-east-1" ? "abc123" : "def456"}"
tags = {
Name = "web-${count.index + 1}"
Env = upper(var.env)
}
Ternaries, function calls, variable references, string templates, arithmetic — all inside your config file. You are no longer writing data. You are writing expressions that a runtime evaluates before it sees the data it was allegedly configured with.
for, dynamic, splat, template directives
HCL has list comprehensions ([for s in var.list: upper(s)]),
meta-blocks that generate other blocks
(dynamic "ingress" { for_each = ... }), splat operators
(aws_instance.web[*].id), and full template syntax with %{for}
and %{if} directives inside heredocs. Your data syntax has
a standard library. Your config has try() and can() for when
the evaluator might throw.
Two syntaxes, one name
Every HCL file can also be written as JSON — same data model, different literal syntax. Two mental models to maintain in the same ecosystem, two sets of tooling to keep in sync. Pick the wrong one and half your editor plugins stop working halfway down the file.
Block vs attribute: looks the same, isn't
settings = { # attribute whose value is an object
timeout = 30
}
settings { # a block named "settings"
timeout = 30
}
One =, one missing =, otherwise identical. Different parse,
different access paths, different override rules, different
error messages. Beginners write x {} when they mean x = {} and
spend an afternoon figuring out why the schema doesn't match.
It's not really HCL — it's "whatever Terraform v1.x accepts"
HCL has a spec. The version that matters is the one your tool
implements. Terraform, Packer, Nomad, Waypoint, Vault each bring
their own functions, their own block types, their own for_each
semantics. "HCL" in practice is "please read the Terraform
release notes for the current version."
It solved the wrong problem
HCL wanted to fix JSON's unreadability by adding syntax sugar.
Then it kept adding — variables, functions, conditionals, loops —
until it became a half-language tightly coupled to a cloud
provisioner. It's a programming language dressed as a config
format, dressed as a provisioning DSL. Three disguises, one
file, and you still have to run terraform plan to find out
what it actually says.
DMS tier 1's dms+hcl covers block-shaped data — @resource("aws_vpc", "main")(...) calls and @expr(...) opaque expressions. DMS still doesn't do HCL-style computation; the dialect expresses HCL's shape, not its evaluator.
KDL
KDL ("KDL Document Language", at kdl.dev) is the
most thoughtful of the recent crop. It studied JSON and XML both,
copied the good parts, the spec is short, the grammar is
unambiguous. Block comments work. Raw strings work. /-
(slashdash) lets you comment out a node, value, or property in
place — genuinely clever. Comments round-trip through kdl-rs.
Nothing here is broken.
The catch is readability. KDL gets called out as clean because
the snippets in the spec docs are short and look like Cargo
manifests. Real configs aren't always that shape, and on most of
them KDL is harder to scan than indent-based or key = value
formats.
Side-by-side:
// Service config — bumped after LB change
service "api-server" version="1.4.2" {
host "0.0.0.0"
port 8080 // standard internal port
features "http2" "tls"
db host="primary.example.com" port=5432 pool_size=20
started_at (date-time)"2026-04-12T08:30:00Z"
}
# Service config — bumped after LB change
+++
name: "api-server"
version: "1.4.2"
+++
host: "0.0.0.0"
port: 8080 # standard internal port
features: ["http2", "tls"]
db:
host: "primary.example.com"
port: 5432
pool_size: 20
started_at: 2026-04-12T08:30:00Z
KDL's db line mixes positional-arg-as-node-name, and named
properties — the reader must re-categorize each token. features
uses multiple positional args as a list-by-convention (no array
primitive). Type annotation (date-time) is advisory; parsers
need not enforce it.
package "my-app" version="1.0.0" {
author "Ada Lovelace" email="ada@example.com"
dependencies {
log "0.4" features="std"
serde "1.0"
}
}
A single line interleaves several lexical categories — bareword node name, quoted positional arg, quoted property — and your eye has to re-categorize each token. Compare the same shape in DMS:
package:
name: "my-app"
version: "1.0.0"
author:
name: "Ada Lovelace"
email: "ada@example.com"
dependencies:
log: "0.4"
serde: "1.0"
One rule per line — key: value. No positional-vs-property
distinction to track, no closing-brace boilerplate, every value is
in the same column-slot. KDL trades that density for its
node-args-props expressiveness; whether the trade is worth it
depends on how config-shaped your data is, and configs that lean
record-shaped (most of them) pay full price for capability they
don't use.
Visual noise stacks up fast
Per line, KDL can ask the reader to parse all of:
- bareword — node name, sometimes a property key
- quoted positional —
"my-app" - quoted property —
version="1.0.0" - type annotation —
(u8)10,(date)"2026-04-29" - raw string —
#"..."#, borrowed from Rust
Plus the closing } lines. Plus the v2 sigils (#null /
#true / #false). KDL's grammar is unambiguous about all of
this, but every line forces a multi-channel parse from the
reader, not the single-axis key:value scan that JSON / TOML / DMS
/ YAML give you.
The mismatch is shape, not quality.
Three types compose. Four slots don't.
DMS picks a small closed algebra: scalar, list, map. That's it. Every generic tool — validators, diffs, patches, schema checkers, templating engines, pretty-printers — works on any DMS document without knowing what it means. They operate on the algebra.
KDL nodes carry four channels: name, positional args, named properties, children. All optional, all reinterpreted per consumer. Generic tooling can't compose across them because every app's "what does this slot mean" answer is different — each integration writes its own micro-decoder.
A closed algebra of three primitives is a uniform shape every tool can target. An open-ended four-slot node isn't.
The data model has three orthogonal slots per line
package "my-app" version="1.0.0" is a node named package
with one positional argument ("my-app"), one named property
(version=...), and a block of children. Every KDL node carries:
- a name
- zero or more positional arguments
- zero or more named properties
- zero or one block of child nodes
That's not a key-value pair. It's roughly an XML element, with positional args playing the role of text content and properties playing the role of attributes.
The data is buried
KDL has no value-column. The value your eye is hunting for isn't in a predictable place on the line:
package "my-app" version="1.0.0" url="https://example.com"
Where's the package name? Second token, positional — nothing
labels it as the name. The version? After version=. The URL?
After url=. To extract any of them you read the node name,
recall the schema (which positional means what, which property
exists), then scan for the right slot.
In key-value formats the data is always on the right of the colon, in the same column on every line:
package:
name: "my-app"
version: "1.0.0"
url: "https://example.com"
[package]
name = "my-app"
version = "1.0.0"
url = "https://example.com"
Your eye trains itself once: skip the key, look at the value column. The data is front and center — a single column-slot on every line, every file, every format in this shape. KDL interleaves four channels per line (name, positionals, properties, children) and the value you care about can be in any of them depending on the schema. The grammar is unambiguous; the reading flow isn't. Every token gets re-categorized before you can extract a value.
Diffs feel it too. version="1.0.0" → version="1.0.1" puts the
changed token mid-line, surrounded by other channels. The
key-value form is just the value-half of the line changing —
your eye lands on the diff without scanning past structural
ceremony.
The same is true for generic tooling: a JSON-pointer-style or
JMESPath-style accessor over Map / List / Scalar lands on the
value directly. Over a KDL node tree, "the value" first requires
deciding which slot to read, per node, per consumer.
Tier 1 keeps the data in the column
The strongest argument for KDL's shape is element-shaped data — HTML, build pipelines, server-block configs, anything that wants a tag plus attributes plus mixed content. That's the territory KDL was designed for, and the territory most key-value formats give up on. DMS tier 1 (see TIER1.md) covers the same ground via decoration, and the data stays in the value-column the whole time.
Same HTML, side by side. KDL:
html lang="en" {
head {
title "DMS feature tour"
meta charset="UTF-8"
}
body class="main" id="root" {
h1 "Welcome to DMS"
p class="lede" {
- "Click "
a href="/spec.html" "here"
- " to read the spec."
}
}
}
DMS tier 1 with dms+html:
+ |html(lang: "en")
+ |head
+ |title "DMS feature tour"
+ |meta(charset: "UTF-8")
+ |body(class: "main", id: "root")
+ |h1 "Welcome to DMS"
+ |p(class: "lede")
+ "Click "
+ |a(href: "/spec.html") "here"
+ " to read the spec."
Both express the same document. The reading surface differs:
- In DMS, the elemental information (
|tag, attributes) sits behind a sigil. Your eye learns to skip the|...(...)prefix when scanning for content, or focus on it when scanning for structure. The actual values —"DMS feature tour","Welcome to DMS","Click "— are still in the value-column, at the right of+, where DMS values always live. - In KDL, the elemental information is inline with the data.
No sigil, no separation.
title "DMS feature tour"runs the bareword node name, the positional argument, and (sometimes) a property list together on one line. To extract the title text, you read pasttitle(which could mean anything until you consult the schema) into the second token.
Decoration is a sidecar, not a channel. Tier-1 lite mode
strips the | decoration entirely and hands back the pure value
tree:
[[[["DMS feature tour"]], [["Welcome to DMS"],
["Click ", ["here"], " to read the spec."]]]]
Every generic tool that works over JSON / TOML / tier-0 DMS works
over this output. Path accessors, schema validators, diff
viewers, templating engines — all get a clean Map / List /
Scalar tree without writing a per-format walker.
KDL has no equivalent strip-down because the elemental information is the tree. Strip the node names and properties and there's nothing left. The two pieces — structural shape and data — share one channel; you can't separate them after the fact. DMS tier 1 keeps them parallel by construction.
Diffs stay column-aligned. Change a content string in DMS and only the value-column changes:
- + |h1 "Welcome to DMS"
+ + |h1 "Welcome to dms"
Change an attribute and only the param group changes:
- + |html(lang: "en")
+ + |html(lang: "es")
KDL's per-line interleaving means a small change can land
anywhere. The grammar treats lang="en" as a property between
the node name and the brace; the diff tool doesn't help your eye
find it any faster. DMS's sigil-separated decoration lets the
diff reader skip directly to either the structure half or the
data half of any line.
The summary: tier 1 covers KDL's element-shaped territory without paying KDL's price. The elemental information lives in a parallel sidecar, sigil-separated at source, dropped wholesale in lite mode. The data stays where DMS data always is — to the right of the colon, at the value-column, front and center.
The spec defines no canonical mapping to a dictionary
"The spec does not define a formal data model section or canonical JSON mapping... implementations are free to interpret type annotations and structure as needed. No JSON mapping is defined." — KDL v2 spec
What every KDL parser actually hands you is the raw node tree:
[
{
"name": "package",
"args": ["my-app"],
"props": {"version": "1.0.0"},
"children": [...]
}
]
To get cfg["package"]["version"] from there, you pick a strategy:
- Walk the tree by hand — write the recursion yourself.
- Schema decode —
knuffel/kdl-script/serde-kdlwith#[derive]annotations specifying which field is an arg vs property vs child. - Adopt a profile — KDL has two official microsyntaxes, JiK (JSON-in-KDL) and XiK (XML-in-KDL), that specify forward encodings. To round-trip cleanly via JiK, your file has to be written in the JiK subset to begin with — at which point it no longer looks like the natural KDL above.
JSON, YAML, TOML, and DMS make the decision in the spec: parse
output is Map / List / Scalar. KDL's spec defines it as
Vec<Node> and stops. The work of going from a node tree to a
dict is a layer of code per consumer that the others don't ask
for.
Braces, not indent
servers {
web1 {
ipv4 "10.0.0.1"
}
}
servers:
web1:
ipv4: "10.0.0.1"
Pick your preference — neither is wrong. The trade-off: { }
makes copy-paste between files reliable at the cost of a closing
line per nested level. DMS goes the indent route: less punctuation,
indent does the work, one sibling-match rule covers it.
Two string flavours, no pipeline
KDL has basic strings ("...") and raw strings (#"..."#,
borrowed from Rust). Both can span lines. That's the menu. There
are no labels for content that contains your closing quote, no
fold_paragraphs() for prose, no trailing_newlines(0) for SQL.
For multi-line content where you care about exactly which newlines
survive, you escape and count.
KDL 1.0 vs KDL 2.0
KDL 2.0 (2024) made breaking changes — null / true / false
became #null / #true / #false, raw strings moved from
r"..." to #"...", number literals shifted. KDL 1.0 files don't
parse as KDL 2.0 and vice versa, so every consumer asks "which
version?" first. DMS shipped one grammar; thirteen ports hold
4695 / 4695 conformance, in lock-step.
Type annotations are a second channel
node (u8)10 (date)"2026-04-29"
KDL lets you tag any value with a type hint in parens. The spec
defines a standard set (u8, i32, date, regex, …) but the
parser doesn't enforce them — they're advisory. You get the value
plus an annotation, and your decoder decides whether to honour it.
DMS's type system is the syntax: dates, integers, hex / binary
/ octal / floats, quoted-vs-bare strings — what you write is what
you get, no second channel.
The document is always a list of nodes
A KDL document, by spec, is a sequence of nodes. You can't have a KDL doc whose root is a single scalar, or a single map with no wrapping node. Want a list of strings at the root? Wrap each in a node. Want a single integer? Wrap it in a node. DMS lets the document be the value:
42
+ "apples"
+ "oranges"
Both are valid DMS docs. The shape of the data is the shape of the file.
Where KDL fits
KDL's shape is great for configs that read like declarations with arguments — Cargo manifests, build pipelines, server-block configs, CLI grammars. Once your config looks more like a record (a tree of named values, no positional args), KDL's node-shape starts asking questions JSON / TOML / DMS don't ask, and your decoder ends up paying for them.
DMS picks the value-shape and stays there.
DMS tier 1's dms+kdl covers node-shaped data — KDL's node arg key=val lines map to |node("name", arg, key: val) decorator calls.
Distinctive features each format brings
Every format above has strengths DMS intentionally gives up. This section names them directly — one bullet list per format, focused on features that format offers and most or all others do not.
KDL
- Slashdash comments (
/-): inline-comment a single node, property, value, or argument without deleting it —node /-"skip-me" keep="yes". No other text format on this list has this mechanism. - Type annotations on values:
(u8)100,(date)"2026-04-12". Embedded type hints the parser can optionally validate against. TOML and JSON have no in-language equivalent. - Mixed positional + keyed args on one line:
font "Arial" weight=700 size=12. HCL has block labels (positional) but separates them from named attributes via block syntax; KDL puts both on the node header. - Children blocks with explicit braces: indent-free nesting that is copy-pasteable across editors and whitespace tools without risk of de-indentation errors.
HCL
- First-class expressions and interpolation:
${var.name}, function calls (upper(),try(),can()), ternary expressions. Computation lives in-format, not just data. - Heredoc with indent strip (
<<-EOT): indented heredoc strips leading whitespace, letting source nest naturally inside block structure. - Block labels:
resource "aws_vpc" "main" { ... }puts positional identifiers on the block header — the standard IaC vocabulary for naming resources. forandifexpressions: dynamic block and list construction ([for s in var.list: upper(s)]) evaluated at parse/plan time.
YAML
- Anchors and aliases (
&/*): in-document references for DRY config. The single most-cited reason teams pick YAML over alternatives. - Merge keys (
<<:): explicit map-merge semantics for layered config overrides. - Folded vs literal scalars (
>vs|): two distinct multi-line string behaviours in one syntax — fold paragraphs or preserve newlines verbatim. - Document streams (
---separators): one file, multiple documents, native to the format. - Explicit type tags (
!!str,!!int, custom!Foo): override YAML's default type inference with an explicit tag per value.
TOML
- Arrays of tables (
[[products]]): repeated section headers expand to a list of records — concise for config arrays without inline syntax. - Strict datetime types: RFC 3339 offset datetimes, local datetimes, local dates, and local times are first-class typed primitives in the spec.
- Hex / octal / binary integer literals:
0xFF,0o77,0b1010are in spec, not implementation extensions. - Dotted flat-shorthand keys:
a.b.c = 1expands to nested structure inline, without a separate section header.
JSON5
- ECMAScript-compatible identifiers as keys: unquoted keys that follow JS identifier rules — reads like a JS object literal.
- Leading-decimal floats:
.5is valid; JSON forbids the leading dot. - Signed Infinity / NaN:
Infinity,-Infinity,NaN— explicit in spec, not implementation-specific. - Line-continuation strings: backslash-newline inside a string allows wrapping long values without explicit concatenation.
HJSON
- Quoteless strings: top-level values can omit quotes when unambiguous — lowest-noise format on this list for hand-authored configs.
- Triple-quoted multiline strings (
'''): heredoc without delimiter labels; just open and close with three single quotes. - Punctuation-light syntax: optional commas between entries, no strict brace requirement at root.
RON
- Tuple structs:
(1, 2, 3)distinct from list[1, 2, 3]— matches Rust's tuple-vs-Vec semantics in the file. - Named struct variants:
Color::RGB(255, 0, 0)— Rust enum variants in textual form, round-tripping cleanly throughserde. - Explicit
Some(x)/None:Option<T>from Rust's type system appears directly in config, making optional fields unambiguous.
XML
- Mixed content: text and child elements interleaved —
<p>This is <b>bold</b>.</p>. Every other format on this list forces a choice between text content and child nodes; XML uniquely allows both on the same element. - Namespaces:
xmlns:db="..."lets multiple vocabularies coexist in one document with unambiguous disambiguation. - Processing instructions:
<?xml-stylesheet ...?>for tooling hooks inside the document. - Schema ecosystem: DTD, XSD, RELAX NG, Schematron — four mature schema languages, each with production tooling.
JSON
- Universal: every language's standard library parses it; every wire protocol speaks it.
- Stable: RFC 8259 hasn't changed in 14 years.
- Tiny grammar: the simplest spec on this list — roughly four productions.
INI
- Drop-dead simple: section headers plus
key=valuelines. Every shell scripter can hand-author it without reading a spec. - Universally readable by humans regardless of programming background — the lowest cognitive barrier of any format here.
BSON
- Binary length prefixes: enables random-access into nested documents without parsing predecessors.
- Native datetime + ObjectID + Decimal128 types: server-side type fidelity for MongoDB that JSON cannot provide.
- Streaming-friendly: each document is self-delimiting, enabling efficient framing in wire protocols.
StrictYAML
- No implicit casting:
NOstays a string,5stays a string until a schema explicitly says otherwise. Eliminates the Norway-class bugs by construction. - Mandatory schema: parse fails without one. Forces design discipline — the types must be declared, not guessed.
DMS picks typed primitives + indent-only nesting + comment round-trip as its load-bearing differentiators and lets the rest go. Where the shape of an external format matters (HTML elements, HCL blocks, KDL nodes, RON ADTs, k8s manifests), tier-1 dialects (html, hcl, kdl, ron, k8s) carry that shape on top of the tier-0 grammar without baking the feature into the base format. References, anchors, in-format expressions, schemas-in-spec, and document streams stay outside DMS by design.