The Quiet Terminal

← Practical Tips

Reading and writing JSON config files, the boring reliable way

2026-04-03 · 3 min read

Sooner or later every small tool I write wants to remember something between runs: the last folder I opened, the window size, that I prefer dark mode. JSON is what I reach for, because the entire API is two functions and one gotcha — and you can learn the gotcha here in the next few minutes instead of at eleven at night.

The whole API: load and dump

import json
from pathlib import Path

config_file = Path.home() / ".viewer.json"

def load_config():
    with config_file.open(encoding="utf-8") as f:
        return json.load(f)

def save_config(config):
    with config_file.open("w", encoding="utf-8") as f:
        json.dump(config, f, indent=2)

indent=2 is the part people skip and shouldn't. Without it you get one enormous line; with it, the file looks like this:

{
  "theme": "dark",
  "font_size": 13,
  "recent_files": [
    "notes.txt",
    "receipts-2026-03.pdf"
  ]
}

The computer doesn't care either way. You do — an indented file can be read by eye, hand-edited in a pinch, and a diff between two versions changes one line instead of all of them.

Where the file should live

The tempting answer is config.json next to the script, and it breaks in two ways. First, the working directory is wherever you launched from — double-click the script or run it from cron and "next to the script" quietly becomes "somewhere else entirely". Second, on machines where the program folder is read-only, saving fails outright. For personal tools I use Path.home() / ".viewer.json": home is writable, it survives reinstalls of the script, and I can find it at a moment's notice. Bigger applications graduate to the platform's proper config directory, but that's a refactor, not a starting point.

Merging defaults into user config

Configs age. You ship version 2 with a new autosave key, and every existing config file on earth lacks it. The fix is to layer the user's file on top of defaults:

DEFAULTS = {"theme": "light", "font_size": 12, "autosave": True}

def load_config():
    config = dict(DEFAULTS)   # a copy, so we never mutate DEFAULTS
    if config_file.exists():
        with config_file.open(encoding="utf-8") as f:
            config.update(json.load(f))
    return config

The dict(DEFAULTS) copy matters: config.update(...) would otherwise edit your DEFAULTS dict in place, and the defaults would drift as the app runs. One honest limit: update() merges only the top level — a nested "editor": {...} in the user file replaces the whole nested default. Fine for flat configs; worth knowing before it surprises you.

The round-trip gotcha: tuples come back as lists

size = (800, 600)
back = json.loads(json.dumps(size))
print(back, type(back))
[800, 600] <class 'list'>

JSON has no tuples. Your window size goes in as a tuple and comes back as a list. Unpacking survives — width, height = back works on both — but any comparison against a tuple, or any use as a dict key, passes on the first run and fails on the second, which is the most annoying category of bug there is. My fix is to just store lists in config and stop caring; converting back to a tuple is a job for the boundary, not the format.

The opinion

I don't want my config format to be interesting. JSON is boring in exactly the right ways: it's in the standard library, any editor can open it, indent=2 makes it readable by humans and diffable by machines, and its one trap is small enough to memorize. Every alternative I've tried offered a feature I didn't need and cost an evening I did.