I use dictionaries the way some people use sticky notes: for anything that has a name. Device names to IP addresses, setting names to values, month numbers to names. The basics fit in one post — including the mistake I made for months, and the KeyError that finally taught me the safe version.
.items()Here's my home-network cheat sheet as a dict. When I want both the name and the address, .items() hands over both at once:
devices = {"router": "192.168.1.1", "nas": "192.168.1.20", "printer": "192.168.1.9"}
for name, ip in devices.items():
print(f"{name:<8} {ip}")
router 192.168.1.1
nas 192.168.1.20
printer 192.168.1.9
(The :<8 pads the name to eight characters — the same f-string trick as always.) For a long time I wrote for name in devices: and then looked up devices[name] inside the loop. It works, but .items() says what it means.
.get(): asking with a fallbackThe settings-file pattern — some keys are always there, some are optional:
settings = {"theme": "dark", "autosave": True}
theme = settings.get("theme", "light")
volume = settings.get("volume", 50) # not in settings — 50, no crash
.get(key, default) returns the value if the key exists and your default if it doesn't. The line reads the same both ways, which is the point: optional settings stop being a special case.
One more tool in the same family: "laptop" in devices is a membership test that costs nothing and answers yes or no. Between in, .get(), and letting a KeyError through on purpose, I can say three different things about a key — might be there, probably is, and must be.
Square brackets add or update — same line either way:
devices["printer"] = "192.168.1.10" # update an existing key
devices["camera"] = "192.168.1.14" # add a new one
defaults = {"retries": 3, "timeout": 30}
user_prefs = {"timeout": 10}
config = {**defaults, **user_prefs} # {'retries': 3, 'timeout': 10}
The {**a, **b} merge builds a new dict from both, and later values win — exactly what you want when user preferences override defaults.
Dicts hold whatever you put in them, including other dicts. Config-shaped data lives happily two levels deep:
host = {
"name": "nas",
"ip": "192.168.1.20",
"disks": {"/": "78%", "/data": "41%"},
}
print(host["disks"]["/data"]) # 41%
Square brackets chain left to right: host, then its disks dict, then the key inside it. Three levels deep is usually where I start wanting real objects — but for config-shaped data, nesting stays readable longer than you'd expect.
And then one day I asked for a device that isn't in the dict:
print(devices["laptop"])
KeyError: 'laptop'
The message is the exact missing key, which makes KeyError one of the friendliest errors once you know how to read a traceback from the bottom up — I wrote a whole post about that. My rule now: if a key might legitimately be missing, an optional setting or a device that's offline, use .get(). If a missing key means my data is broken, let the KeyError happen, loudly. Silence isn't the same as handling.