Configuration Languages
JSON, TOML, YAML, HCL, Jsonnet, Starlark, CUE, Dhall — a ladder from pure data to real computation. Every rung was reached by a format that started as data and was asked for one more feature, and the two that stopped deliberately are the interesting ones.
Which configuration format should I use, and why do they all seem to turn into programming languages?
A document that some engine gives meaning to. At the bottom of the ladder it is a tree of scalars, lists and maps — pure data, with the schema living outside the file. Higher up it is an expression tree with variables, functions and imports, evaluated to produce the tree. The rung a format sits on determines exactly one thing that matters: whether the file can be understood by reading it, or whether it must be evaluated to know what it says.
A configuration format may guarantee only what its evaluation model permits. A format with no computation can guarantee that reading the file is understanding it, that evaluation terminates, and that validation is decidable from the text. Adding variables costs the first; adding functions and imports costs the second and the third unless the language is deliberately total. A tool that promises to validate configuration statically is making a claim about the format's expressive power, and the claim becomes false the moment the format gains a construct whose value is not determined by the text.
Key points
- Configuration formats form a ladder from pure data to full computation, and every format moves up it over time.
- Each rung is reached by granting a reasonable request, and each request spends a guarantee that nobody explicitly decided to give up.
- The guarantees in order: local readability, static validation, terminating evaluation, self-contained meaning.
- Templated YAML is the end state reached by accident: a language with no parser, no types and no diagnostics.
- YAML's implicit typing produces the Norway problem and its siblings, and it is specified behaviour rather than a bug.
- Significant indentation turns a nesting mistake into a valid document with a different meaning and no error.
- Starlark, CUE and Dhall are the formats that stopped deliberately, each keeping a specific guarantee.
- If repetition is the problem, generating plain data from ordinary code beats making the format more powerful.
The ladder
Configuration formats form a progression, and the progression is one-directional: every format moves up it over time and none has ever moved down. It is worth being able to name the rungs, because the choice of format is really a choice of how far up you are willing to go.
Pure data. JSON and TOML. A document is a tree of scalars, lists and maps and nothing else. No variables, no references, no computation. What the file says is what the file says, and the only tooling you need is a parser and a schema validator. JSON has no comments, which is a real and much-complained-about omission; TOML has comments, unambiguous typed scalars and a syntax designed for humans to write.
Data plus references. YAML. Anchors and aliases let one part of a document refer to another, and merge keys let one map extend another. This is the first rung with a form of abstraction, and it is why a YAML document can no longer be understood entirely locally: what a key means may depend on an anchor defined a hundred lines away.
Data plus expressions. HCL, as used by Terraform. Values can be expressions: interpolation, arithmetic, conditionals, built-in functions, for comprehensions, and references to other resources. The file is now a program that evaluates to a configuration, and reading it does not tell you what it produces — which is exactly why terraform plan exists as a first-class command.
A real language. Jsonnet, Starlark, Nickel and friends. User-defined functions, imports, composition. These are programming languages that produce configuration, and they are honest about it.
A constrained language, deliberately. CUE, Dhall, and Starlark in its Bazel form. These have the abstraction but hold on to a guarantee: Dhall is total, so every program terminates; Starlark forbids recursion and unbounded loops and is deterministic; CUE has no general computation at all, expressing everything through unification of types and values in a lattice. They are what the top of the ladder looks like when somebody decided in advance where to stop.
| Format | Abstraction available | Guarantee kept | What it costs |
|---|---|---|---|
| JSON | None | The file is what it says; validation is decidable from the text | No comments, and repetition must be handled outside the format |
| TOML | None | Same, with comments and unambiguous typed scalars | Deep nesting is awkward; large structures read poorly |
| YAMLspec | Anchors, aliases, merge keys | Still no computation, so evaluation terminates | Non-local reading, significant indentation, and implicit typing hazards |
| HCL (Terraform)implementation | Expressions, conditionals, built-in functions, for-comprehensions, references | A plan can still be computed and shown before applying | The file must be evaluated to know what it produces |
| Jsonnet | Functions, imports, inheritance, laziness | Output is JSON, so consumers stay simple | A general-purpose language; a config bug is now a program bug |
| Starlarkimplementation | Functions, imports | Deterministic, no recursion, no unbounded loops, no I/O | Deliberately cannot express things a general language can |
| CUE | Unification of types and values, constraints, no general computation | Order-independent, deterministic, schema and data in one language | A genuinely unfamiliar model that people must learn |
| Dhall | Typed functions, imports with hashes | Total: every program terminates; imports are integrity-checked | Total means restricted, and the type system is a real learning cost |
Why everything climbs
while loops and has no I/O, so a build file is deterministic and hermetic and can be evaluated in parallel and cached — the restrictions exist to serve the build system, not for elegance. Dhall guarantees totality through its type system, so every expression terminates, and adds hash-pinned imports so a configuration cannot change underneath you. Neither is a general-purpose language and neither is trying to be.The mechanism is always the same and it is always reasonable at each step. Someone has forty nearly-identical service definitions and asks for a way to avoid repeating them: variables arrive. Someone needs a value to differ between staging and production: a conditional arrives. Someone needs the same block in twelve files: an import arrives. Someone needs to generate a list of ports: a loop arrives. Every one of those requests is legitimate and every one of them is granted for a good reason.
What is lost is lost quietly. Once there are variables, the file cannot be read locally. Once there are conditionals, it cannot be validated without evaluating it. Once there are imports, the meaning of a file depends on files it names. Once there are loops, evaluation can fail to terminate. At no point does anyone decide to build a programming language, and at the end there is one — with a hand-written parser, no debugger, no type system and error messages that report a line in a rendered intermediate nobody has.
The most-repeated instance is templating over YAML. A Helm chart is a Go template that produces YAML: the file is not valid YAML until it has been rendered, so no YAML tool can check it, no editor can complete it, and an indentation bug inside a conditional branch appears only for the configurations that take that branch. It has all the properties of a programming language and none of the properties of a format. This is the end state the ladder describes, reached without anyone choosing it.
Recognising the pattern is the practical value of this lesson. When a request arrives to add a conditional to a configuration format, the question is not whether the request is reasonable — it is — but which guarantee it spends, and whether that guarantee was the reason the format was chosen.
YAML, and what its hazards teach about syntax design
y, yes, on, n, no and off as booleans; the YAML 1.2 core schema narrows this to true and false, which fixes the Norway problem — but only where a 1.2 core-schema parser is used. PyYAML implements 1.1 by default and is used in an enormous amount of deployed tooling, so the hazard persists in practice long after the specification addressed it. Go's and Rust's common YAML libraries differ again. Never reason about YAML typing without knowing which parser will read the file.YAML is the most widely used and the most instructive, because its problems are not bugs; they are the consequences of two design decisions that looked like usability wins.
The first is implicit typing. YAML infers a scalar's type from its shape, which means unquoted tokens become booleans, numbers or nulls without the author asking. Under the YAML 1.1 schema — which many widely deployed parsers still implement — yes, no, on, off, y and n are booleans. That is the famous *Norway problem*: a list of country codes containing NO yields false where the author wrote a string. It has siblings: a version string 1.10 becomes the number 1.1 and loses its trailing zero, a MAC address or a time-like 12:30 can be read as a sexagesimal number under 1.1, and a zero-padded value like 08 is either an invalid octal or a different number depending on the schema.
The lesson is not "quote your strings", though you should. It is that *inferring meaning from surface shape is a hazard proportional to how many shapes are recognised*. Every additional implicit conversion is another way for an author's intent to be silently changed. TOML avoids the entire class by making scalar types unambiguous in the syntax; JSON avoids it by having almost no types at all.
The second decision is significant indentation, which makes documents readable and makes an error in a deeply nested structure produce a valid document with a different meaning. A block indented two spaces too few is silently a sibling rather than a child. There is no error, because the document is well-formed — it just says something else. Python has significant indentation too and does not have this problem nearly as badly, because a misindented Python program is usually a syntax error or an obvious behavioural change, whereas a misindented YAML document is a valid document nobody notices until deployment.
Both hazards get much worse under templating, where the indentation of a block depends on where it was inserted, and neither the template engine nor the YAML parser sees the whole picture.
1# The Norway problem: under the YAML 1.1 schema these are booleans.2countries: [NO, SE, DK] # -> [false, "SE", "DK"]3enabled: no # -> false, which happens to be what you meant4answer: y # -> true, which probably is not5 6# Implicit numeric conversion.7version: 1.10 # -> 1.1 (a float; the trailing zero is gone)8port: 08 # -> invalid octal under 1.1; 8 under some parsers9time: 12:30 # -> 750 as a sexagesimal integer under 1.110 11# Indentation: valid, and not what was written.12service:13 name: api14 env:15 LOG_LEVEL: debug16 replicas: 3 # child of service, as intended17service2:18 name: worker19 env:20 LOG_LEVEL: debug21 replicas: 3 # two extra spaces: now a member of env, not of service222 23# The fix is uniform and boring.24countries: ["NO", "SE", "DK"]25version: "1.10"Nothing here is a parser bug. Each line is the specified behaviour of some YAML schema, faithfully applied to text whose author meant something else. That is what makes implicit typing a design hazard rather than an implementation one.
Choosing, and where to draw the line
The useful decision is not which format but which rung, and the honest default is the lowest one that expresses the configuration. If the file is a set of declarations with no repetition problem, JSON or TOML with a schema is the right size and everything downstream is easier. If repetition is the problem, the first thing to try is not a more powerful format but generating the plain data from code in a language you already have — which puts the abstraction where a debugger, a type checker and a test framework already exist, and keeps the deployed artifact dumb.
That last point is the most useful heuristic in this lesson. Configuration that is *generated* can be as boring as you like, because nobody hand-writes it, and the generator is ordinary code with ordinary tooling. Configuration that is *authored* should be readable without evaluation. Trouble arrives when one file is both, which is what templated YAML is.
If abstraction genuinely must live in the format — because the authors are not the application's developers, or because the configuration is the product — then choose a format that stopped deliberately. CUE, Dhall and Starlark each kept a guarantee that a general-purpose language cannot offer, and the guarantee is the reason to pay their learning cost. Picking a Turing-complete configuration language instead gets you a programming language with a small ecosystem, which is strictly worse than using the programming language you already have.
- Prefer the lowest rung that expresses the configuration; every rung up costs a property.
- If repetition is the problem, generate plain data from ordinary code before making the format more powerful.
- Keep the authored artifact readable without evaluation, and let generated artifacts be as boring as possible.
- If abstraction must live in the format, pick one that stopped deliberately — the guarantee is what you are paying for.
- A Turing-complete configuration language is a programming language with a worse ecosystem than the one you already have.
How it works
The steps, in the order the compiler takes them.
- Write the three hardest real configurations as plain data and see whether anything is actually missing.
- If repetition is the only problem, generate the data from host code and keep the deployed artifact free of abstraction.
- If abstraction must be in the format, name the guarantee you need — determinism, termination, static validation, hermetic imports — and pick a format that keeps it.
- Validate against a schema at load time and fail loudly, so a configuration error is a startup failure rather than a run-time surprise.
- Pin the parser and the schema version, because YAML typing behaviour differs between parsers and between schema versions.
- Quote every string that could be read as a boolean, a number or a date, and prefer a format with unambiguous scalars where that discipline cannot be enforced.
- When a feature request arrives, name the guarantee it would spend and decide explicitly rather than incrementally.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A country code, a version string or a port silently changes type on load, and the wrong value reaches production with no error anywhere.
- A block indented two spaces too few becomes a sibling instead of a child; the document is valid, the deployment is wrong, and nothing reports it.
- A templated configuration is not valid in its own format until rendered, so no editor, linter or schema validator can check it and syntax errors reach the cluster.
- A configuration file cannot be understood without running the evaluator, so a reviewer approves a change whose effect they could not see.
- An import or an anchor changes underneath a file, and the deployed configuration differs from what the file appeared to say last week.
- Configuration logic accretes conditionals until it needs tests, and it has no test framework, no debugger and no types.
- The same document is read by two parsers with different schema defaults, and a value is a boolean in one service and a string in another.
When it helps
- Separating what changes per environment from the code, so a deployment change is not a code change.
- Making the deployed state reviewable as a diff, which is the entire argument for declarative infrastructure.
- Giving non-developers a way to change behaviour without touching or building the application.
- Enabling static validation of the deployed state before it reaches anything, which is only available on the lower rungs.
When it hurts
- When the configuration has grown logic and is now an untested, undebuggable program in a format designed for data.
- When it must be evaluated to be understood, so review cannot see what a change does.
- When implicit typing changes an author's value silently, which is invisible in review and only appears at run time.
- When the format's abstraction mechanism is used to avoid writing code, so the logic lives where none of the tooling is.
What it costs
Every one of these is paid by something.
- Pure data buys local readability, decidable validation and trivial tooling, and pays in repetition: every abstraction must happen outside the file, by generation or by a merge layer.
- Adding references and anchors buys deduplication and pays local readability — what a key means may now depend on text far away, or in another file.
- Adding expressions and conditionals buys per-environment variation in one file and pays static validation: the document's content is no longer determined by its text.
- Adding functions and imports buys real composition and pays termination and self-containedness, unless the language is deliberately total — which is what Dhall and Starlark bought back at the cost of expressiveness and a learning curve.
- Templating over a data format buys abstraction with no new language to learn and pays everything at once: the file is not valid in its own format, so no validator, editor or schema tool can see it.
What else you could do
What a different compiler or language does instead, and when that is better.
- Generate plain JSON or YAML from ordinary host code, so the abstraction lives where the type checker, debugger and test framework already are, and the artifact stays dumb.
- CUE, which unifies schema and data in one language with no general computation, so validation and configuration are the same operation.
- Dhall, which is total and hash-pins its imports, giving guaranteed termination and reproducible evaluation at the cost of a real type system to learn.
- Starlark, which is deterministic and hermetic by construction and is what Bazel uses precisely because a build must be cacheable and parallelisable.
- Environment variables plus a validated startup schema for the small cases, which is the lowest-ceremony option and stops working as soon as structure is needed — see
[[should-i-build-a-dsl]]for the same question in its general form.
See it for yourself
The flag, dump or tool that shows you this directly.
- Load a YAML file with the parser your service actually uses and print the resulting types.
python -c "import yaml,sys,json;print(json.dumps(yaml.safe_load(open(sys.argv[1])),default=str))"will show you which of your strings became booleans. - Compare parsers deliberately: the same file through PyYAML (1.1 defaults) and a 1.2 core-schema parser will disagree on
no,yes,onandoff. terraform planandhelm templateboth render the program to its output — reading that output is the only way to know what an expression-bearing configuration actually says.cue vet data.yaml schema.cuechecks data against a schema in a language where both are the same thing; it is the clearest demonstration of what unification buys.- For any templated configuration, render it and validate the result against the format's schema in CI. If that step does not exist, nothing is checking the file.
Plausible wrong readings
Stated the way a confident engineer states them.
- "YAML is broken." YAML behaves as specified. The specification chose implicit typing and significant indentation, and both choices have exactly the consequences they were always going to have.
- "Quoting strings fixes the Norway problem." It fixes each instance. The class remains, because the hazard is that surface shape determines type, and the next unquoted value will do it again.
- "YAML 1.2 fixed this." The core schema did, and most deployed parsers still default to 1.1 behaviour. What matters is which parser reads your file.
- "A configuration language should be as expressive as possible." Expressiveness is what costs you static validation, review-by-reading and terminating evaluation. The restricted ones are restricted deliberately.
- "Templating is simpler than adopting a configuration language." It is a language, with no parser for the pre-render text, no types and no diagnostics. It is the most expensive option, not the cheapest.
Misconceptions
The claim, and what is actually true.
NO became false before validation ran, because by then the value is a boolean and the schema is checking a boolean.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Configuration formats sit on a ladder. At the bottom are JSON and TOML: plain data, where the file says what it says. In the middle are YAML and HCL, which add references and expressions so a file can no longer be understood just by reading it. At the top are full languages that produce configuration. Every format tends to climb, because each new feature is asked for by someone with a real problem — and each one gives up something the format used to guarantee.
practical
Three habits. Quote anything in YAML that could look like a boolean, a number or a date, and know which parser your service actually uses, because the answer differs. Prefer generating plain data from ordinary code over making the format more powerful — the abstraction then lives where the debugger and the type checker are. And if a configuration file has to be rendered before it is valid in its own format, treat that as a design problem rather than a build step, because nothing is checking it: not your editor, not your linter, not your schema.
advanced
The ladder is the DSL feature-creep story from [[dsl]] playing out in public, and it is worth studying for that reason. Each rung is reached by a reasonable request and spends a guarantee: local readability, then static validation, then termination, then self-contained meaning. Nobody makes the decision to build a programming language, and one exists at the end. The two formats that held — Starlark and Dhall — did it by writing the guarantee down first and treating it as the product rather than as a constraint on the product, which meant every feature request had a stated position to be evaluated against instead of being relitigated. That is transferable well beyond configuration: the durable defence against feature creep in any restricted language is a written record of what the restrictions buy, because otherwise every individual request wins on its own merits and the aggregate loses.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
y, yes, on, n, no and off, while the 1.2 core schema accepts only true and false. PyYAML implements 1.1 by default and is embedded in a large amount of deployed tooling, so the Norway problem is alive in practice regardless of what the current specification says. Go and Rust YAML libraries make different choices again.while loops and has no I/O, which is what makes build files deterministic and cacheable. Other Starlark embeddings relax some of these. Similarly, HCL is a syntax that Terraform gives semantics to — statements about what HCL can express are usually statements about Terraform, and other HCL consumers differ.If you were asked this in an interview
- Why do configuration languages keep growing conditionals and loops, and what does each one cost?
- What is the Norway problem, and what does it teach about syntax design beyond YAML?
- When would you choose CUE or Dhall over YAML, and what are you paying for?
Connections
- DevOps / Production Engineering — Operating configuration in production: rollout, review, drift and rollbackThis lesson is about what a configuration format can express and guarantee. How a change to one is reviewed, staged, rolled out and reverted — and what an unreviewable rendered template does to an incident — is an operational subject that begins where the format design ends.