Python Backends Configuration — JSON, Defaults, and Overrides
Learn how to build flexible Python backends using JSON, defaults, env vars, & CLI overrides -a guide to clean, layered configuration management.
Lesson 05 Configuration Management
“Every program carries a silent brain — its configuration.”
— Teslaverse University, Book of Clarity
The command room dimmed.
A constellation of key–value pairs hovered above the console like a neural map.
Nova: “If code is behavior, config is personality.”
Teslanic: “And personality should be deliberate — not hard-coded.”
Lesson 05 is where your backend begins to remember who it is.
1 · What JSON Is (and Why Backends Love It)
JSON (JavaScript Object Notation) is a lightweight, structured text format — the common language of data interchange.
It is:
- Human-readable
- Language-agnostic
- Ideal for configs, APIs, logs, and backend state
- Git-friendly (easy to diff and version)
- Governed by strict but simple syntax (double quotes, no trailing commas)
Backends love JSON because it moves between systems without friction — it’s data diplomacy in its purest form.
2 · The Config Ladder: How Real Systems Decide Who They Are
Every serious backend defines its identity through layers — a chain of overrides, each closer to the point of execution:
- Defaults — safe starting values
- settings.json — local developer overrides
- Environment variables — deployment-level overrides
- CLI flags — last-mile, runtime overrides
Golden rule: later wins.
This hierarchy keeps your configuration flexible but predictable, giving you control without chaos.
3 · Reading JSON
from pathlib import Path
import json
def read_json(path: str | Path) -> dict:
p = Path(path)
with p.open("r", encoding="utf-8") as f:
return json.load(f)
Readable, reliable, and Pythonic.
4 · Writing JSON
from pathlib import Path
import json
def write_json(path: str | Path, data: dict) -> None:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
Readable indentation will save your sanity down the road. Future you will thank you.
5 · Default Fallbacks and Merging Configs
A proper config-driven backend should:
- Run even if external files are missing
- Fall back to defaults
- Allow targeted overrides (not wholesale copies)
- Merge nested structures cleanly
Deep merging grants flexibility without fragility — it’s composable configuration.
6 · Real Backend Example — Load → Override → Run
Below is a production-shaped configuration loader that gracefully handles all layers:
from __future__ import annotations
from pathlib import Path
import json, os, argparse
from copy import deepcopy
# --------------------------------------
# Deep merge
# --------------------------------------
def deep_merge(base: dict, override: dict) -> dict:
out = deepcopy(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
# --------------------------------------
# Layers
# --------------------------------------
def load_defaults() -> dict:
return {
"app": {"name": "TaskCLI", "env": "dev"},
"logging": {"level": "INFO"},
"db": {"engine": "sqlite", "path": "taskcli.db"},
"features": {"async_tasks": False, "streamlit": False},
}
def load_json_file(path: Path) -> dict:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def infer_type(s: str):
if s.lower() in {"true", "false"}:
return s.lower() == "true"
try:
if "." in s:
return float(s)
return int(s)
except ValueError:
return s
def env_overrides(prefix: str = "APP_") -> dict:
out = {}
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
_, raw = key.split(prefix, 1)
path = raw.split("__") # e.g. logging__level
cursor = out
for part in path[:-1]:
cursor = cursor.setdefault(part, {})
cursor[path[-1]] = infer_type(value)
return out
def parse_cli(argv=None) -> tuple[dict, Path]:
parser = argparse.ArgumentParser()
parser.add_argument("--env", choices=["dev","test","prod"])
parser.add_argument("--log-level", choices=["DEBUG","INFO","WARNING","ERROR"])
parser.add_argument("--db-path")
parser.add_argument("--enable-async", action="store_true")
parser.add_argument("--enable-streamlit", action="store_true")
parser.add_argument("--settings", default="settings.json")
args = parser.parse_args(argv)
cli = {}
if args.env:
cli = deep_merge(cli, {"app": {"env": args.env}})
if args.log_level:
cli = deep_merge(cli, {"logging": {"level": args.log_level}})
if args.db_path:
cli = deep_merge(cli, {"db": {"path": args.db_path}})
if args.enable_async:
cli = deep_merge(cli, {"features": {"async_tasks": True}})
if args.enable_streamlit:
cli = deep_merge(cli, {"features": {"streamlit": True}})
return cli, Path(args.settings)
# --------------------------------------
# Build Final Config
# --------------------------------------
def build_config(argv=None) -> dict:
defaults = load_defaults()
cli_overrides, settings_path = parse_cli(argv)
settings = load_json_file(settings_path)
env = env_overrides()
cfg = deep_merge(defaults, settings)
cfg = deep_merge(cfg, env)
cfg = deep_merge(cfg, cli_overrides)
return cfg
# --------------------------------------
# Application Entrypoint
# --------------------------------------
def run_app(cfg: dict) -> None:
print(f"Starting {cfg['app']['name']} in {cfg['app']['env']} mode…")
print(f"Log level: {cfg['logging']['level']}")
print(f"DB: {cfg['db']['engine']} @ {cfg['db']['path']}")
print(f"Features: async={cfg['features']['async_tasks']}, streamlit={cfg['features']['streamlit']}")
if __name__ == "__main__":
config = build_config()
run_app(config)
Clean, layered, and production-minded.
7 · Example Settings and Overrides
settings.json
{
"logging": { "level": "DEBUG" },
"db": { "path": "dev_tasks.db" }
}
Environment override
export APP_logging__level=WARNING
export APP_features__async_tasks=true
CLI override
python config_loader.py --env prod --db-path /srv/tasks.db --enable-streamlitNotes
- Default fallbacks keep your app runnable with zero external files.
- Deep merge allows clean, minimal overrides.
- Environment variables are deployment-friendly and secure.
- TaskCLI can adopt this structure today for DB path, log level, and feature flags.
- Future Streamlit dashboards can read from the same config file.
- Async tasks should always be feature-flagged, not hard-wired.
-* This is a configuration management system for a Python command-line application called TaskCLI.
Let’s break it down piece by piece
Purpose
This script dynamically builds a runtime configuration for a CLI app by merging settings from multiple layers:
- Built-in defaults
- A JSON file (e.g. settings.json)
- Environment variables
- Command-line arguments
Each subsequent layer overrides the previous one.
1. The Deep Merge Function
def deep_merge(base: dict, override: dict) -> dict:
out = deepcopy(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
What it does:
It recursively merges dictionaries — useful when merging nested configurations (e.g., updating only one field in "logging" while keeping others intact).
Example:
deep_merge({"logging": {"level": "INFO"}}, {"logging": {"level": "DEBUG"}})
# → {'logging': {'level': 'DEBUG'}}
2. The Layers
Default Configuration
load_defaults()
Provides a base config:
{
"app": {"name": "TaskCLI", "env": "dev"},
"logging": {"level": "INFO"},
"db": {"engine": "sqlite", "path": "taskcli.db"},
"features": {"async_tasks": False, "streamlit": False},
}
JSON Settings File
load_json_file(path)
If settings.json exists, it loads it and returns a dictionary (otherwise {}).
So users can define app settings in a file like:
{
"logging": {"level": "DEBUG"},
"features": {"async_tasks": true}
}Environment Variable Overrides
env_overrides(prefix="APP_")
Scans all environment variables that start with APP_ and maps them into nested dictionaries using double underscores __.
Example:
export APP_LOGGING__LEVEL=ERROR
export APP_FEATURES__STREAMLIT=true
Becomes:
{
"logging": {"level": "ERROR"},
"features": {"streamlit": True}
}Command-Line Overrides
parse_cli(argv)
Supported flags:
--env [dev|test|prod]
--log-level [DEBUG|INFO|WARNING|ERROR]
--db-path <path>
--enable-async
--enable-streamlit
--settings <path to json file>
Each CLI flag merges into the configuration.
Example:
python app.py --env prod --log-level DEBUG --enable-asyncOverrides:
{
"app": {"env": "prod"},
"logging": {"level": "DEBUG"},
"features": {"async_tasks": True}
}
🧩 3. Final Configuration Assembly
def build_config(argv=None):
defaults = load_defaults()
cli_overrides, settings_path = parse_cli(argv)
settings = load_json_file(settings_path)
env = env_overrides()
cfg = deep_merge(defaults, settings)
cfg = deep_merge(cfg, env)
cfg = deep_merge(cfg, cli_overrides)
return cfgIt merges everything in the correct order of precedence:
|
Priority |
Source |
Description |
|
1️⃣ |
Defaults |
base layer |
|
2️⃣ |
JSON settings |
persistent configuration |
|
3️⃣ |
Environment vars |
system-wide overrides |
|
4️⃣ |
CLI args |
runtime overrides (highest priority) |
🚀 4. The Entrypoint
if __name__ == "__main__":
config = build_config()
run_app(config)run_app() just prints out the resolved config for now — a placeholder for real startup logic.
Example Run:
$ export APP_DB__PATH=/tmp/test.db
$ python main.py --env prod --enable-streamlitOutput:
Starting TaskCLI in prod mode…
Log level: INFO
DB: sqlite @ /tmp/test.db
Features: async=False, streamlit=TrueIn Summary
✅ Combines multiple configuration sources
✅ Supports nested overrides
✅ Converts env vars like APP_LOGGING__LEVEL → cfg["logging"]["level"]
✅ Clean separation of concerns
✅ Ideal for CLI or microservice apps with flexible config layers
In essence:
This is a lightweight, extensible configuration loader that supports hierarchical overrides from defaults, JSON files, environment variables, and CLI arguments.
Summary
Configuration is your program’s memory of who it should be today.
Keep it layered, structured, and override-friendly — and your backend will stay flexible without becoming fragile.