When I first encountered configuration files in a real codebase, I was confused. Why weren't settings just hardcoded? Why INI files instead of JSON? After working with configparser extensively, I get it now. Here's everything I've learned.

Why INI Files?

INI files have been around since the 1980s. They look like this:

[database]
host = localhost
port = 5432
name = myapp
 
[logging]
level = INFO
path = /var/log/app.log

They're human-readable, easy to edit, and don't require understanding data structures. Your ops team, your PM, even your CEO can edit an INI file without breaking anything.

Python's configparser module reads and writes this format. It's in the standard library—no pip install needed.

Reading INI Files with ConfigParser

The basics are simple:

import configparser
 
config = configparser.ConfigParser()
config.read('config.ini')
 
# Access values using dict-like syntax
host = config['database']['host']
port = config['database']['port']
 
print(f"Connecting to {host}:{port}")
# Connecting to localhost:5432

A few things I wish I'd known earlier:

  1. All values are strings. That port above is '5432', not 5432.
  2. Keys are case-insensitive by default. config['database']['HOST'] works too.
  3. read() returns a list of successfully read files. It doesn't raise an error if the file is missing.
# This doesn't raise an error!
config.read('nonexistent.ini')  # Returns []
 
# Check if config was loaded
files = config.read('config.ini')
if not files:
    raise FileNotFoundError("Config file missing!")

Writing and Updating Config Files

Creating a config from scratch:

import configparser
 
config = configparser.ConfigParser()
 
# Add sections and values
config['database'] = {
    'host': 'localhost',
    'port': '5432',
    'name': 'myapp'
}
 
config['logging'] = {}
config['logging']['level'] = 'DEBUG'
config['logging']['path'] = '/var/log/app.log'
 
# Write to file
with open('config.ini', 'w') as f:
    config.write(f)

The output:

[database]
host = localhost
port = 5432
name = myapp
 
[logging]
level = DEBUG
path = /var/log/app.log

Updating an existing config:

config = configparser.ConfigParser()
config.read('config.ini')
 
# Modify values
config['database']['port'] = '5433'
config['logging']['level'] = 'WARNING'
 
# Add a new section
config['cache'] = {
    'enabled': 'true',
    'ttl': '3600'
}
 
# Save changes
with open('config.ini', 'w') as f:
    config.write(f)

Important: The entire file is rewritten. Comments in your original INI file will be lost. This caught me off guard the first time.

Type Conversion Helpers

Since everything is stored as strings, you need conversion helpers:

config = configparser.ConfigParser()
config.read_string("""
[server]
port = 8080
debug = yes
timeout = 30.5
workers = 4
""")
 
# The wrong way
port = config['server']['port']
print(type(port))  # <class 'str'>
 
# The right way
port = config.getint('server', 'port')
debug = config.getboolean('server', 'debug')
timeout = config.getfloat('server', 'timeout')
 
print(type(port))     # <class 'int'>
print(type(debug))    # <class 'bool'>
print(type(timeout))  # <class 'float'>

Boolean Parsing

getboolean() is generous about what it accepts:

# All of these are True:
# yes, true, on, 1
 
# All of these are False:
# no, false, off, 0
 
config.read_string("""
[features]
a = yes
b = true
c = on
d = 1
e = no
f = false
g = off
h = 0
""")
 
for key in 'abcdefgh':
    print(f"{key}: {config.getboolean('features', key)}")
# a: True, b: True, c: True, d: True
# e: False, f: False, g: False, h: False

This flexibility is intentional—different teams use different conventions, and configparser handles them all.

Default Values and Fallbacks

Two approaches to handle missing values:

1. The fallback Parameter

# If the key doesn't exist, use the fallback
timeout = config.getint('database', 'timeout', fallback=30)
ssl = config.getboolean('database', 'ssl', fallback=False)
pool_size = config.get('database', 'pool_size', fallback='5')

2. The DEFAULT Section

Values in [DEFAULT] are inherited by all sections:

[DEFAULT]
timeout = 30
retry_count = 3
 
[database]
host = localhost
# timeout and retry_count are inherited
 
[api]
host = api.example.com
timeout = 60  # Override the default
config = configparser.ConfigParser()
config.read('config.ini')
 
# Both work
print(config.getint('database', 'timeout'))  # 30 (inherited)
print(config.getint('api', 'timeout'))       # 60 (overridden)
print(config.getint('database', 'retry_count'))  # 3 (inherited)

