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:

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

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

&lt;, &gt;, &amp;, 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:

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:

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:

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:

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

HCL

YAML

TOML

JSON5

HJSON

RON

XML

JSON

INI

BSON

StrictYAML


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.