Have you ever watched a perfectly valid INI file explode only after it finally reached a remote Linux box? I spent forty-eight hours convinced the parser was broken, the file was truncated, or some invisible BOM had snuck into the first line. The traceback named InterpolationSyntaxError, which sounded like a syntax lecture rather than a password hiding inside the value. Why would a secret that worked in my notebook fail the moment a service account actually used it?
The service would not start
I was wiring a small Python worker that read database credentials from a classic INI file. Locally the process started, connected, and printed a boring ready line I actually wanted to see. On the shared Linux box the same commit died during config load, before any socket even opened. I copied the worker module back and forth, and those bytes looked identical in cmp and sha256sum.
sha256sum worker.py
python -c "import pathlib; print(pathlib.Path('worker.py').read_bytes()[:60])"
The application hash matched, so why did only the server refuse to parse a config file I barely looked at? I had been checksumming the code, not the rendered secrets sitting beside it.
I blamed encoding, then line endings
Was it UTF-8 versus latin-1 again, because locale defaults have burned me more than once already? I ran file, hexdump, and a tiny Python snippet that printed repr of every single line. Nothing showed a CR, a BOM, or a smart quote hiding inside the password field at all.
from pathlib import Path
for i, line in enumerate(Path("app.ini").read_text(encoding="utf-8").splitlines(), 1):
print(f"{i:02d} {line!r}")
The password line looked like a normal secret with an at-sign, a percent, and a couple of digits. I asked myself out loud whether ConfigParser even cared about special characters sitting inside ordinary values. Did I really read the interpolation docs, or did I just skim them years ago during a tutorial?
A second Linux shell finally told the truth
My laptop still would not fail, because my local INI used a throwaway password without a percent sign. That is an embarrassing confession, but it is also exactly how these quiet bugs survive code review. I needed the real secret shape without dumping production credentials into a chat window or a gist.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I copied a redacted INI onto MonkeyCode's free server option, then used free model access only to list parser-level hypotheses. The model did not get the live password, and it did not need one for this class of failure.
On that Linux shell the traceback was immediate and repeatable, which finally killed my platform theory. Local success had been a sampling error, not a mysterious difference between macOS and Linux parsers.
I finally read the exception payload
The error named a key, a value, and a dangling percent with no conversion specifier after it. ConfigParser, by default, treats each value as a percent-format string for interpolation against other keys in the file. A password like p@ss%word is not a password to that parser; it is a broken format string.
import configparser
parser = configparser.ConfigParser()
parser.read_string(
"""
[database]
user = app
password = p@ss%word
"""
)
print(parser["database"]["password"])
That snippet is enough, and you do not need Docker, systemd, or a listening database to prove the point. If your secret contains a percent, BasicInterpolation raises InterpolationSyntaxError before any of your application code runs.
What BasicInterpolation actually does
I had used percent-style interpolation in logging formatters, so I thought I already understood this entire class of bugs. ConfigParser is stricter in a different direction, because it looks up other keys when it sees a percent parenthesis pattern. A lone percent that is not %% and not %(name)s is a syntax error, not a character that passes through. Have you looked at a password lately and asked which characters your parser silently treats as grammar?
import configparser
text = """
[paths]
home = /var/app
log = %(home)s/log
[database]
password = p@ss%word
"""
The paths section is the feature someone wanted in 2003, and the database section is how that feature bites you now. Intended interpolation and accidental punctuation live in the same grammar, which is a rude surprise during boot.
The other half of the outage was a generator that wrote secrets with percent encodings from a URL parser. If your password originated as a URL userinfo field, percent-escaping is normal, and INI interpolation will hate you for it. I now write INI through a helper that disables interpolation on read and never tries to be clever on write.
import configparser
def write_ini(path, mapping):
parser = configparser.ConfigParser(interpolation=None)
for section, values in mapping.items():
parser[section] = values
with open(path, "w", encoding="utf-8") as handle:
parser.write(handle)
That helper is boring on purpose, because clever writers are how doubled percents and raw percents get mixed in one directory.
A regression test you can keep
I now keep a regression test that feeds ugly secrets through whatever parser the service actually uses. If you only test with password123, you will ship the same outage I just described to myself. The following pytest file is meant to be run as written on a throwaway machine.
# test_config_passwords.py
import configparser
import pytest
UGLY = r"p@ss%word%%token%40end"
def parse(text, interpolation):
parser = configparser.ConfigParser(interpolation=interpolation)
parser.read_string(text)
return parser["database"]["password"]
INI = f"""
[database]
user = app
password = {UGLY}
"""
def test_default_interpolation_rejects_percent_in_password():
with pytest.raises(configparser.InterpolationSyntaxError):
parse(INI, interpolation=configparser.BasicInterpolation())
def test_raw_values_preserve_percent_and_doubled_percent():
value = parse(INI, interpolation=None)
assert value == UGLY
def test_escaped_percent_round_trips_with_basic_interpolation():
escaped = UGLY.replace("%", "%%")
text = f"[database]\npassword = {escaped}\n"
value = parse(text, interpolation=configparser.BasicInterpolation())
assert value == UGLY
Run it with pytest -q test_config_passwords.py on every machine that claims to parse these INI files. The first test documents the trap, and the second test documents the fix I actually shipped later. The third test exists so nobody helps by doubling percents in a writer that already disabled interpolation.
A decision table I wanted at hour one
I built a small decision table so the next review does not turn into another oral tradition. Would I still use INI for secrets after this week of notes, or should the format itself retire?
| Approach | Keeps % in secrets |
Still interpolates other keys | Notes |
|---|---|---|---|
ConfigParser() default |
No | Yes, %(name)s
|
Default footgun for credentials |
ConfigParser(interpolation=None) |
Yes | No | The constructor I shipped |
RawConfigParser() |
Yes | No | Same idea, older name |
ExtendedInterpolation() |
No | Yes, ${section:key}
|
Different syntax, same class of bug |
Escape as %% in the file |
Yes, after parse | Yes | Easy to forget in generators |
Would I still use INI for secrets after this? Only for small tools, and only with interpolation turned off. Environment variables or a dedicated secret store hurt less once more than one person rotates passwords.
Commands that changed my mind
I kept a notes file, because my memory after hour thirty is not a reliable instrument anymore. These are the commands that actually changed my mind, rather than the ones that only soothed me.
- I printed configparser.file so I knew which standard library I was accusing of the syntax error.
- I ran python3 -m pytest -q test_config_passwords.py on the laptop and then again on the Linux shell.
- I used a redacted diff of local.ini and server.ini that compared structure and never printed live secrets.
- I searched the whole repository for ConfigParser( because one helper still constructed the default interpolating parser.
If a command cannot run on a throwaway server with a fake secret, I do not trust it yet. Reproduction that needs production credentials is not reproduction, and it is basically a leak with extra steps.
What broke, and what I would repeat
The generated starter, including my own first draft of the loader, used ConfigParser with no arguments. That call is idiomatic in tutorials, and it is the wrong default for credential files in production. I also generated sample INI files with tame passwords, so CI stayed green while production used a generated secret.
Hours thirty through forty-eight were mostly writing the test, ripping out a compatibility shim, and explaining the failure to future me. The shim tried to catch InterpolationSyntaxError and retry with RawConfigParser, which hid the constructor bug from logs. Catch-and-retry made the service start, then made every later reader unsure which parser rules applied to which file. I deleted the shim. One constructor, one test, one comment. That is the whole operational story.
What I would repeat looks unglamorous, which is usually a good sign in this kind of incident.
- Put one ugly-character fixture in every config parser test, including percent, at-sign, hash, quotes, and a leading space.
- Disable interpolation at the constructor itself, not with a comment that merely says someone should be careful.
- Keep a second cheap Linux shell so local sample data cannot hide a character-set bug in secrets.
- Ask a model for hypothesis lists after I have a traceback, not before I can name the offending file.
I would not repeat pasting live secrets into a prompt, even when the prompt promises it will not train on them. I would not repeat assuming a checksum match means equal runtime behavior when my laptop file was a sanitized cousin.
Limitations, and who should not copy this
This write-up is about CPython configparser and INI-shaped files, not a complete secret-management design for teams. It does not replace vaults, cloud secret APIs, or sealed volumes in Kubernetes, and it should not pretend to. Turning interpolation off will surprise you if some other key legitimately used percent-based expansion like home paths.
Do not use this approach if your config language is TOML, YAML, or JSON, because those parsers have different escape rules. Do not use a shared free server for real credentials, even redacted poorly, if your threat model includes other tenants. A free model can miss the interpolation docs the same way I did, so treat suggestions as unexecuted until a test speaks. Python's own documentation for configparser still describes BasicInterpolation and the doubled-percent escape; read that page beside these notes.
The loader I will not "simplify"
After the fix, the constructor looks like this, and the comment exists because future me will try to simplify it.
import configparser
from pathlib import Path
def load_service_ini(path: str) -> configparser.ConfigParser:
# interpolation=None is load-bearing for secrets that contain "%".
parser = configparser.ConfigParser(interpolation=None)
loaded = parser.read(path, encoding="utf-8")
if path not in loaded:
raise FileNotFoundError(path)
if "database" not in parser:
raise KeyError("database")
return parser
Is this glamorous work compared with rewriting the whole service around a cloud secret API tomorrow morning? The database client can still reject the secret, but that would be a different forty-eight hours of notes. If you are already keeping a second machine for works-on-my-laptop fights, MonkeyCode's free server is one place I parked the redacted repro. I still want the pytest file more than I want another prompt, because the test is what I will trust next month.
Top comments (0)