bestconfig
One Config object over every place a Python project hides its settings
A configuration library that removes the setup step entirely. Instantiate Config() and it walks up from the current file to the project root, finds the YAML, JSON, INI, .env and Python config files along the way, folds in environment variables, and exposes the result through one typed accessor.
$ pip install bestconfig # app/main.py — config.yaml and .env live two levels upfrom bestconfig import Config config = Config("myconfig.json")# that was the entire configuration step mode = config.logger.mode # 'WARNING'port = config.int('PORT') # 5050pw = config.get('DATABASE_PASSWORD')config.to_dict() -> every source, mergedThe whole setup step, taken from the project README.
The problem
Every Python project reinvents the same fifty lines: find the config file, decide whether the environment overrides it, parse whichever formats this project happens to use, cast the strings, and produce a friendly error when something is missing. It is not hard work, and it is never the same twice.
bestconfig deletes the step. Config() scans upward from the calling file to
the project root, picks up every recognised config file it passes, folds in the
environment, and hands back a single object.
What I wanted from the API
No paths. A file in app/main.py should find config.yaml at the repository
root without being told where it is. Discovery walks up, so moving a module
around does not break its configuration.
Defaults matching what people actually name things. Every combination of
config, configuration, settings, setting and conf with .json,
.yaml, .ini, .env and .cfg, plus the specific names .env, env_file
and config.py. Most projects pass no arguments at all.
Missing values behave differently depending on how you ask. config['key']
raises, config.get('key') returns None, config.get('key', default) returns
the default, and config.assert_contains('key') fails fast at startup. The
choice of accessor is the choice of failure mode — that is the point.
Types resolved once, at the edge. YAML and JSON already carry types; string
values from .env are then interpreted as Python literals, so LIST=[1, 2]
arrives as a list. When that guessing is unwanted, get_raw turns it off, and
config.int(...) and friends cast explicitly, returning None rather than
throwing when the value is not convertible.
Runtime mutation is a first-class case. set, insert (a dict or another
file) and update_from_locals exist because configuration is often derived —
composing a full name, joining a URL — and those derived values deserve to live
in the same place as the ones read from disk.
Where it landed
Nine releases on PyPI, currently 1.3.6, and the most-starred library I have written. It is the project that taught me that an API’s real surface is its failure behaviour, not its happy path — almost every issue and change was about what happens when a value is not there.
- .yaml / .yml / .json
- Parsed with native types — `port: 5050` arrives as an int.
- .ini / .cfg
- Standard section-based config files.
- .py
- Python config modules, skipped if they construct Config() themselves to avoid recursion.
- .env / env_file
- KEY=VALUE files, including values that parse as Python literals.
- Environment variables
- Existing and newly set, merged with the file sources.
- dict
- Plain dictionaries inserted at runtime via `config.insert(...)`.
Defaults are the names people already use, so most projects need no arguments at all.
- config.name
- Attribute access for the common case.
- config['name']
- Dict access — raises KeyError when absent.
- config.get('a.b.c', default)
- Dotted path to any depth, with a default instead of an exception.
- config.int / float / str / list / dict
- Typed getters that return None when the cast fails.
- config.get_raw('key')
- Opt out of type interpretation entirely.
- config.assert_contains('key')
- Fail loudly at startup if a required setting never loaded.
One value, several ways to ask for it — chosen by what you want to happen when it is missing.