Checking for Existence

# Check if a section exists
if config.has_section('database'):
    # ...
 
# Check if a key exists
if config.has_option('database', 'password'):
    password = config['database']['password']
else:
    password = input("Enter database password: ")

Interpolation: Basic and Extended

Interpolation lets you reference other values within your config. This keeps things DRY.

Basic Interpolation (Default)

Use %(key)s to reference values in the same section:

[paths]
base_dir = /opt/myapp
data_dir = %(base_dir)s/data
log_dir = %(base_dir)s/logs
cache_dir = %(data_dir)s/cache
config = configparser.ConfigParser()
config.read('config.ini')
 
print(config['paths']['cache_dir'])
# /opt/myapp/data/cache

The %(key)s syntax might look weird, but it's borrowed from Python's old % string formatting.

Extended Interpolation

For referencing values across sections, use ExtendedInterpolation:

config = configparser.ConfigParser(
    interpolation=configparser.ExtendedInterpolation()
)

Now you can use ${section:key} syntax:

[common]
app_name = MyApp
version = 1.0.0
 
[database]
name = ${common:app_name}_production
connection_string = postgresql://${database:host}:${database:port}/${database:name}
host = localhost
port = 5432
 
[logging]
prefix = [${common:app_name} v${common:version}]
config = configparser.ConfigParser(
    interpolation=configparser.ExtendedInterpolation()
)
config.read('config.ini')
 
print(config['database']['name'])
# MyApp_production
 
print(config['database']['connection_string'])
# postgresql://localhost:5432/MyApp_production
 
print(config['logging']['prefix'])
# [MyApp v1.0.0]

Disabling Interpolation

Sometimes you have values with % or $ that shouldn't be interpreted:

# Option 1: Escape with %%
# password = my%%secret%%pass
 
# Option 2: Disable interpolation entirely
config = configparser.ConfigParser(interpolation=None)

Multiple File Reading and Merging

This is where configparser really shines for production use. You can layer multiple config files, with later files overriding earlier ones:

config = configparser.ConfigParser()
 
# Files are read in order; later values override earlier ones
config.read([
    'defaults.ini',      # Base defaults
    '/etc/myapp/config.ini',  # System-wide settings
    '~/.myapp.ini',      # User preferences
    'local.ini'          # Local development overrides
])

A practical pattern I use:

from pathlib import Path
 
def load_config(app_name: str) -> configparser.ConfigParser:
    config = configparser.ConfigParser()
    
    config_files = [
        Path(__file__).parent / 'defaults.ini',
        Path(f'/etc/{app_name}/config.ini'),
        Path.home() / f'.{app_name}.ini',
        Path('config.ini'),
        Path('.env.ini'),  # Local overrides (gitignored)
    ]
    
    # read() silently skips missing files
    loaded = config.read([str(f) for f in config_files])
    
    print(f"Loaded config from: {loaded}")
    return config
 
config = load_config('myapp')

Reading from Strings

Sometimes config comes from environment variables or APIs:

# From a string
config.read_string("""
[temp]
key = value
""")
 
# From a dict
config.read_dict({
    'database': {
        'host': 'localhost',
        'port': '5432'
    }
})

Common Patterns

Pattern 1: Config as a Dataclass

Validate and type your config at load time:

import configparser
from dataclasses import dataclass
from pathlib import Path
 
@dataclass
class DatabaseConfig:
    host: str
    port: int
    name: str
    user: str
    password: str
 
@dataclass
class AppConfig:
    debug: bool
    log_level: str
    log_path: Path
    database: DatabaseConfig
 
def load_config(path: str) -> AppConfig:
    parser = configparser.ConfigParser()
    if not parser.read(path):
        raise FileNotFoundError(f"Config not found: {path}")
    
    return AppConfig(
        debug=parser.getboolean('app', 'debug', fallback=False),
        log_level=parser.get('app', 'log_level', fallback='INFO'),
        log_path=Path(parser.get('app', 'log_path', fallback='/var/log/app.log')),
        database=DatabaseConfig(
            host=parser.get('database', 'host'),
            port=parser.getint('database', 'port'),
            name=parser.get('database', 'name'),
            user=parser.get('database', 'user'),
            password=parser.get('database', 'password'),
        )
    )
 
