Configuration Files with configparser
INI configuration files have been around since the early days of Windows for a reason: they are easy to read, easy to hand-edit, and they support comments. Python’s configparser module gives you a parser for that exact format, built into the standard library, with no extra install. If you have ever saved key = value pairs to a file and then regretted it, this is the module to reach for first.
Why configparser and not JSON or TOML?
Each format has its sweet spot, and the right choice depends on who edits the file and how often:
jsonworks well for machine-generated data and APIs, but it has no comments, no trailing commas, and quotes around every key make hand-editing noisy.tomlis more modern and supports nested tables, but the strict dotted-key syntax is less forgiving for non-developers.- INI is the right call when the config is shallow, human-edited, comment-friendly, and shared between tools (for example, a
mysqld-style service file or asetup.cfg).
If your config comes from environment variables only, os.environ is a better fit. For .env files, reach for python-dotenv. For deeply nested data, use tomllib (Python 3.11+).
Reading your first configuration file
Start with a small file on disk:
# app.ini
[DEFAULT]
debug = false
[server]
host = 127.0.0.1
port = 8080
[logging]
level = INFO
file = /var/log/app.log
Once the file is on disk, parsing it takes three lines: open the parser, point it at the file, and start reading keys. This is the smallest possible reading example, which prints the host value from the [server] section of the file above:
import configparser
from pathlib import Path
config = configparser.ConfigParser()
config.read(Path("app.ini"))
print(config["server"]["host"])
# output: 127.0.0.1
config.read() returns the list of files that were actually parsed. Missing files are silently skipped, so check the return value if the file is required. Treat the return list as the ground truth: an empty list means nothing was loaded, and a populated list tells you exactly which paths contributed to the parser state:
read = config.read(Path("app.ini"))
if not read:
raise SystemExit("config file missing")
You can also pass a list of paths, and later files override earlier ones. That is the cleanest way to layer a system default over a per-user override over a project-local override.
Reading values with typed getters
Every value in an INI file is a string. bool("False") returns True, so calling parser.get("server", "debug") and casting the result will burn you. Use the typed getters instead, and the parser does the parsing for you:
config.getboolean("DEFAULT", "debug") # -> False
config.getint("server", "port") # -> 8080
# config.getboolean("server", "port") # -> ValueError: 'not a boolean'
getboolean is permissive on input. It accepts (case-insensitive): 1, yes, true, on for True; 0, no, false, off for False. Anything else raises ValueError.
For everything else, get() returns a string. Pass a fallback keyword argument for the case where the option is missing. It is safer than catching NoOptionError because you avoid the exception path entirely when the missing value is expected:
level = config.get("logging", "level", fallback="WARNING")
# output: 'INFO' (if present), 'WARNING' (if missing)
The default value is a string, not a number, so get() with a fallback like 0 will return the string "0". Wrap with int() only after you confirm the option exists.
Writing configs back to disk
Build a parser in memory, then write it. The mapping protocol is the cleanest way to set sections and options without juggling the type-checked set() method for every key:
config = configparser.ConfigParser()
config["DEFAULT"] = {"debug": "false"}
config["server"] = {"host": "0.0.0.0", "port": "8080"}
config["logging"] = {"level": "INFO", "file": "/var/log/app.log"}
with open("out.ini", "w", encoding="utf-8") as fh:
config.write(fh)
# out.ini contents:
# [DEFAULT]
# debug = false
#
# [server]
# host = 0.0.0.0
# port = 8080
#
# [logging]
# level = INFO
# file = /var/log/app.log
A few things to know about the round trip:
write()requires the file to be open in text mode. Passencoding="utf-8"explicitly.- Comments from the original file are not preserved. There is no round-trip fidelity.
- In Python 3.14,
write()raisesInvalidWriteErrorif a section or option name would re-parse incorrectly, which catches typos at write time rather than at the next read.
The DEFAULT section, demystified
DEFAULT is the one section name that gets special treatment. Values inside DEFAULT are merged into every other section at read time, so you do not repeat yourself:
[DEFAULT]
host = 127.0.0.1
timeout = 30
[server]
port = 8080
[worker]
concurrency = 4
Both [server] and [worker] see host and timeout as if they were defined inline. The merge happens at parse time, which means a later read against either section returns the default value when the section does not redefine it.
The catch is that the fallback argument to get() does not override a default value. From the official quick-start:
topsecret.get("CompressionLevel", "3") # returns '9' (the default), not '3'
If you want “fallback only if no default and no section value”, you have to read the section explicitly or take a different approach. The behavior is rarely what you want when defaults are present, so it is worth a comment if you rely on it.
DEFAULT also does not show up in parser.sections(). It is always there, but it is hidden from that view. The way around it is to read parser.defaults() if you need a snapshot of the merged-in values, or to look up options on parser["DEFAULT"] directly through the mapping protocol.
Interpolation: build values from other values
By default, ConfigParser expands %(name)s references inside values against the current section and DEFAULT. So given a value of data = %(home)s/data, cfg.get("paths", "data") returns /Users/me/data if home = /Users/me lives in DEFAULT.
A few rules:
- Use
%%to write a literal%in a value. - To reference a value from a different section, switch to
ExtendedInterpolation():data = ${paths:data}/exportsresolves cross-section refs. Escape$with$$. - To disable interpolation entirely, pass
interpolation=None. Useful when your values contain$or%for unrelated reasons (URL templates, template literals, etc.). get(section, option, raw=True)skips interpolation and returns the literal string.MAX_INTERPOLATION_DEPTH(default 10) caps recursion. Circular references raiseInterpolationDepthError.
Quick example:
import configparser
ini = """
[DEFAULT]
home = /Users/me
[paths]
data = %(home)s/data
export_root = ${paths:data}/exports
"""
cfg = configparser.ConfigParser()
cfg.read_string(ini)
cfg.get("paths", "data") # -> '/Users/me/data'
cfg.get("paths", "export_root") # -> '/Users/me/data/exports'
cfg.get("paths", "export_root", raw=True) # -> '${paths:data}/exports'
Customising the parser
The constructor accepts a dozen keyword arguments, but five of them cover most of the real-world cases where the defaults are wrong. Reach for these before reaching for anything else:
| kwarg | effect |
|---|---|
allow_no_value=True | Permit lines like skip-bdb with no =. The value is None. |
inline_comment_prefixes=("#",) | Allow key = value # comment. |
interpolation=None | Disable %(name)s expansion. |
converters={"list": fn} | Register getlist("section", "option"). |
parser.optionxform = str | Make option names case-sensitive. |
Two specific scenarios come up a lot.
Mysqld-style no-value options. Useful for flag files where the presence of a key is the whole point and the value is irrelevant:
mysqld = """
[mysqld]
user = mysql
skip-bdb
skip-innodb
"""
cfg = configparser.ConfigParser(allow_no_value=True)
cfg.read_string(mysqld)
cfg["mysqld"]["user"] # -> 'mysql'
cfg["mysqld"]["skip-bdb"] # -> None
Custom converters. A converter is any callable that takes a string value from the file and returns your target type. Register one in the constructor and the parser adds a matching get<name>() method for it. Built-ins like int, float, and boolean are pre-registered as getint, getfloat, and getboolean, so this is mostly for types the standard library does not already cover:
cfg = configparser.ConfigParser(
converters={"list": lambda s: [x.strip() for x in s.split(",") if x.strip()]}
)
cfg.read_string("[features]\nflags = a, b ,c\n")
cfg.getlist("features", "flags")
# output: ['a', 'b', 'c']
Practical patterns
A few patterns come up in real projects more often than the rest. Layering multiple files, feeding the parser from in-memory sources, and preserving case in option names cover most of what you will write in production code.
Override file chain. Pass a list of paths to read() and the parser merges them, with later entries winning on conflict. The list can mix strings and pathlib.Path objects freely. This is the cleanest way to layer a system default, a per-user override, and a project-local override without writing merge code yourself, and the same approach works for development versus production configurations:
import os
config.read([
"/etc/myapp/config.ini",
os.path.expanduser("~/.config/myapp/config.ini"),
"config.local.ini",
])
Loading from a string or dict. For tests you almost never want to touch the filesystem. read_string() takes the INI content directly, and read_dict() takes a nested Python dict that mirrors the section and option shape. Both are great for fixtures, and they are also the easiest way to drive the parser from configuration data you already have in memory at runtime, for example when a user pastes a config block into a web form:
config.read_string("[server]\nport = 8080\n")
config.read_dict({"server": {"port": 8080}})
Case-sensitive option names. The default optionxform lower-cases every key, which trips people who want Foo and foo as distinct entries (common in MySQL-style configs and in some metrics exporters). Setting it to str keeps the original casing. Apply the change right after constructing the parser; it takes effect for every section read afterwards, so do not flip it mid-lifecycle:
cfg = configparser.ConfigParser()
cfg.optionxform = str
cfg.read_string("[s]\nFoo = 1\nfoo = 2\n")
list(cfg["s"]) # -> ['Foo', 'foo']
Common mistakes
A short list of things that have caught me, ordered by how often they bite:
- Reading
bool(config.get("server", "debug"))instead ofconfig.getboolean(...). The string"False"is truthy in Python, so the result is the opposite of what you wanted. - Assuming
fallback=overridesDEFAULT. It does not. Defaults win, and the fallback only applies when the section exists but the option does not. - Trusting
read()to fail on missing files. It does not. It returns an empty list and moves on, which is the right behavior for layered configs but the wrong behavior for a required file. Check the return value. - Writing a config with
wb(binary) instead ofw(text).config.write()needs a text-mode file, and theencodingargument only does anything when the file is open in text mode. - Using
RawConfigParserfor new code. It is the legacy variant with interpolation disabled and is more lenient about non-string values. PreferConfigParser(interpolation=None)so you still get the strict type checks. - Forgetting that
read()is forgiving about missing files. The return list tells you what was actually loaded; it is the only signal you have.
When to skip configparser
Pick a different tool if any of these apply:
- The config is deeply nested. Use
tomlliborjson. - The data is meant to be machine-generated and consumed by another program. Use the format that program expects.
- The config lives entirely in environment variables. Use
os.environ. - Users will edit the file in a browser or GUI. Use JSON over a schema, or a typed config like
pydanticreading from YAML.
For everything else, configparser is small enough to learn in an afternoon, ships with every Python install, and does not add a dependency to your project. It is the right tool for the boring, useful, hand-edited configuration files at the bottom of your repo, the ones a new teammate should be able to read on day one without a glossary.