The same INI file, two different configs

INI has no specification, so every language guessed. Six places Python and Node read the same file differently — and the three that never tell you.

Your provisioning config works. The Node service reads it, the Python worker reads it, nothing throws. Then someone notices the worker has been using a fifteen-second timeout for a month, while the file plainly says 30000. Both programs read the same file. Neither is broken. They just disagree about what it says.

This is the tax on a format with no specification. JSON has RFC 8259. TOML has a versioned grammar. INI has an ancestor — Windows 3.x kept its settings in .ini files and read them through GetPrivateProfileString, and that function's behaviour became the de facto standard purely by being the only implementation to check against. Everything since has been reimplemented from memory.

So I took one realistic config and ran it through the two parsers most likely to see it: Python's standard-library configparser, and the ini package that most of Node reaches for. Four lines in this nineteen-line file are read differently by the two, and two more disagreements need only one extra line each. None of this is exotic — it is an ordinary config.

gateway.ini
; Provisioning gateway - shared by the Node API and the Python worker
; Reviewed 2026-01-14 by the network team

[operator]
name = Linnea Mobile
mcc = 234
mnc = 15

[network]
TimeoutMs = 30000
port = 5432 ; staging uses 5433
retries = 3

[network.tls]
enabled = true

[db]
user = provisioning
password = p%ssw0rd

Three of the six make a program stop and tell you. Three of them do not. Those are in different leagues, so they are worth taking in that order — worst last.

The three that at least shout

Start with the loud ones, because they are the good news. In every case below Python refuses to continue while Node carries on, and it is worth noticing that the stricter parser is the one doing you a favour.

1. The same key set twice

Add a second retries to the file. This happens constantly — a merge that went the wrong way, a config generated by concatenating fragments, or somebody adding a setting at the bottom of a long file without checking whether it already exists further up.

Python 3.11
>>> configparser.ConfigParser().read_string("[a]\nretries = 1\nretries = 2\n")
configparser.DuplicateOptionError: While reading from '<string>' [line  3]: option
'retries' in section 'a' already exists
Node
> require('ini').parse("[a]\nretries = 1\nretries = 2\n")
{ a: { retries: '2' } }

Python refuses to load the file at all. Node returns the last value and says nothing — no warning, no second entry, no trace that the first line ever existed. If you only ever run the Node side, a config can carry a contradiction for years and read perfectly to anyone scrolling it.

2. A percent sign in a password

password = p%ssw0rd is not a trick. It is a password with a percent sign in it, which is a thing that generated credentials do all the time.

Python 3.11
>>> configparser.ConfigParser().read_string("[db]\npassword = p%ssw0rd\n")
configparser.InterpolationSyntaxError: '%' must be followed by '%' or '(',
found: '%ssw0rd'

configparser treats % as the start of a substitution — %(other_key)s pulls in another value from the same section, which is a genuinely useful feature and completely absent from every other INI parser. The cost is that a bare % is a syntax error. The fix is either to double it to %%, which then breaks every non-Python reader, or to construct the parser with interpolation=None. Node has no opinion here at all: the value is the string p%ssw0rd.

3. A flag with no value

A bare verbose on its own line, with no =. It reads like a flag, and about half the INI dialects in the world treat it as one.

Both parsers, same line
Python  ParsingError: Source contains parsing errors: '<string>'
          [line  2]: 'verbose\n'

Node    { a: { verbose: true } }

Python rejects the file unless you pass allow_no_value=True, and then gives you None rather than True. Node gives you the boolean. Three plausible readings of one line — a syntax error, a null, and true — which is a fair summary of the whole format.

The three that never tell you

Now the expensive ones. In each of these both parsers succeed, neither warns, and they hand back different data. An exception costs you an hour. A wrong value costs you a week, because you spend six days doubting everything except the config file.

4. Key case

The file says TimeoutMs. Here is what each parser gives you.

ParserKey you get backLookup for "TimeoutMs"
Python configparsertimeoutmsKeyError
Node iniTimeoutMsworks

configparser lowercases every key on the way in. That is deliberate and documented — it is the optionxform hook, and assigning str to it turns the behaviour off entirely — but almost nobody knows it is there until it bites. The failure is quiet in the worst way: your Python code reads config['network']['timeoutms'] because that is what worked, and now the config file and the code that reads it spell the same setting differently. The next person to grep the repo for TimeoutMs finds the config and not the code.

5. Comments at the end of a line

port = 5432 ; staging uses 5433. Obviously a note. Obvious to a human, anyway.

The value each parser returns
Node    '5432'
Python  '5432 ; staging uses 5433'

Node strips it. Python keeps the whole thing, because inline comments are off by defaultinline_comment_prefixes is None until you set it. A comment on its own line is stripped by both, which is what makes this so easy to miss: the file is full of comments that work fine, and then one of them is on the end of a line.

Watch where this actually fails. Nothing goes wrong at parse time — the value is a string either way. It goes wrong at int(config['network']['port']), possibly in a different module, and the traceback points at a line of perfectly correct code.

6. Dotted section names

[network.tls] looks like a subsection of [network], and one parser agrees with you.

Node ini — nested
{
  "network": {
    "TimeoutMs": "30000",
    "port": "5432",
    "retries": "3",
    "tls": { "enabled": true }
  }
}
Python configparser — flat (with interpolation=None, or it stops at the password)
{
  "network":     {
    "timeoutms": "30000",
    "port": "5432 ; staging uses 5433",
    "retries": "3"
  },
  "network.tls": { "enabled": "true" }
}

Node runs a post-processing pass that folds dotted names into a tree. Python keeps a section called exactly network.tls, because a section name is a string and a dot is a character in it. So this is not a value difference — it is a shape difference. Code written against one structure cannot walk the other at all, and neither parser thinks anything happened.

The scoreboard

The lineNode iniPython configparserTold you?
retries set twicekeeps '2'DuplicateOptionErrorPython only
password = p%ssw0rd'p%ssw0rd'InterpolationSyntaxErrorPython only
bare verbosetrueParsingErrorPython only
TimeoutMs = 30000TimeoutMstimeoutmsno
port = 5432 ; note'5432''5432 ; note'no
[network.tls]nested under networksection network.tlsno

Four of those are in the file above as written; the other two need one line each. The half that stay quiet are the half that matter. Worth saying plainly: neither library is at fault. There is no document either of them could be violating.

Writing INI that survives both

You cannot make parsers agree. You can write files where there is nothing left to disagree about, and it costs almost nothing. In rough order of how often each rule would have saved me:

  1. Never set the same key twice. Half your readers will pick a value and not mention it.
  2. Put comments on their own line. This one rule removes the worst of the six, and costs you a newline.
  3. Use lowercase keys with underscores. timeout_ms reads identically everywhere. TimeoutMs does not.
  4. Do not rely on dotted sections nesting. If you need a tree, you have outgrown the format — that is what TOML was designed for.
  5. Quote anything with a %, a ; or a # in it, and expect a Python reader to need interpolation=None regardless.
  6. Save as UTF-8 without a BOM. Those three bytes — EF BB BF, described in the Unicode byte order mark FAQ — render as nothing and attach themselves to your first section header, so a lookup for the name you can plainly see returns nothing.
INI Validator Reports duplicate keys, repeated sections, bare flags and a BOM with real line numbers — the things both parsers above resolve without telling you. INI to JSON Shows the structure a program actually receives, which is the fastest way to see whether dotted sections nested or not.
gateway.ini The file from this article. Run it through whatever your project uses and check the key case, the port value and whether the TLS section nested.

What to take away

INI is not a bad format. It is readable, it diffs cleanly, and it has outlived several formats designed to replace it. But it is a convention rather than a specification, and a convention only holds while everyone reading the file shares the same assumptions.

So the useful question is never "is this file valid INI?" — there is nothing to be valid against. It is "which programs read this file, and do they agree about it?" If the answer is more than one program and you have never checked, the six lines above are where to look first. Start with key case and inline comments; between them they account for most of the quiet ones.