# Now you get autocompletion and type checking
config = load_config('config.ini')
print(config.database.host)  # IDE knows this is a string

Pattern 2: Environment Variable Override

Let environment variables override config file values:

import os
import configparser
 
class EnvOverrideConfig(configparser.ConfigParser):
    def get(self, section, option, **kwargs):
        # Check for ENV_SECTION_OPTION first
        env_key = f"{section.upper()}_{option.upper()}"
        if env_key in os.environ:
            return os.environ[env_key]
        return super().get(section, option, **kwargs)
 
config = EnvOverrideConfig()
config.read('config.ini')
 
# If DATABASE_HOST is set, it overrides the config file
host = config.get('database', 'host')

Pattern 3: Singleton Config

Load config once, use everywhere:

# config.py
import configparser
from functools import lru_cache
 
@lru_cache(maxsize=1)
def get_config() -> configparser.ConfigParser:
    config = configparser.ConfigParser()
    config.read('config.ini')
    return config
 
# app.py
from config import get_config
 
config = get_config()  # Returns cached instance

When to Use configparser vs TOML vs JSON

This is the question I had when I started. Here's how I think about it now:

Use INI / configparser when:

  • Your config is flat. Sections with simple key-value pairs.
  • Non-developers might edit it. INI is universally understood.
  • You want stdlib only. No dependencies.
  • You have legacy systems. INI has been around forever.
# Simple, flat, obvious
[database]
host = localhost
port = 5432

Use TOML when:

  • You need nested structures. TOML handles arrays and nested tables well.
  • You're configuring a Python package. pyproject.toml is standard.
  • You want modern, expressive syntax. TOML is more powerful than INI.
# Nested structures, arrays, typed values
[database]
host = "localhost"
port = 5432
replicas = ["db1.example.com", "db2.example.com"]
 
[database.pool]
min_size = 5
max_size = 20

Note: Python 3.11+ includes tomllib in stdlib for reading TOML. For writing, you still need tomlkit or similar.

Use JSON when:

  • Machines generate/consume the config. APIs, data pipelines.
  • You need complex nested data. JSON handles arbitrary nesting.
  • Interoperability matters. Every language speaks JSON.
{
  "database": {
    "host": "localhost",
    "port": 5432,
    "replicas": ["db1.example.com", "db2.example.com"]
  }
}

Downsides: No comments (in standard JSON), harder for humans to edit, easy to break with a misplaced comma.

Use YAML when:

Honestly? I avoid YAML for config when I can. It's powerful but has surprising edge cases (no being parsed as False, significant whitespace issues). If your ecosystem uses it (Kubernetes, Ansible), fine. Otherwise, I prefer TOML or INI.

My Rule of Thumb

NeedChoice
Simple app configINI (configparser)
Python project settingsTOML (pyproject.toml)
API/machine configJSON
DevOps/infrastructureYAML (if required by tooling)

Quick Reference

import configparser
 
config = configparser.ConfigParser()
 
# Reading
config.read('config.ini')              # From file(s)
config.read_string('[s]\nk=v')         # From string
config.read_dict({'s': {'k': 'v'}})    # From dict
 
# Accessing values
config['section']['key']               # String value
config.get('section', 'key')           # String value
config.getint('section', 'key')        # Integer
config.getfloat('section', 'key')      # Float
config.getboolean('section', 'key')    # Boolean
 
# With fallbacks
config.get('s', 'k', fallback='default')
config.getint('s', 'k', fallback=0)
 
# Checking existence
config.has_section('section')
config.has_option('section', 'key')
 
# Iterating
config.sections()                      # List of section names
config.items('section')                # List of (key, value) tuples
config['section'].keys()               # Keys in section
 
# Writing
config['section'] = {'key': 'value'}
config['section']['key'] = 'new_value'
with open('config.ini', 'w') as f:
    config.write(f)
 
# Interpolation
# Basic: %(key)s references same section
# Extended: ${section:key} cross-section
config = configparser.ConfigParser(
    interpolation=configparser.ExtendedInterpolation()
)

Wrapping Up

configparser isn't flashy, but it's reliable. It's been in Python since version 2, it has no dependencies, and it handles the most common configuration patterns well.

For simple application settings, environment-specific overrides, and human-editable config files, it's often the right choice. When you need more—nested data, arrays, typed values—reach for TOML.

The best config format is the one your team can maintain without breaking things. Often, that's a humble INI file.

React to this post: