How to Actually Choose a Config File Format: YAML, JSON, TOML, INI, or XML
Every project eventually grows a config file, and almost nobody chooses its format on purpose. You copy whatever the last project used, or whatever the framework's quickstart showed, or whatever your editor highlights the nicest. That's usually fine, right up until someone adds a key with the wrong indentation, a deploy reads NO as false, or the person who wrote the 400-line JSON settings file leaves and nobody can tell what any of it was for, because JSON doesn't let you write comments.
The format you pick sets the failure modes your users will hit and the tooling you get for free. It also decides how much your config fights the ecosystem around it. None of the common options are bad. Each was built for a specific job, and things go wrong when it gets used for a different one.
Here's a practical rundown of the five formats you'll actually run into, what each one is good at, and how to pick one based on the language you're writing in and who's going to be editing the file.
The short version
If you only read one section:
- Building in Rust or Python? Use TOML. Your ecosystem has already decided.
- Building in JavaScript/TypeScript? Use JSON for plain data, or a
config.tsfile if you need types and logic. - Building in Go? YAML if your users live in the cloud-native world, TOML if they're humans editing a file by hand, JSON if you want zero dependencies.
- Deploying to Kubernetes or writing CI pipelines? You're writing YAML whether you like it or not. Learn its sharp edges.
- Starting fresh with no ecosystem pressure? TOML is the safest default for a file people will edit by hand.
Read on for the reasoning.
YAML (.yaml / .yml)
YAML Ain't Markup Language (it originally stood for "Yet Another Markup Language," before the authors decided they'd rather it didn't).
YAML is the lingua franca of DevOps. Kubernetes manifests, Docker Compose files, GitHub Actions, GitLab CI, Ansible playbooks, Helm charts, and Prometheus configs are all YAML. If you work anywhere near infrastructure, you read and write it every day.
# Server configuration
server:
host: localhost
port: 8080
features:
- authentication
- logging
defaults: &defaults
timeout: 30s
retries: 3
production:
<<: *defaults
retries: 5Where it shines:
- Very little punctuation. Structure comes from indentation, so a well-written YAML file reads almost like an outline.
- Comments with
#, which matters more than people think for files that live for years. - Anchors (
&) and aliases (*) let you define a block once and reuse it, which cuts down on repetition in big configs. - YAML 1.2 is (nearly) a superset of JSON, so most JSON documents are also valid YAML. Handy when tools emit one and consume the other.
Honest caveats:
- Whitespace is syntax. Spaces only, never tabs, and one misplaced indent can silently change your data's structure instead of throwing an error. A list item indented one level too far becomes part of a different key, and the file still parses fine.
- Implicit typing will bite you. This is the famous "Norway problem": under YAML 1.1 rules, an unquoted
NO(Norway's country code) is the booleanfalse. So areoffandn. Version strings like1.10become the float1.1. Exactly what happens depends on your parser and which YAML version it implements, which is its own problem. The defensive habit is to quote any string that could possibly look like something else. - The spec is enormous. Multi-document streams, nine different ways to write a multiline string, tags, merge keys. Most people use 10% of it, and the other 90% is where the surprises come from.
- Anchors and merges are hard to trace. A heavily anchored file can make it hard to figure out what a given key actually resolves to without running it through a parser.
Reach for YAML when your users already live in the cloud-native ecosystem and expect it. Don't pick it for a small app config just because it looks clean.
JSON (.json)
JavaScript Object Notation.
JSON is everywhere because it's the default data interchange format of the web. package.json, tsconfig.json, VS Code's settings.json, and most REST API payloads are JSON. Every mainstream language can parse it, and many (JavaScript, Go, Python) do it from the standard library with no dependencies at all.
{
"server": {
"host": "localhost",
"port": 8080,
"features": ["authentication", "logging"]
}
}Where it shines:
- Zero ambiguity. The grammar fits on a business card. There's exactly one way to write a string, a number, or a boolean, so every parser agrees.
- Universal support.
JSON.parse()in JavaScript,encoding/jsonin Go,jsonin Python. No third-party library, no supply chain to think about. - Great for machine-written config. If a program generates or rewrites the file, JSON round-trips cleanly without losing formatting decisions a human cared about, because there aren't any to lose.
- JSON Schema gives you validation and editor autocomplete, and it's well supported in VS Code and most JetBrains IDEs.
Honest caveats:
- No comments. This is the big one for config files. You can't explain why a value is set the way it is, and you can't temporarily comment out a line. The community workarounds (
"_comment"keys, JSONC, JSON5) all prove how much people miss it. - No trailing commas. Add an item to the end of a list, forget to add the comma on the line above, and the whole file fails to parse. The error message often points at the wrong line.
- Mandatory quoting and braces make it noisy for humans to write by hand, especially once it's nested a few levels deep.
A note on JSONC: tsconfig.json and VS Code's settings files quietly accept comments and trailing commas. That's not standard JSON. It's "JSON with Comments," and a strict parser will reject those same files. If you go that route, be explicit about it in your docs.
Reach for JSON when the config is mostly machine-written, when you want zero parsing dependencies, or when it lives right next to a web API that already speaks JSON.
TOML (.toml)
Tom's Obvious, Minimal Language, named after Tom Preston-Werner, co-founder of GitHub.
TOML was designed specifically for config files, and it shows. It maps cleanly onto a hash table, has a real specification (1.0 since 2021), and settles somewhere between YAML's readability and JSON's strictness. It's the standard for Rust's Cargo.toml and Python's pyproject.toml, and it's Hugo's default site config format.
# Server configuration
[server]
host = "localhost"
port = 8080
features = ["authentication", "logging"]
started = 2026-10-24T08:00:00-06:00
[database]
url = "postgres://localhost/app"
max_connections = 20Where it shines:
- Obvious is the point.
key = valueunder[section]headers is readable by anyone who's ever seen an INI file, but strings are always quoted and types are always explicit, so there's no Norway problem. - Comments with
#. - Indentation isn't syntax. You can indent for readability, but misindenting doesn't change what the file means.
- Native datetime types, which neither JSON nor INI have. Useful for things like schedules, cutoffs, and expiry dates.
- Strong library support. First-class in Rust via
serde, in the Python standard library since 3.11 (tomllib), and well covered in Go byBurntSushi/tomlandpelletier/go-toml/v2.
Honest caveats:
- Deep nesting gets awkward. Once you're past two or three levels, you end up writing
[servers.alpha.tls.certs]headers or arrays of tables ([[servers]]), and the file gets harder to scan than the equivalent YAML. - Less ubiquitous than JSON. Most languages need a third-party library, and fewer generic tools (diffing, querying, linting) understand it out of the box.
- Not great for large, generated, or deeply structured data. That's not what it was built for, and it tells you so.
Reach for TOML when humans will edit the file by hand and the structure is mostly flat. Honestly, that describes most application config.
INI (.ini)
Initialization file, one of the oldest config formats still in daily use.
INI is the simplest thing that could possibly work: sections in brackets, key = value underneath. php.ini, a lot of Windows desktop software, and plenty of legacy tooling use it. INI-style syntax also shows up in places you might not think of as INI: .gitconfig, systemd unit files, and Python's setup.cfg all follow the same basic shape.
; Server configuration
[server]
host = localhost
port = 8080Where it shines:
- About as low a barrier to entry as config gets. Anyone can read and edit it without knowing anything about the format.
- Perfectly adequate for a small, flat set of settings.
Honest caveats:
- There's no formal standard. Whether comments start with
;or#, whether values get quoted, how arrays work, whether nested sections exist, and whether keys are case-sensitive all depend on which parser you use. Two "INI" libraries can disagree about the same file. - Everything is a string. Your code has to decide whether
port = 8080is a number and whetherenabled = yesis a boolean. - No real support for lists or nesting. Once you need either one, you've outgrown it.
Reach for INI when you're maintaining something that already uses it, or when your settings truly are a short, flat list. For anything new, TOML is basically INI with a spec and a type system, so use that instead.
XML (.xml)
Extensible Markup Language.
XML used to be the answer to everything, and it still holds on in enterprise Java (Maven's pom.xml, Spring's older configs), .NET (.csproj, web.config), Android manifests, and plenty of document-centric systems.
<configuration>
<!-- Server configuration -->
<server>
<host>localhost</host>
<port>8080</port>
<features>
<feature>authentication</feature>
<feature>logging</feature>
</features>
</server>
</configuration>Where it shines:
- Schema validation is a first-class idea. XSD lets you specify, enforce, and document exactly what a valid config looks like, and tooling can validate it before your app ever starts.
- Handles deeply hierarchical and mixed-content data (text with markup inside it) better than any format here.
- Mature tooling for transformation and querying (XSLT, XPath) if you need it.
Honest caveats:
- It's verbose. Every value gets an opening and a closing tag. The signal-to-noise ratio for a human reading the file is the worst of the five.
- Attributes vs. elements is a permanent design debate. Should it be
<server port="8080">or<server><port>8080</port></server>? Every project answers differently. - Security baggage. XML parsers have a long history of entity-expansion and external-entity (XXE) vulnerabilities. Modern parsers default to safer settings, but it's one more thing to check.
Reach for XML when the ecosystem you're in demands it, or when strict schema validation is a hard requirement. Don't choose it for a new project's config otherwise.
Side by side
| Format | Human Readability | Comments? | Strictness | Indentation-sensitive? | Best For |
|---|---|---|---|---|---|
| YAML | High | Yes | Moderate (implicit typing) | Yes | DevOps, Kubernetes, CI/CD |
| JSON | Medium | No | Very strict | No | Web APIs, machine-written config |
| TOML | High | Yes | Strict | No | Hand-edited app config, Rust/Python packaging |
| INI | High (when small) | Yes | Loose, no standard | No | Simple or legacy settings |
| XML | Low | Yes | Very strict (with schema) | No | Enterprise Java/.NET, schema-validated config |
Choosing by language
Language ecosystems have strong opinions here, and going along with them is almost always the right call. Your users already know the format, and your libraries already support it well.
Rust: TOML
There's no real debate here. Cargo uses Cargo.toml, so every Rust developer already reads TOML fluently, and serde with the toml crate turns a config file into a strongly typed struct in a handful of lines:
#[derive(serde::Deserialize)]
struct Config {
server: Server,
}
#[derive(serde::Deserialize)]
struct Server {
host: String,
port: u16,
features: Vec<String>,
}
let config: Config = toml::from_str(&std::fs::read_to_string("config.toml")?)?;serde_json is the obvious alternative when the config is tied to a web API, but for static configuration files, TOML is the default.
JavaScript / TypeScript: JSON, or just write TypeScript
JSON is native: JSON.parse() costs you nothing in dependencies, and the whole Node ecosystem already speaks it.
More and more, though, the modern answer is no static file at all. Vite, ESLint's flat config, Tailwind, Drizzle, and plenty of others now load a *.config.ts (or .js) that exports an object. You get full type checking, editor autocomplete, comments, and the ability to compute values at load time:
export default {
server: {
host: "localhost",
port: Number(process.env.PORT ?? 8080),
features: ["authentication", "logging"],
},
} satisfies AppConfig;The trade-off is that your config is now executable code. That's a real cost if non-developers need to edit it, or if you want to parse it safely from another language.
Go: it depends on who your users are
Go is the one language where the answer genuinely depends on context, so it's worth slowing down here.
- YAML is the social norm for anything cloud-native. Go is the language of Kubernetes, Docker, Terraform, and Prometheus, and people deploying Go services expect YAML config.
spf13/viper, the de facto config library, treats YAML as a first-class citizen. Worth knowing: the long-standinggopkg.in/yaml.v3package was archived by its original maintainer, and active development continues under the YAML organization atgo.yaml.in/yaml/v3. If you're starting a new project, import from there. - JSON comes with the standard library through
encoding/json, and as of Go 1.27 it runs on the faster v2 engine. For internal services, or anywhere you want zero third-party dependencies, it's a perfectly reasonable choice, as long as you can live without comments. - TOML is underrated in Go. Hugo uses it as its default config format, and
BurntSushi/tomlis small, stable, and battle-tested. If your tool is a CLI that people configure by hand in~/.config/yourtool/config.toml, TOML gives them comments and readability without YAML's indentation traps.
Whichever you choose, the Go pattern looks the same, which is part of why switching formats is cheap:
type Config struct {
Server struct {
Host string `yaml:"host" toml:"host" json:"host"`
Port int `yaml:"port" toml:"port" json:"port"`
Features []string `yaml:"features" toml:"features" json:"features"`
} `yaml:"server" toml:"server" json:"server"`
}A struct with tags for each format, decoded at startup, validated, and passed around as an immutable value. If you keep the config struct as the source of truth and treat the file format as a serialization detail, you can support more than one format later without rewriting anything.
Quick reference
| Language | Primary Choice | Why |
|---|---|---|
| Rust | TOML | Ecosystem standard (Cargo) and seamless serde integration |
| Python | TOML | pyproject.toml is the standard; tomllib is in the stdlib |
| JavaScript / TypeScript | JSON or .ts config | Native parsing, or full type safety and dynamic values |
| Go | YAML, TOML, or JSON | YAML for cloud-native users, TOML for hand-edited CLIs, JSON for zero dependencies |
| Java / .NET | XML (legacy) or YAML/JSON | Ecosystem tooling; Spring Boot and ASP.NET Core have largely moved on to YAML and JSON |
A few rules that matter more than the format
After enough years of debugging other people's config, I've found that the format matters less than a handful of habits:
- Validate at startup and fail loudly. Decode into a typed struct, check required fields and ranges, and refuse to start with a clear error message. A bad config that crashes immediately is a five-minute fix. A bad config that silently falls back to defaults is a 2 a.m. incident.
- Keep secrets out of the file. Config files end up in Git, in container images, and in support tickets. Pull credentials from environment variables or a secrets manager, and let the file reference them.
- Layer your sources in a predictable order. Defaults in code, then the config file, then environment variables, then command-line flags. Write the order down. Most config libraries (viper included) support this, and your users will reach for it whether you document it or not.
- Ship an example file with comments. A
config.example.tomlthat explains every option is worth more than any amount of external docs. This is the strongest single argument against JSON for hand-edited config. - Don't let config turn into a programming language. When you catch yourself adding conditionals, loops, or templating to a YAML file, stop. Either move that logic into code, or reach for a tool built for it (CUE, Pkl, HCL, or just a
config.ts) on purpose, instead of slowly reinventing one inside Helm templates.
Wrapping up
There's no universally correct config format, but there's almost always a correct one for your project. Start with what your ecosystem expects. Then ask who's editing the file: a human by hand, a program, or both. Then pick the format whose failure modes you can live with. YAML's surprises, JSON's lack of comments, TOML's clunky deep nesting, INI's missing standard, and XML's verbosity are all known quantities. Choosing one deliberately, knowing what it's going to cost you, beats inheriting one by accident every time.

