The Story That Explains Config Files
Your Python program is the same. The logic lives in code. The settings — database host, API key, log level, feature flags — live in a configuration file. Change the file, restart the app, done. No code edits, no redeploy, no risk of breaking the recipes.
ConfigParser is Python's built-in reader for the oldest and simplest config format there is: INI files. Sections in square brackets,
key = value pairs underneath, comments starting with
# or ;. It's been around since the 1980s Windows
days, and it's still the go-to format when you want humans to edit config
without breaking anything.
This tutorial covers Python's configparser module end-to-end:
reading INI files, extracting typed values, using the special DEFAULT section,
variable interpolation, writing files back out, and where INI beats JSON/YAML/TOML
and where it doesn't.
Config files exist to separate what changes from what doesn't. Code is what your program does. Config is how it does it in this particular environment. Mix the two, and every environment change becomes a code change. Separate them, and one binary runs everywhere.
Anatomy of an INI File
Before writing any Python, understand the file format. INI files have exactly four building blocks — you'll never need a fifth.
Every INI file is nothing more than these four blocks repeated. Purple headers group things. Blue keys name settings. Green values hold data. Grey comments explain.
Reading a Config File — The Basics
Step 1 — Create a Sample File
# app.ini
[database]
host = localhost
port = 5432
user = admin
password = secret
[server]
debug = true
timeout = 30
workers = 4
[logging]
level = INFO
file = /var/log/app.log
Step 2 — Read It From Python
import configparser
# Create a parser and load the file
config = configparser.ConfigParser()
config.read("app.ini")
# Dict-style access — the modern way
print(config["database"]["host"]) # localhost
print(config["database"]["port"]) # 5432 (still a STRING!)
print(config["logging"]["level"]) # INFO
# Method-style access — same result
print(config.get("database", "host")) # localhost
config["database"]["port"] returns the string "5432",
not the integer 5432. Doing port + 1 gives
"54321" instead of 5433. Always use the typed
accessors — getint, getboolean, getfloat
— for anything that isn't literally a string.
All ConfigParser Methods — Reference
| Method | What It Does |
|---|---|
read(filename) | Load one or more INI files (accepts a list too) |
read_file(fp) | Load from an already-open file object |
read_string(text) | Load from a Python string |
read_dict(d) | Load from a nested dictionary |
sections() | List of section names (excludes DEFAULT) |
options(section) | List of keys in a section (includes DEFAULT keys) |
items(section) | All key-value pairs in the section |
has_section(section) | Returns True if section exists |
has_option(section, key) | Returns True if key exists in section |
get(section, key) | Get value as string |
getint(section, key) | Get value as int |
getfloat(section, key) | Get value as float |
getboolean(section, key) | Get value as bool (yes/no/true/false/1/0/on/off) |
set(section, key, value) | Set a value (must be a string) |
add_section(section) | Create a new section |
remove_section(section) | Delete a section entirely |
remove_option(section, key) | Delete a single key |
write(fp) | Write the config out to a file object |
Iterating the Whole Config
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
# Every section
for section in config.sections():
print(f"[{section}]")
for key, value in config.items(section):
print(f" {key} = {value}")
print()
# Just the section names
print(config.sections())
# ['database', 'server', 'logging']
# Just the keys in one section
print(config.options("database"))
# ['host', 'port', 'user', 'password']
Checking Before Reading
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
if config.has_section("cache"):
ttl = config.getint("cache", "ttl")
else:
ttl = 300 # fallback
if config.has_option("database", "replica_host"):
replica = config["database"]["replica_host"]
else:
replica = None
# Or use the fallback= parameter — cleaner
port = config.getint("database", "port", fallback=5432)
name = config.get("database", "name", fallback="myapp")
Getting Values with Proper Types
The getint, getfloat, and getboolean
accessors do more than convert — they raise clear errors when the value can't
be converted, saving you from silent bugs.
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
# Integer — raises ValueError if not a valid integer
port = config.getint("database", "port")
print(port + 1) # 5433 (now it's a real int)
# Float
timeout = config.getfloat("server", "timeout")
print(type(timeout)) # <class 'float'>
# Boolean — accepts many spellings
debug = config.getboolean("server", "debug")
print(debug) # True
# All of these are accepted for booleans:
# True ← yes, true, on, 1
# False ← no, false, off, 0
# With fallback — no crash if the key is missing
workers = config.getint("server", "workers", fallback=4)
getboolean understands true/false,
yes/no, on/off, and 1/0 — case
insensitive. Anything else raises ValueError. This flexibility
is a feature: your users can write natural-looking config, and typos still
fail loudly.
The DEFAULT Section — Shared Values
The [DEFAULT] section is magic. Every key you put there becomes
automatically visible in every other section — unless that section
overrides it. Perfect for values that apply broadly but vary in specific
environments.
Purple values flow down from [DEFAULT] into every section.
A section can override a value by redefining it (red dashed line).
Sections that don't mention a key inherit whatever DEFAULT has.
# app.ini
[DEFAULT]
log_level = INFO
timeout = 30
retries = 3
[production]
host = prod.db.co
log_level = WARN
[staging]
host = staging.db.co
[dev]
host = localhost
log_level = DEBUG
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
# Notice: 'timeout' is NEVER defined in [staging] itself
# But it's readable because DEFAULT provides it
print(config["staging"]["host"]) # staging.db.co
print(config["staging"]["log_level"]) # INFO (from DEFAULT)
print(config["staging"]["timeout"]) # 30 (from DEFAULT)
# [production] overrides log_level
print(config["production"]["log_level"]) # WARN (overridden)
print(config["production"]["timeout"]) # 30 (still from DEFAULT)
# sections() does NOT include DEFAULT — it's not a normal section
print(config.sections())
# ['production', 'staging', 'dev']
Interpolation — Variable Substitution
ConfigParser supports interpolation: values can reference other
values using %(key)s syntax (basic) or ${section:key}
syntax (extended). Great for building paths from base directories, or URLs from
hostnames.
Basic Interpolation — Same Section Only
# app.ini
[paths]
home = /home/alice
data = %(home)s/data
logs = %(home)s/logs
backup = %(data)s/backups
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
print(config["paths"]["home"]) # /home/alice
print(config["paths"]["data"]) # /home/alice/data
print(config["paths"]["logs"]) # /home/alice/logs
print(config["paths"]["backup"]) # /home/alice/data/backups
Extended Interpolation — Cross-Section References
# app.ini
[server]
host = example.com
port = 8080
[api]
base_url = https://${server:host}:${server:port}/api
health_url = ${base_url}/health
users_url = ${base_url}/users
import configparser
# Extended interpolation supports ${section:key} references
config = configparser.ConfigParser(
interpolation=configparser.ExtendedInterpolation()
)
config.read("app.ini")
print(config["api"]["base_url"])
# https://example.com:8080/api
print(config["api"]["users_url"])
# https://example.com:8080/api/users
With basic interpolation on, % is a special character. To write a
literal percent — say in a password or format string — use %%.
Or disable interpolation entirely: ConfigParser(interpolation=None).
Writing & Updating Config Files
Modify Values in Memory, Then Save
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
# Update an existing value — must be a STRING
config["database"]["port"] = "5433"
# Add a whole new section
config["cache"] = {
"backend": "redis",
"host": "cache.example.com",
"ttl": "3600"
}
# Or add a new key to an existing section
config["server"]["host"] = "0.0.0.0"
# Write it back to disk
with open("app.ini", "w") as f:
config.write(f)
Removing Sections and Options
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
# Delete a single key
config.remove_option("database", "password")
# Delete a whole section (and all its keys)
config.remove_section("logging")
# Both return True if removed, False if it wasn't there
# Persist the changes
with open("app.ini", "w") as f:
config.write(f)
Building a Config File From Scratch
import configparser
config = configparser.ConfigParser()
# Dict-of-dicts is the cleanest way to build
config["DEFAULT"] = {"log_level": "INFO", "timeout": "30"}
config["database"] = {
"host": "localhost",
"port": "5432",
"user": "admin"
}
config["server"] = {
"debug": "false",
"workers": "4"
}
with open("new_config.ini", "w") as f:
config.write(f)
Reading From Multiple Files
A powerful pattern: layer configs. Load a base config, then load an environment- specific one that overrides only what needs changing. Load a user config last for final overrides.
import configparser
config = configparser.ConfigParser()
# Files loaded LATER override values from earlier ones
config.read([
"defaults.ini", # shipped with the app
"/etc/app.ini", # system-wide config
"~/.app.ini", # user's home directory
"./app.ini" # current directory (highest priority)
])
# Missing files are silently skipped — read() returns the list of files
# that were actually loaded
loaded = config.read(["a.ini", "b.ini"])
print(f"Loaded: {loaded}")
Ship sensible defaults with your code. Let sysadmins tune per-machine settings
via /etc/. Let users personalise via their home directory.
Each layer overrides only what it cares about — the rest falls through. This
is how professional applications handle configuration.
Practical Real-World Examples
Example 1 — Database Connection From Config
import configparser
def get_db_config(path="app.ini"):
config = configparser.ConfigParser()
config.read(path)
return {
"host": config.get("database", "host", fallback="localhost"),
"port": config.getint("database", "port", fallback=5432),
"user": config.get("database", "user"),
"password": config.get("database", "password"),
"dbname": config.get("database", "dbname", fallback="myapp"),
}
db = get_db_config()
print(f"Connecting to {db['host']}:{db['port']}")
Example 2 — Environment Switcher
import configparser
import os
env = os.getenv("APP_ENV", "dev")
config = configparser.ConfigParser()
config.read("config.ini")
# config.ini has [dev], [staging], [production] sections
# with a [DEFAULT] section for shared values
if not config.has_section(env):
raise ValueError(f"Unknown environment: {env}")
active = config[env]
print(f"Running in {env} mode")
print(f"Database: {active['db_host']}")
print(f"Debug: {config.getboolean(env, 'debug')}")
Example 3 — Feature Flags
import configparser
# flags.ini
# [features]
# dark_mode = true
# new_dashboard = false
# beta_analytics = on
config = configparser.ConfigParser()
config.read("flags.ini")
def flag_enabled(name):
return config.getboolean("features", name, fallback=False)
if flag_enabled("dark_mode"):
print("🌙 Using dark theme")
if flag_enabled("new_dashboard"):
print("Loading new dashboard")
else:
print("Loading classic dashboard")
Example 4 — API Client Setup
import configparser
config = configparser.ConfigParser(
interpolation=configparser.ExtendedInterpolation()
)
config.read_string("""
[server]
protocol = https
host = api.example.com
port = 443
[endpoints]
base = ${server:protocol}://${server:host}:${server:port}
users = ${base}/v1/users
orders = ${base}/v1/orders
""")
print(config["endpoints"]["users"])
# https://api.example.com:443/v1/users
print(config["endpoints"]["orders"])
# https://api.example.com:443/v1/orders
Example 5 — Save User Preferences
import configparser
from pathlib import Path
CONFIG_PATH = Path.home() / ".myapp.ini"
def load_prefs():
config = configparser.ConfigParser()
if CONFIG_PATH.exists():
config.read(CONFIG_PATH)
return config
def save_prefs(config):
with open(CONFIG_PATH, "w") as f:
config.write(f)
# Load, modify, save
prefs = load_prefs()
if not prefs.has_section("ui"):
prefs.add_section("ui")
prefs["ui"]["theme"] = "dark"
prefs["ui"]["font_size"] = "14"
prefs["ui"]["language"] = "en"
save_prefs(prefs)
print(f"Preferences saved to {CONFIG_PATH}")
INI vs JSON vs YAML vs TOML
ConfigParser handles INI files, but Python has options. Here's when to pick which.
| Format | Best For | Weakness |
|---|---|---|
| INI | Simple flat config, human-editable, no dependencies | No nested structures, no arrays, no types |
| JSON | Machine-generated config, API responses, data exchange | No comments allowed — painful for humans to maintain |
| YAML | Rich nested structures, cross-language (Kubernetes, CI) | Whitespace-sensitive, complex spec, needs PyYAML package |
| TOML | Modern Python projects (pyproject.toml) | Nesting syntax gets awkward, less familiar to non-developers |
Pick INI when the config is flat (2 levels max), humans will edit it, and you
don't want any dependencies. Pick TOML for anything Python-tool-related
(it's the standard for pyproject.toml). Pick YAML for
deeply nested or DevOps-facing config. Pick JSON only when generated by
machines — the no-comments rule makes it painful for humans.
Common Pitfalls
config["s"]["port"] returns "5432", not
5432. Use getint, getboolean,
getfloat. When calling set(), pass strings only.
Host and
HOST collapse to host. If you need case
preservation, subclass or set optionxform = str.
%, format strings, URLs with URL-encoded chars
— all break basic interpolation. Escape as %%, or disable:
ConfigParser(interpolation=None).
config.read("missing.ini") returns silently — no error, empty
config. Check the return value (list of loaded files) or use
read_file() with an open() that will raise.
config.sections() excludes [DEFAULT]. It's
accessed via config.defaults() or by reading through any
other section (values fall through).
servers[]
or database.credentials.password, use TOML or YAML instead
— forcing it into INI leads to string parsing hacks.
Golden Rules
getint, getboolean, getfloat.
Never int(config["s"]["port"]) in application code — typed
accessors give better error messages and support fallbacks.
config.get("s", "key", fallback="default"). Missing keys should
be a design choice, not a crash. Fallbacks make configs backward-compatible
when you add new options in later releases.
[DEFAULT] for values shared across sections.
Timeout, log level, retry count — things that vary rarely per environment.
Override in specific sections only when they differ. Halves your file size
and prevents drift.
secrets.ini. Git history is forever
— a leaked credential in a committed file lives on even after removal.
config.read([defaults, system, user, local]). Later files
override earlier ones. This lets defaults ship with the app while allowing
per-user and per-environment overrides without duplicating config.
read()'s return value in critical paths.
Silent failure to load a required config file causes mystery bugs. If a
config is required, use read_file(open(path)) — it raises
FileNotFoundError loudly.