ini
INI to JSON: Why "Everything Is a String" Breaks Round-Trips
INI has no spec — only dialects — so every INI↔JSON converter must decide whether 01 is a number, whether yes is a boolean, and where a comment starts. Those judgment calls determine whether ini → json → ini gives you back the same file.
Convert an INI file to JSON, convert it back, and diff the two files. If they don’t match, your converter isn’t necessarily broken — because there is no INI specification for it to be broken against. No RFC, no ECMA number, no ISO document. What we call “INI” is a family of dialects that agree on roughly one thing — key = value lines under [section] headers — and disagree on everything that matters for a round-trip: whether yes is a boolean, whether 01 is a number, whether # starts a comment, and what a repeated key means. JSON, meanwhile, does have a spec, and it insists on knowing whether every value is a string, a number, a boolean, or null. So an INI→JSON converter is forced to make type-coercion judgment calls that no standard can settle — and each call it makes is a place where the trip back can silently rewrite your file.
(This is not a problem unique to INI: .env has the same missing spec and the same divergent implementations, worked through in there is no .env spec.)
There is no INI. There are dialects.
Here are three widely deployed “INI” parsers, each authoritative for its own ecosystem, none authoritative for the format:
Python’s configparser refuses to guess types at all. Its documentation says it plainly: config parsers “do not guess datatypes of values in configuration files, always storing them internally as strings.” Typed access is an opt-in read, not a parse-time decision — you call getint(), getfloat(), or getboolean() on a value you already know should be typed, and getboolean() accepts 1/0, yes/no, true/false, and on/off, case-insensitively. It also supports interpolation — %(name)s references by default, ${section:option} with ExtendedInterpolation — meaning a value can change depending on other values, something JSON has no way to represent. Keys are case-insensitive and lowercased by default; duplicate keys raise DuplicateOptionError in the default strict mode.
PHP’s parse_ini_file() coerces at parse time, and aggressively. In its default mode, an unquoted yes, on, or true comes back as the string "1", and an unquoted no, off, false, or null comes back as the empty string "" — your word is gone either way. Those words (plus none) are outright reserved and can’t be used as keys. There’s an INI_SCANNER_TYPED mode that produces real booleans, nulls, and integers instead, and an INI_SCANNER_RAW mode that turns coercion off entirely — three different answers to “what does this file contain?” from one function, selected by a flag. PHP also has key[] = value array syntax, which no other dialect here recognizes.
systemd unit files are INI-shaped but their own animal. The systemd.syntax man page says the format “is inspired by XDG Desktop Entry Specification .desktop files, which are in turn inspired by Microsoft Windows .ini files” — inspired by, not conforming to. Booleans accept 1, yes, true, on for true and 0, no, false, off for false. Comments are full lines starting with # or ; — inline comments don’t exist. And repeated keys are a feature: “various settings are allowed to be specified more than once,” many settings accumulate into lists, and assigning an empty value resets the list. A converter that treats a duplicate key as an error, or keeps only the last one, just destroyed a unit file’s ExecStartPre= sequence.
Python configparser | PHP parse_ini_file() | systemd units | |
|---|---|---|---|
| Types at parse time | Never — everything is a string | Coerces by default (yes → "1", false → ""); INI_SCANNER_TYPED for real types | Per-setting; booleans widely accepted |
| Booleans | Opt-in via getboolean(): 1/0, yes/no, true/false, on/off | true/on/yes and false/off/no/none/null are reserved words | 1/yes/true/on, 0/no/false/off |
| Comments | # and ; full-line; inline comments off by default | ; (the documented marker) | # and ;, full lines only |
| Duplicate keys | Error (strict mode, the default) | Last value wins | Often append to a list; empty value resets |
| Extras | %(name)s / ${section:option} interpolation; key: value also allowed | key[] arrays; reserved characters in keys | \ line continuation |
Three dialects, three incompatible answers to every question a converter has to ask. Keep that table in mind, because every round-trip hazard below is just one of these disagreements meeting JSON’s demand for exactly one type per value.
Hazard 1: leading zeros, the irreversible one
This is the same bug class that mangles ZIP codes in spreadsheets — we covered that side of it in Excel dates and IDs break on export — and INI→JSON converters walk right into it:
[store]
id = 007
region = 01
A converter that eagerly types “anything that looks numeric” produces:
{ "store": { "id": 7, "region": 1 } }
And now the trip back is unrecoverable. JSON numbers don’t have leading zeros (the JSON grammar literally forbids them), so 7 can only serialize back as 7. Your 007 — a store code, a Bond reference, an octal-looking permission string — is gone, and no amount of cleverness on the INI-writing side can restore it, because the information no longer exists. This is the defining property of a bad coercion call: it’s not wrong on the way in, it’s irreversible on the way back.
Hazard 2: booleans, where the dialects disagree the most
Is enabled = yes a boolean? Depends who you ask. configparser says it’s the string "yes" until you explicitly call getboolean(). PHP’s default scanner says it’s the string "1" (destroying the word you wrote). PHP’s typed scanner and systemd both say it’s true. Four parsers, three different values, one line of INI.
Now round-trip it through a converter that maps yes → JSON true. On the way back, true can only be written as some spelling the converter picks — probably true:
; before
enabled = yes
; after ini → json → ini
enabled = true
Semantically identical to systemd. Byte-different to your diff, your code review, and your config-management tool. And if the file was destined for a parser where yes and true aren’t equivalent, the round-trip changed its meaning, not just its bytes.
Hazard 3: version numbers and other numeric impostors
version = 1.10
Coerce that to a JSON number and it becomes 1.1 — because as a float, 1.10 is 1.1. But as a version, 1.10 is nine minor releases after 1.1. Same trap, different costume: +491701234567 phone numbers (the + and sometimes the digits vanish), sixteen-digit account numbers (past 2^53, JSON numbers lose integer precision), 1e5 build tags (hello, 100000). The string looked like a number, the coercion was locally reasonable, and the round-trip shipped a different value. If you generate typed models downstream, this is the same judgment-call territory as JSON to Pydantic gotchas — someone has to decide what these strings mean, and the format can’t tell them.
Hazard 4: comments, and the URL that gets eaten
Two hazards hide in comment handling. The first is representational: JSON has no comments, full stop. Whatever ; explains why this timeout is 30 was doing in your INI file, it does not survive the trip to JSON, so it cannot survive the trip back. Any INI→JSON→INI pipeline is lossy for comments by construction — the only honest question is whether the tool tells you.
The second is a genuine parsing bug in naive converters:
docs = https://example.com/page#section
A parser that truncates at the first # or ; it sees turns that value into https://example.com/page and throws the fragment away. Real INI dialects mostly dodge this by not supporting inline comments at all — systemd only ignores full lines starting with # or ;, and configparser ships with inline comments disabled by default (its docs even warn that enabling inline_comment_prefixes “may prevent users from specifying option values with characters used as comment prefixes,” since there’s no escaping). It’s converters that try to support inline comments with a naive split('#') that eat your URLs.
Hazard 5: duplicate keys and nesting — the structural mismatches
JSON objects have unique keys and arbitrary depth. INI dialects have neither guarantee. Duplicate keys are an error (configparser strict mode), a last-one-wins overwrite (PHP), or a list-building feature (systemd) — so a converter must pick one behavior and lose the others. And in the reverse direction, INI has no native nesting at all, so deep JSON has to be flattened into some convention — dotted section names like [server.tls], or path-flattened keys — and JSON arrays have no portable INI representation whatsoever. If your JSON is deeply nested and array-heavy, the honest answer may be that INI is the wrong target and you want YAML instead.
What a converter should do about all this
Since no spec can settle these calls, the only defensible move is to make them deliberately, document them, and choose reversibility whenever coercion would destroy information. That’s how we built our INI to JSON converter — not “spec-compliant,” because that phrase is meaningless here, but documented:
- Leading zeros are never coerced.
01and007stay strings in both directions — the integer and float patterns in ourparseValue()explicitly exclude leading zeros, because parsing007as7would strip the zeros irreversibly on the way back.port = 8080becomes the number8080;id = 007stays"007"and comes back as007, byte for byte. - Inline comments require a preceding space.
timeout = 30 ; secondsdrops the comment, butdocs = https://example.com/page#sectionkeeps its fragment, because a;or#only counts as an inline comment when a space comes before it (and never inside quotes). Your URLs survive. - Coercion is a visible toggle, not a hidden opinion. Types on: unquoted
true/false/null(case-insensitive) and clean numbers get real JSON types. Types off: everything is a string, configparser-style. We deliberately do not treatyes/no/on/offas booleans — that’s the coercion most likely to rewrite a file that another dialect reads differently. - The writer protects the reader. Going JSON→INI, a string that looks like a number, boolean, or null gets quoted, so it re-parses as the same string instead of being coerced into something else on the next pass. Quoted values always stay strings.
- Structure is explicit. Dotted sections (
[a.b]) nest; duplicate keys take the last value; arrays fail loudly with an error instead of inventing a syntax that no INI dialect would agree on.
Once you’re on the JSON side, JSON Formatter will pretty-print and validate the result before it goes anywhere important.
The short version
| The assumption | The reality |
|---|---|
| ”INI is a standard format” | No spec exists — configparser, PHP, and systemd are dialects that disagree on types, comments, and duplicates |
”01 is obviously the number 1” | Coercing it destroys the leading zero irreversibly; JSON’s grammar can’t write it back |
”yes is obviously true” | It’s "yes" to configparser, "1" to default PHP, true to systemd — pick a dialect, lose the others |
”1.10 is a number” | As a float it’s 1.1; as a version it isn’t — the string is the truth |
”Strip everything after #” | That’s a URL fragment, not a comment — real dialects mostly don’t do inline comments at all |
| ”Duplicate keys are a mistake” | In systemd they’re how lists work; converters that dedupe rewrite unit files |
| ”Round-tripping is a converter feature” | It’s a set of judgment calls — use a tool that documents which ones it made |
INI→JSON is easy. INI→JSON→INI, unchanged, is a design problem — and the tools that solve it are the ones that tell you exactly which types they refuse to guess.