Understanding INI ↔ JSON
The config format with no specification.
Where INI came from, why every parser disagrees, and the rules of thumb that keep your conversion stable across them all.
No specification, many dialects.
INI is the format Microsoft introduced with Windows 3.x in 1990, copied from earlier Unix-world ad-hoc config files. There has never been a canonical specification. Every parser implements roughly the same idea — sections in square brackets, key=value pairs, comments — and disagrees on the edge cases. Python's configparser, PHP's parse_ini_file, Git's config syntax, systemd's unit files, and the npm registry's .npmrc are all "INI", and none of them parse each other cleanly.
The shape that's reliably common.
The conservative subset that nearly every parser agrees on: line-based, square-bracket section headers, key = value or key=value, hash-mark or semicolon line comments. Values are untyped strings; the parser doesn't know the difference between port = 80 and name = 80. Quoted strings let you include leading/trailing whitespace explicitly; without quotes the value is trimmed.
Nesting (and the lack of it).
Pure INI has exactly one level of nesting: section → key. To represent deeper structures, dialects added conventions — dotted keys (db.host, db.port inside one section), sub-sections ([db.primary]), or repeated keys interpreted as arrays. None of these have universal agreement. A converter has to pick one convention and stick with it; users with documents from a different dialect need to translate.
A worked conversion.
INI input: [server]
host = localhost
port = 8080
; debug only
verbose = true
becomes JSON { "server": { "host": "localhost", "port": "8080", "verbose": "true" } }. Note the values stay strings — INI doesn't know that "8080" is meant to be a number or that "true" is meant to be a boolean. A converter that coerces types is making a guess; pass-through-as-string is the safer default.
Single section, three keys
[server] host=... port=... verbose=...
Section becomes object key; each key=value becomes a string field.
[server] → { "server": {...} }
= Nested JSON with stringly-typed values
No section
Top-level keys before any section header
Most converters dump these into a "default" or "global" object.
key=value (no [section] above) → { "default": { ... } }
= Convention-dependent root container
Comments don't survive.
JSON has no comment syntax. INI's ; and # comments drop on conversion. If the source INI is a hand-edited config with meaningful comments, the conversion is information-losing. The opposite direction — JSON to INI — has space to add comments back, but the inferrer has no way to know which fields wanted to be commented. Round-tripping through JSON loses every comment in the original.
Type coercion is risky.
A converter that decides "this looks like an integer, I'll emit a JSON number" handles port = 80 correctly and breaks zip = 02134 by stripping the leading zero. "This looks like a boolean" handles debug = true correctly and breaks password = yes (it's not a yes/no, it's a string). The safer default is to emit every value as a JSON string and let the consumer parse types from context.
Why INI persists.
The format is forty years old, has no standard, and disagrees with itself across implementations — and is still used everywhere because it has two unbeatable properties. It reads beautifully (a sysadmin can edit a config without learning a language), and it survives carrying around in plain text files through every text processing tool ever made. JSON is better-specified; YAML is more powerful; TOML is more modern. None of them displaced INI in places where the audience is "a human at a terminal".